diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index c6243f3529..200f66f538 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -13,6 +13,7 @@ import DropdownV2Inner from './DropdownV2Inner.svelte' import { pointerDownOutside } from '$lib/utils' import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte' + import type { MenubarMenuElements } from '@melt-ui/svelte' import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte' import Button from '$lib/components/common/button/Button.svelte' import { twMerge } from 'tailwind-merge' @@ -40,7 +41,10 @@ size?: ButtonType.UnifiedSize btnText?: string buttonReplacement?: import('svelte').Snippet - menu?: import('svelte').Snippet + // In customMenu mode the snippet receives the melt-ui `item` action + // store so consumers can wrap their own rows in (or + // `use:melt={$item}`) and get arrow-key navigation + aria wiring. + menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]> maxHeight?: string | undefined } @@ -172,7 +176,7 @@ transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }} > {#if customMenu} - {@render menu?.()} + {@render menu?.({ item, close })} {:else}
{ @@ -1829,6 +1835,30 @@ $effect(() => { lang = scriptLangToEditorLang(scriptLang) }) + + // Opt-in (syncExternalCode): reflect external `code` prop mutations into + // Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g. + // the inline rawscript in the flow editor — otherwise mutate the prop + // without Monaco ever showing the change (the AI chat editing a flow + // module's content in a session is the motivating case). Gated off by + // default: Editor is sensitive and most callers either bind:code (and + // carry their own external-sync) or treat code as init-only, so a blanket + // setValue would risk clobbering them. The `getValue() !== code` guard + // keeps the caret intact when the change originated from typing inside + // Monaco (which round-trips code back via `$bindable`, re-firing this + // effect with `code === getValue()`). + let lastExternalCodeSync = code + $effect(() => { + if (!syncExternalCode) return + if (code === lastExternalCodeSync) return + lastExternalCodeSync = code + if (!editor) return + untrack(() => { + if (editor!.getValue() !== code) { + editor!.setValue(code ?? '') + } + }) + }) $effect(() => { filePath = computePath(path) }) diff --git a/frontend/src/lib/components/FlowDiffViewer.svelte b/frontend/src/lib/components/FlowDiffViewer.svelte index 86fc002795..3539cbc5fe 100644 --- a/frontend/src/lib/components/FlowDiffViewer.svelte +++ b/frontend/src/lib/components/FlowDiffViewer.svelte @@ -6,13 +6,28 @@ interface Props { beforeYaml: string afterYaml: string + /** Side-by-side vs unified. Leave undefined to let + * FlowGraphDiffViewer show its own user-facing toggle (matches the + * pre-fork-diff-drawer behavior). */ + inlineDiff?: boolean + /** Forwarded to FlowGraphDiffViewer — render an empty surface + * placeholder for the "before" / "after" pane when the item is + * added / removed. */ + beforeMissing?: boolean + afterMissing?: boolean } - let { beforeYaml, afterYaml }: Props = $props() + let { + beforeYaml, + afterYaml, + inlineDiff = undefined, + beforeMissing = false, + afterMissing = false + }: Props = $props() let diffMode: 'yaml' | 'graph' = $state('graph') -
+
@@ -30,6 +45,7 @@ defaultLang="yaml" defaultOriginal={beforeYaml} defaultModified={afterYaml} + {inlineDiff} readOnly /> {/await} @@ -37,7 +53,7 @@ {#await import('$lib/components/FlowGraphDiffViewer.svelte')} {:then Module} - + {/await} {/if}
diff --git a/frontend/src/lib/components/FlowGraphDiffViewer.svelte b/frontend/src/lib/components/FlowGraphDiffViewer.svelte index 3639f0955b..caab746502 100644 --- a/frontend/src/lib/components/FlowGraphDiffViewer.svelte +++ b/frontend/src/lib/components/FlowGraphDiffViewer.svelte @@ -2,12 +2,12 @@ import type { OpenFlow } from '$lib/gen' import YAML from 'yaml' import FlowGraphV2 from './graph/FlowGraphV2.svelte' - import { Alert, Button } from './common' + import { Alert } from './common' import { computeFlowModuleDiff } from './flows/flowDiff' import { Pane, Splitpanes } from 'svelte-splitpanes' + import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import type { Viewport } from '@xyflow/svelte' const SIDE_BY_SIDE_MIN_WIDTH = 700 @@ -15,13 +15,54 @@ interface Props { beforeYaml: string afterYaml: string + /** When true, render an empty surface placeholder for the "before" + * pane in side-by-side mode (use for added items where there's no + * prior flow to show). */ + beforeMissing?: boolean + /** Same as `beforeMissing` but for the "after" pane (use for removed + * items). */ + afterMissing?: boolean + /** Render the unified single-pane diff when true, side-by-side + * otherwise. When undefined, the component renders its own + * Unified / Side-by-side toggle in the corner (legacy behavior for + * the standalone comparison page). A narrow viewer still falls back + * to unified automatically. */ + inlineDiff?: boolean | undefined } - let { beforeYaml, afterYaml }: Props = $props() + let { + beforeYaml, + afterYaml, + beforeMissing = false, + afterMissing = false, + inlineDiff = undefined + }: Props = $props() + + // Local toggle state, used only when no inlineDiff prop is supplied. + let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside') + const showLocalToggle = $derived(inlineDiff === undefined) + const effectiveInlineDiff = $derived( + inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified' + ) let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH) let beforePaneSize = $state(50) - let viewMode = $state<'sidebyside' | 'unified'>('sidebyside') + // Track the content area's rendered height so unified-mode graphs can + // grow to fill the diff box (otherwise FlowGraphV2 sits at its + // content-fit height + small floor, leaving empty space below). + let contentAreaHeight = $state(0) + + // Each FlowGraphV2 sizes itself to its own content (clamped to minHeight). + // In side-by-side mode we want both graphs to share the same height, so + // we track each side's reported height and feed back the max as minHeight + // to both. The width-graph then stays at its computed size; the shorter + // graph grows to match. + let beforeContentHeight = $state(0) + let afterContentHeight = $state(0) + const SHARED_MIN_HEIGHT = 400 + const sharedMinHeight = $derived( + Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight) + ) // Shared viewport for synchronizing both graphs in side-by-side mode let sharedViewport = $state({ x: 0, y: 0, zoom: 1 }) @@ -29,7 +70,10 @@ let beforeGraph: FlowGraphV2 | undefined = $state(undefined) let afterGraph: FlowGraphV2 | undefined = $state(undefined) - function parseFlow(yaml: string, label: 'before' | 'after'): { + function parseFlow( + yaml: string, + label: 'before' | 'after' + ): { flow: OpenFlow | undefined error: string | undefined } { @@ -49,14 +93,27 @@ } } - let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before')) - let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after')) + // For added/removed items, the caller passes empty YAML and sets the + // corresponding *Missing flag. We swap in an empty OpenFlow stub on + // that side so the unified diff path still has something to compare + // against (every module on the present side becomes added / removed). + // The side-by-side rendering uses the flag directly to draw a + // placeholder pane instead. + const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } } + + let beforeParsed = $derived.by(() => + beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before') + ) + let afterParsed = $derived.by(() => + afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after') + ) let parseError = $derived(beforeParsed.error ?? afterParsed.error) let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow) let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow) - // Determine if we should render side-by-side or unified (user controlled via toggle) - let isSideBySide = $derived(viewMode === 'sidebyside') + // Side-by-side unless the caller asked for unified, OR the viewer pane + // is too narrow to comfortably split (fallback to unified for legibility). + const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH) // Build timeline using history-based approach // In side-by-side view, mark removed modules as 'shadowed' in the After graph @@ -72,14 +129,6 @@ sharedViewport = viewport } } - - $effect(() => { - if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) { - viewMode = 'unified' - } else { - viewMode = 'sidebyside' - } - }) {#if parseError} @@ -88,10 +137,12 @@ {:else if beforeFlow && afterFlow}
- -
-
- + {#if showLocalToggle} + +
+ {#snippet children({ item })}
- + {/if} + +
{#if isSideBySide} - -
- +
{/if} -
- - -
{#if isSideBySide} -
-
- - {#snippet leftHeader()} - Before - {/snippet} - -
+
+ {#if beforeMissing} + + Before (no prior version) + + {:else} +
+ (beforeContentHeight = h)} + > + {#snippet leftHeader()} + Before + {/snippet} + +
+ {/if}
-
-
- - {#snippet leftHeader()} - After - {/snippet} - -
+
+ {#if afterMissing} + + After (flow deleted) + + {:else} +
+ (afterContentHeight = h)} + > + {#snippet leftHeader()} + After + {/snippet} + +
+ {/if}
@@ -219,7 +299,7 @@ editMode={false} download={false} scroll={false} - minHeight={400} + minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)} triggerNode={false} />
@@ -231,3 +311,31 @@

Loading graphs...

{/if} + + diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 710ce9bd5d..9dc36e3503 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -81,7 +81,7 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' import LabelsInput from './LabelsInput.svelte' @@ -134,7 +134,9 @@ onSaveDraftError, onSaveDraft, onNavigate, - disableAi + disableAi, + initialTestPanelCollapsed = false, + initialPathChosen = false }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -626,17 +628,23 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if ( + // "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the + // editor in place rather than navigating to the deployed item. + const stayHere = stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language)) - ) { + if (stayHere) { + // Re-pin parent_hash so the next deploy's conflict check is against + // the version we just wrote. script.parent_hash = newHash - sendUserToast('Deployed') - } else { - onDeploy?.({ path: script.path, hash: newHash }) } + // Always notify on a successful deploy; the consumer decides whether to + // navigate (route) or stay + sync the preview (session). Previously the + // stay/lib branch skipped onDeploy, so session previews didn't sync after + // a "Deploy & Stay here" or lib-script deploy. + onDeploy?.({ path: script.path, hash: newHash, stay: stayHere }) } catch (error) { onDeployError?.({ path: script.path, error }) sendUserToast(`Error while saving the script: ${error.body || error.message}`, true) @@ -793,6 +801,12 @@ loadingDraft = false } + // Inside an AI session pane (which injects an aiChatManager via context) the + // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace + // fork, Exit & See details, Export — don't make sense: the session always + // stays put and is already scoped to a fork. Only "Show diff" is kept. + const inSessionPane = !!getContext('aiChatManager') + function computeDropdownItems( initialPath: string, savedScript: NewScriptWithDraftAndDraftTriggers | undefined, @@ -801,26 +815,30 @@ let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ - { - label: 'Deploy & Stay here', - onClick: () => { - handleEditScript(true) - } - }, - { - label: 'Fork', - onClick: () => { - window.open(`/scripts/add?template=${initialPath}`) - } - }, - ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ...(!inSessionPane ? [ { - label: 'Edit in workspace fork', + label: 'Deploy & Stay here', onClick: () => { - window.open(buildForkEditUrl('script', initialPath)) + handleEditScript(true) } - } + }, + { + label: 'Fork', + onClick: () => { + window.open(`/scripts/add?template=${initialPath}`) + } + }, + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ? [ + { + label: 'Edit in workspace fork', + onClick: () => { + window.open(buildForkEditUrl('script', initialPath)) + } + } + ] + : []) ] : []), ...(customUi?.topBar?.diff !== false && savedScript && diffDrawer @@ -852,7 +870,10 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.auto_kind + ...(!inSessionPane && + !script.draft_only && + script.kind === 'script' && + !script.auto_kind ? [ { label: 'Exit & See details', @@ -862,7 +883,7 @@ } ] : []), - ...(isWorkflowAsCode(script.content, script.language) + ...(!inSessionPane && isWorkflowAsCode(script.content, script.language) ? [ { label: 'Export as YAML/JSON', @@ -875,7 +896,11 @@ ] : [] - if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) { + if ( + !inSessionPane && + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { dropdownItems = [ { label: 'Export as YAML/JSON', @@ -901,7 +926,11 @@ } let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) + // Seed "path is already chosen" so the summary→path auto-slug (which only + // runs for new scripts with initialPath == '') doesn't clobber a path the + // caller pre-assigned. The session preview opens AI-created scripts as new + // (empty initialPath) but with a path the AI already picked. + let dirtyPath = $state(initialPathChosen) let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state( (() => { @@ -2091,6 +2120,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet + {initialTestPanelCollapsed} />
{:else} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 9fe173f0ba..a281b266da 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -160,6 +160,11 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + // When true the right-hand test/run pane mounts collapsed. The user + // can still expand it via `toggleTestPanel`. Defaults to false so the + // regular /scripts/edit route keeps its current open-by-default UX; + // the session preview opts in to save vertical real estate. + initialTestPanelCollapsed?: boolean } let { @@ -193,7 +198,8 @@ assets = $bindable(), modules = $bindable(undefined), editorBarRight, - enablePreprocessorSnippet = false + enablePreprocessorSnippet = false, + initialTestPanelCollapsed = false }: Props = $props() let initialArgs = structuredClone($state.snapshot(args)) @@ -1360,8 +1366,11 @@ // dynamic minimum below — so when the editor shrinks, the displayed test // pane grows to honor the new minimum without needing an effect. The code // pane's size is purely derived from it (100 - test). - let rawTestPanelSize = $state(30) - let storedTestPanelSize = untrack(() => rawTestPanelSize) + // `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while + // keeping the "remembered" size at 30, so the user's first toggle expands + // the pane to a sensible width rather than 0. + let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30))) + let storedTestPanelSize = 30 const testPanelSize = $derived( rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent) ) diff --git a/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte new file mode 100644 index 0000000000..2ee01b4607 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte @@ -0,0 +1,162 @@ + + + +{#if kind === 'flow'} +
+ +
+{:else if hasContent} +
+ + + + +
+ {#if contentTab === 'content'} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {/if} +
+
+{:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} +
+ +
+ {/await} +{/if} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index abee584afa..9136f52205 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import { onMount, untrack } from 'svelte' import { @@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' + import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' type Kind = WorkspaceItemKind type Item = WorkspaceItem @@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // Sibling-popover open: melt-ui's `openFocus` runs once during the close→open // transition; the picker may not be mounted yet. Retry after settle. + // Also kicks off the initial scope's fetch — drill/goUp do the same from + // their respective branches, so `ensureLoaded` is always a callback + // reaction to user navigation, never a reactive consequence. onMount(() => { const t = setTimeout(focus, 50) + const initial = untrack(() => scope) + if (initial) { + if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(initial.kind) + } return () => clearTimeout(t) }) @@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let scope = $state(untrack(() => initialScope)) let filter = $state('') + /** + * Canonical entry point for changing the picker's scope. Triggers the + * fetch for the kind(s) the new scope needs at the same point in time. + * Replaces the older "react to `scope` change via `$effect`" wiring, + * which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the + * effect ended up subscribed to the signal it fills — every fetch + * result re-fired it. With explicit callbacks the fetch is tied to + * the user's action, never to a reactive consequence of that action. + */ + function setScope(next: Scope) { + scope = next + if (!next) return + if (next.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(next.kind) + } + /** Tracks whether the last user action was mouse movement (true) or * keyboard nav (false). When false, row `mouseenter` events are ignored * — prevents the cursor from stealing the keyboard-driven highlight as @@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * mounts under a stationary cursor doesn't clobber `initialHighlight`. */ let mouseActive = $state(false) - // Seed from cache so kinds already fetched in this session render on the - // first frame. Read once at mount: melt-ui mounts a fresh picker per - // popover open, so workspace changes are picked up at the next open - // without needing this seed to be reactive. + // Seed from the last fetched snapshot so kinds already fetched in this + // session render on the first frame. Each entry is replaced once + // `loadKind` returns fresh data — stale-while-revalidate, so deploys and + // AI-created drafts surface on the next open without explicit cache + // busting. let loaded = $state>>( (() => { if (!$workspaceStore) return {} @@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level async function ensureLoaded(kind: Kind) { if (!$workspaceStore) return - if (loaded[kind]) return - loadingKind[kind] = true + // Always re-fetch. If we have nothing cached, show a spinner; if we do, + // keep displaying it and quietly swap to fresh data when it lands. + // `loaded[kind]` is read inside `untrack(...)` because this function is + // reachable from the search `$effect` below — without the untrack, + // that effect would subscribe to the signal `ensureLoaded` fills, and + // each `loaded[kind] = items` (proxy `set` notifies even when the ref + // is unchanged from cache) would refire it → runaway loop. Drill + // navigation goes through `setScope` directly so it isn't affected. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true try { const items = await loadKind($workspaceStore, kind) loaded[kind] = items @@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level } } - // Fetch the scope's kind on entry to a non-root level. The `'all'` scope - // needs every kind loaded since it merges items across them. - $effect(() => { - if (!scope) return - if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k) - else ensureLoaded(scope.kind) - }) + // Chat tools and session editor previews write drafts through + // `UserDraft` (workspace-scoped, localStorage-backed). Merge those into + // the picker so users can navigate to in-flight items that haven't been + // deployed yet. Filter to kinds the picker actually displays. + // + // Gated on the same dev flag as the rest of the sessions feature: without + // it there are no sessions, so the only UserDrafts present are the + // standalone editors' autosaves — surfacing those in the breadcrumb picker + // would be surprising (they'd appear as navigable items that 404 on the + // backend draft fetch). When the flag is off this is a no-op. + const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + function aiDraftsForKind(k: Kind): Item[] { + if (!isGlobalAiEnabled()) return [] + if (!$workspaceStore) return [] + const targetType = KIND_TO_DRAFT_TYPE[k] + return listGlobalDrafts($workspaceStore) + .filter((d) => d.type === targetType) + .map((d) => ({ + path: d.path, + summary: d.summary ?? '', + kind: k, + // `raw_app` lives on the draft envelope for legacy/raw-app distinction. + raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined + })) + } // Searching is global → load every kind. $effect(() => { @@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level leaves: Item[] } + /** Merge AI-created in-memory drafts into a kind's list. The AI may have + * scaffolded a script/flow/app via chat tools without the user saving + * yet — those drafts should be navigable from the picker. Existing items + * (same path) win to keep the backend's metadata (summary etc.). */ + function withAiDrafts(items: Item[], k: Kind): Item[] { + const ai = aiDraftsForKind(k) + if (ai.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(ai.filter((d) => !known.has(d.path))) + } + /** Inject the currently-edited item into a kind's list at its live path, * dropping the saved entry when a draft rename is in progress. Other kinds * pass through untouched. */ @@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * cached. */ function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] { if (!kinds.includes(k)) return [] - const items = withCurrent(list ?? [], k) + const items = withAiDrafts(withCurrent(list ?? [], k), k) if (items.length === 0) return [] return buildTreeFromItems(items) } @@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * one folder hierarchy. Each leaf still carries its real kind, so the row * icon and `editPathFor` routing still work; folders contain a mix. */ const allTree = $derived.by(() => { - const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k)) + const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k)) return merged.length === 0 ? [] : buildTreeFromItems(merged) }) @@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let allItems = $derived( kinds.flatMap((k) => - withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` })) + withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({ + ...it, + _key: `${k}:${it.path}` + })) ) ) @@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level function drill(entry: Entry) { if (entry.type === 'kind') { - scope = { kind: entry.kind } + setScope({ kind: entry.kind }) } else if (entry.type === 'dir') { - scope = { kind: entry.kind, dir: entry.node.fullPath } + setScope({ kind: entry.kind, dir: entry.node.fullPath }) } else { pick(entry.item) } @@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // just left, so the user sees where they came from. if (!scope.dir) { const leaving = kindKey(scope.kind) - scope = undefined + setScope(undefined) highlightedKey = leaving return } const leaving = dirKey(scope.kind, scope.dir) const parent = parentDirPath(scope.dir) - scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind } + setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }) highlightedKey = leaving } @@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level {#snippet leafRow(it: Item, secondary: string, baseClass: string)} {@const key = leafKey(it)} - {@const isHl = key === highlightedKey} - {@const isCur = isCurrent(it)} - + /> {/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} + + +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index d6b2a7818d..cd9acdbffa 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -79,20 +79,29 @@ gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), unsavedConfirmationModal, onSavedNewAppPath, + onNavigate, initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) + // Inside a session pane the AIChatManager is injected via context. Sessions + // have their own state machinery (sessionRuntime + per-fork backend), and + // the user-facing $workspaceStore stays on the main workspace even when + // the session is editing in a fork — so a UserDraft handle here would + // share its LS key with the regular /apps/edit route and clobber both + // sides' autosaves. Skip UserDraft entirely in that case. + const inSessionPane = !!getContext('aiChatManager') + const appDraftPath = newApp ? '' : (path ?? '') - const appDraftHandle = UserDraft.use('app', appDraftPath) + const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry // (`UserDraft.remove`) when it wants to force a fresh start — // `?nodraft=true`, template/hub loads, etc. - const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) + const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the // seed meta on the very first persist of this entry. After that the @@ -112,6 +121,7 @@ let firstMirror = true $effect(() => { readFieldsRecursively(stateApp) + if (!appDraftHandle) return untrack(() => { // Resolve the meta to attach BEFORE the wipe — the wipe clears // in-memory meta and would otherwise force-seed `initialRevs` @@ -884,6 +894,7 @@ rightPanelHidden={rightPanelSize === 0} bottomPanelHidden={runnablePanelSize === 0} {onSavedNewAppPath} + {onNavigate} onShowLeftPanel={() => showLeftPanel()} onShowRightPanel={() => showRightPanel()} onShowBottomPanel={() => showBottomPanel()} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 7a55f28860..5bbd0d17c7 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -64,7 +64,7 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' - import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' @@ -110,6 +110,7 @@ onHideRightPanel?: () => void onHideLeftPanel?: () => void onHideBottomPanel?: () => void + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void } let { @@ -130,7 +131,8 @@ onShowBottomPanel, onHideLeftPanel, onHideRightPanel, - onHideBottomPanel + onHideBottomPanel, + onNavigate = undefined }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -170,6 +172,14 @@ const { history, jobsDrawerOpen, refreshComponents } = getContext('AppEditorContext') + // Sessions inject an AIChatManager via context; AppEditor skips its + // UserDraft handle in that case, so the cleanup calls here must skip too + // (otherwise we'd wipe a non-session tab's autosave at the same path). The + // session-side equivalent is the View's `onDeploy` → + // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // the preview to the deployed version. + const inSessionPane = !!getContext('aiChatManager') + const loading = $state({ publish: false, save: false, @@ -229,7 +239,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -313,7 +323,6 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) - invalidatePicker($workspaceStore!, 'app') invalidateWorkspacePaths($workspaceStore!) savedApp = { summary: $summary, @@ -330,7 +339,7 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) if ($appPath !== npath) { onSavedNewAppPath?.(npath) } @@ -406,7 +415,7 @@ // The initial draft was promoted to a real path on the backend — // drop the autosave keyed on the prior (possibly empty) path so // a future "+ App" click opens on a clean slate. - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) onSavedNewAppPath?.(newEditedPath) } catch (e) { sendUserToast('Error saving initial draft', e) @@ -497,7 +506,7 @@ } sendUserToast('Draft saved') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { onSavedNewAppPath?.(newEditedPath || path) @@ -1006,7 +1015,7 @@ bind:path={newEditedPath} savedPath={$appPath || newPath || undefined} kind="app" - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
{#if $app} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 02ce70f64f..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -139,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -264,10 +264,10 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref}
diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 2fe6536aa8..64b08d621e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -164,6 +164,8 @@ export interface AppEditorProps { gotoFn?: (path: string, opt?: Record | undefined) => void unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void + /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** * Backend revs at the load that produced `app`. Used as the seed * `UserDraft` meta on the first local autosave: until the handle has diff --git a/frontend/src/lib/components/common/EditableInput.svelte b/frontend/src/lib/components/common/EditableInput.svelte index e772247c0a..e85625c58f 100644 --- a/frontend/src/lib/components/common/EditableInput.svelte +++ b/frontend/src/lib/components/common/EditableInput.svelte @@ -78,6 +78,15 @@ this component just proposes new values. }) } + // External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap + // stays active for a brief window after the menu closes — focusing our + // input during that window causes checkFocusIn to slam focus back out, which + // fires onblur=save and instantly closes the edit. A 50ms defer is enough + // for Melt's trap to release. + export function edit() { + setTimeout(startEditing, 50) + } + function save() { // Re-entry guard: Enter calls `save()` and sets `editing = false`, // which unmounts the `` and synchronously fires its `blur` diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 07ccd1efb1..c6366113c9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -3,30 +3,61 @@ import { untrack } from 'svelte' import { type ScriptLang } from '$lib/gen' import { dbSchemas, userStore, workspaceStore } from '$lib/stores' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() import { base } from '$lib/base' import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core' import { copilotInfo, copilotSessionModel } from '$lib/aiStore' + let { + hideHeader = false, + hideModeSelector = false, + forceDisabled = false, + forceDisabledMessage = '', + wideLayout = false, + emptyHint, + inputPreface + }: { + hideHeader?: boolean + hideModeSelector?: boolean + // External "you can't type here" override. Used by sessions when + // the session's committed workspace was deleted/archived so the + // chat is effectively read-only until the user moves or discards + // the session. Wins over the internal disabled derivation. + forceDisabled?: boolean + forceDisabledMessage?: string + // Forwarded to AIChatDisplay. When true, the messages / input + // columns are centered in a max-w-3xl px-8 box. Sessions opt + // in; the narrow global-chat panel leaves it off. + wideLayout?: boolean + emptyHint?: import('svelte').Snippet + inputPreface?: import('svelte').Snippet + } = $props() + const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) const hasCopilot = $derived($copilotInfo.enabled) const disabled = $derived( - !hasCopilot || + forceDisabled || + !hasCopilot || (aiChatManager.mode === AIMode.SCRIPT && aiChatManager.scriptEditorOptions?.lang && !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)) ) const disabledMessage = $derived( - !hasCopilot - ? isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` - : 'Ask an admin to enable Windmill AI in this workspace to use this chat' - : aiChatManager.mode === AIMode.SCRIPT && - aiChatManager.scriptEditorOptions?.lang && - !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) - ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` - : '' + forceDisabled + ? forceDisabledMessage + : !hasCopilot + ? isAdmin + ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + : 'Ask an admin to enable Windmill AI in this workspace to use this chat' + : aiChatManager.mode === AIMode.SCRIPT && + aiChatManager.scriptEditorOptions?.lang && + !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) + ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` + : '' ) const suggestions = [ @@ -53,6 +84,10 @@ aiChatManager.sendRequest(options) } + export function focusInput() { + aiChatDisplay?.focusInput() + } + const historyManager = aiChatManager.historyManager let aiChatDisplay: AIChatDisplay | undefined = $state(undefined) @@ -129,4 +164,9 @@ {disabled} {disabledMessage} {suggestions} + {hideHeader} + {hideModeSelector} + {wideLayout} + {emptyHint} + {inputPreface} > diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e2f21ce96..5b0e549dd0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -61,7 +61,7 @@ import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' -import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' +import { globalToolsFor, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message @@ -208,13 +208,26 @@ export class AIChatManager { private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined + disabledModes: Partial> = $state({}) + // Set by AI sessions. Enables the session-only preview tools (open_preview / + // get_preview_status) and their system-prompt guidance in GLOBAL mode; the + // global side-panel chat leaves it false so those tools aren't offered. + isSessionChat = false + // The session this manager belongs to (session chats only). Carried into the + // tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS + // session rather than the UI-active one — keeps backgrounded sessions isolated. + sessionId: string | undefined = undefined + allowedModes: Record = $derived({ - script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined, - flow: this.flowAiChatHelpers !== undefined, - app: this.appAiChatHelpers !== undefined, - navigator: true, - ask: true, - API: true, + script: + this.flowAiChatHelpers === undefined && + this.scriptEditorOptions !== undefined && + !this.disabledModes.script, + flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, + navigator: !this.disabledModes.navigator, + ask: !this.disabledModes.ask, + API: !this.disabledModes.API, // Dev-only gate. See `./global/gate.ts` for how to enable. global: isAIModeVisible(AIMode.GLOBAL) }) @@ -495,9 +508,11 @@ export class AIChatManager { this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) - this.systemMessage = prepareGlobalSystemMessage(customPrompt) - this.tools = [...globalTools] - this.helpers = {} + this.systemMessage = prepareGlobalSystemMessage(customPrompt, { + previewTools: this.isSessionChat + }) + this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) + this.helpers = this.isSessionChat ? { sessionId: this.sessionId } : {} } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -795,6 +810,12 @@ export class AIChatManager { } } + // Optional pre-flight hook called once per send, after validation but + // before any UI state mutates or backend calls go out. Sessions use + // this to commit/materialise the workspace (creating a staged fork via + // the API) so the first message targets the correct workspace. + beforeSend?: () => Promise | void + sendRequest = async ( options: { removeDiff?: boolean @@ -819,6 +840,24 @@ export class AIChatManager { if (!this.instructions.trim()) { return } + if (this.beforeSend) { + try { + await this.beforeSend() + } catch (e) { + // beforeSend commits the session's workspace before the first + // message hits the backend. If it throws, sending anyway would + // silently target the wrong workspace (typically the parent), so + // abort and tell the user — their message text stays in the input. + console.error('AIChatManager beforeSend hook failed', e) + sendUserToast( + `Could not prepare the session before sending: ${ + e instanceof Error ? e.message : String(e) + }. Your message was not sent — please try again.`, + true + ) + return + } + } try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 27adb56ef3..2ab480a706 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -76,7 +76,7 @@ onClick={() => onMenuOpen?.()} startIcon={{ icon: Menu }} iconOnly - > + />
{@render children?.()} @@ -96,5 +96,13 @@ {/if} {:else} - {@render children?.()} +
+ {@render children?.()} +
{/if} diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index e59ed4b513..5b4258d4e7 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -3,9 +3,16 @@ import { CircleHelp } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { aiChatManager } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' import type { UserQuestionDisplay } from './shared' + // Sessions inject a per-pane `AIChatManager` via context; outside of + // sessions getAiChatManager falls back to the global singleton. Without + // this, answers clicked inside a session would dispatch to the singleton's + // pending callbacks map (which doesn't have the session manager's question + // callback), and the AI loop would stall. + const aiChatManager = getAiChatManager() + interface Props { toolCallId: string userQuestion: UserQuestionDisplay diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 5a40c49e36..89b714b80f 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -2,7 +2,10 @@ import { ChevronDown } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import Button from '$lib/components/common/button/Button.svelte' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode' diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 4df548192a..65abb0e1c5 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -1,7 +1,9 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 2b9f4a09f1..56a594ff1e 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -867,6 +867,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { @@ -930,6 +931,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 563a387184..fbe07ac7b8 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -200,6 +200,9 @@ markRemovedAsShadowed?: boolean controlsPosition?: 'top' | 'bottom' outerDivClass?: string + /** Fires when the computed graph height changes. Diff views can use + * this to equalize heights of side-by-side graphs. */ + onHeight?: (height: number) => void } let { @@ -273,7 +276,8 @@ onMoveMultiple = undefined, movingIds = undefined, controlsPosition = 'top', - outerDivClass = '' + outerDivClass = '', + onHeight = undefined }: Props = $props() // Initialize note manager with fine-grained reactivity @@ -759,6 +763,7 @@ const computed = maxBottom - minY height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight) } + onHeight?.(height) } $effect(() => { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 7249f0ae05..8121ca8b70 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -66,6 +66,9 @@ } | undefined diffDrawer?: DiffDrawer | undefined + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void + /** Fired after a successful deploy; the session preview reloads on it. */ + onDeploy?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -75,6 +78,14 @@ * preference. */ sidebarStorageKey?: string liveEditorDraftStoragePath?: string + /** Initial value for the "Split with Preview" tab-bar toggle. Defaults + * to `true` (split mode, preview always pinned to the right). Set + * `false` when the editor mounts inside a context that wants single- + * view by default with the Preview tab selected — e.g. session + * previews, where the editor pane is already narrow. The user can + * still toggle the mode after mount; this prop only seeds the + * initial state. */ + defaultSplitWithPreview?: boolean } let { @@ -88,9 +99,12 @@ newPath = undefined, savedApp = $bindable(undefined), diffDrawer = undefined, + onNavigate, + onDeploy = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', - liveEditorDraftStoragePath = undefined + liveEditorDraftStoragePath = undefined, + defaultSplitWithPreview = true }: Props = $props() export const version: number | undefined = undefined @@ -225,7 +239,9 @@ } let tabs: TabItem[] = $state([previewTab]) let activeTabId: string = $state(PREVIEW_TAB_ID) - let splitWithPreview: boolean = $state(true) + // Seed from the prop, then own the state locally so the user's toggle + // after mount sticks even if the prop reference changes. + let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview)) const activeTabKind = $derived<'file' | 'runnable' | 'preview'>( activeTabId === PREVIEW_TAB_ID ? 'preview' @@ -255,11 +271,23 @@ const showRunnable = $derived(activeTabKind === 'runnable') // Mount the UI Builder iframe the first time a file is shown (paneA has // width then; mounting it at 0-width breaks the VS Code workbench), and - // keep it mounted so tab switches don't reload it. + // keep it mounted so tab switches don't reload it. Mount it as soon as + // either pane needs it: `showSource` for the source-editor view, OR the + // preview tab is active — the Preview iframe is fed by `preview` + // postMessages bundled by the UI Builder iframe, so it needs to be + // mounted even when the user opens the editor straight on Preview (e.g. + // session previews seeded with `defaultSplitWithPreview=false`). let iframeShouldMount = $state(false) $effect(() => { - if (showSource) iframeShouldMount = true + if (showSource || activeTabKind === 'preview') iframeShouldMount = true }) + // Width of the editor area (both inner panes). The UI Builder iframe is + // pre-mounted while it's the inactive tab so the editor is ready instantly; + // but the VS Code workbench inside crashes if it boots at 0 size. So while + // inactive we keep the iframe at this real width and hide it with + // `visibility` instead of collapsing it — Monaco boots correctly and + // revealing a file is just an unhide (no reload, no relayout, no latency). + let editorAreaWidth = $state(0) // Inner pane sizes are a pure function of mode + active tab → derived. // `paneARatio` is the user's last manual split drag (set by rememberPaneDrag). @@ -994,7 +1022,11 @@ ensureFileTab(selectedDocument) // Don't auto-activate — the user's tab choice wins. // But if no file tab is currently active, fall in line. - if (activeTabKind === 'preview' && tabs.length === 2) { + // Skip this auto-activation in single-view-with-preview + // mode (the caller seeded `defaultSplitWithPreview=false` + // because Preview is the intended starting tab); the + // iframe's first setActiveDocument shouldn't fight that. + if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) { activateTab(id) } } @@ -1158,9 +1190,14 @@ }) }) - // Open a default file on mount (boots the iframe; avoids a blank preview). - // Layout isn't persisted — each open starts fresh in split mode. + // Open a default file on mount (boots the iframe in split mode and gives + // the user something to edit on the left). When the caller seeded + // `defaultSplitWithPreview=false` we instead want the Preview tab as the + // only-visible / active surface, so skip the file-tab activation — the + // iframe still boots via `populateFiles`/`setFilesInIframe` even without + // a selected document. onMount(() => { + if (!splitWithPreview) return if (tabs.length === 1) { const def = pickDefaultFile(files) if (def) activateTab(ensureFileTab(def)) @@ -1332,6 +1369,8 @@ {data} {runnables} {getBundle} + {onNavigate} + {onDeploy} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1415,6 +1454,7 @@ Preview previously hid every tab. -->
-
+ +
{#if iframeShouldMount}