diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index 0ea66146ac..dfa928f08c 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -2,13 +2,8 @@ import FlowBuilder from '$lib/components/FlowBuilder.svelte' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' - import { untrack } from 'svelte' import type { SessionRuntime } from './sessionRuntime.svelte' - import type { Flow } from '$lib/gen' - import { UserDraft } from '$lib/userDraft.svelte' - import { flowDraftSig } from './flowDraftSig' - import { initFlowState } from '$lib/components/flows/flowState' - import SessionItemNotFound from './SessionItemNotFound.svelte' + import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' let { @@ -22,25 +17,14 @@ path: string workspaceId: string onNavigate?: (item: WorkspaceItem) => void - /** - * Only the visible session should claim the workspace's live-editor - * slot — without this, a hidden warm-mounted session can overwrite the - * active session's UserDraft live-editor target (one slot per - * (workspace, kind)), so chat actions like discard / "the open editor" - * resolve to the wrong session. - */ + /** Forwarded to SessionEditorTarget — only the visible session claims the + * workspace's single live-editor slot. */ isActiveSession?: boolean } = $props() let selectedId = $state('settings-metadata') let diffDrawer: DiffDrawer | undefined = $state() - $effect(() => { - if (workspaceId && path) { - untrack(() => runtime.loadFlow(workspaceId, path)) - } - }) - // In a session pane, "restore" just reloads from the current state — the // session target stays put. The Diff drawer's primary use here is viewing // the diff; restore is best-effort. @@ -48,100 +32,6 @@ diffDrawer?.closeDrawer() await runtime.loadFlow(workspaceId, path) } - - // Mark this editor as the "live editor" for the session's workspace so - // the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to - // this path. Same registration the regular /flows/edit page does on - // mount, scoped to the session's (forked) workspace. - // Gated on `isActiveSession`: warm-but-hidden session editors must not - // claim the workspace's single live-editor slot, else chat actions on the - // visible session resolve to the hidden one's path. - $effect(() => { - if (!workspaceId || !path) return - if (!isActiveSession) return - UserDraft.setLiveEditorDraft({ - workspace: workspaceId, - itemKind: 'flow', - storagePath: path, - effectivePath: runtime.flowStore.val?.path ?? path - }) - return () => - UserDraft.clearLiveEditorDraft('flow', { workspace: workspaceId, storagePath: path }) - }) - - // Bidirectional sync between this preview and `UserDraft`. - // We hold a *live* handle (useMany) rather than reading via the static - // `UserDraft.get`. The handle materializes UserDraft's shared reactive - // `$state` cell for (workspace, 'flow', path), and that cell is what lets - // the chat's writes (UserDraft.save, from write_flow / patch_flow_json / - // set_flow_module_code) reach this preview. Without a live entry those - // writes only touch localStorage and the inbound effect below never - // re-fires. A reactive getter is used (not `use()`) because switching - // open_preview to another flow swaps `path` without remounting this view, - // so the handle must re-acquire. - // - // One-way-reactive discipline: inbound tracks only the handle's draft, - // outbound tracks only `flowStore.val`; the read on the "other side" - // inside each effect goes through `untrack()`. Without that asymmetry, a - // user keystroke would re-fire the inbound effect with the pre-keystroke - // stored value and revert the edit. - const draftHandles = UserDraft.useMany(() => [ - { itemKind: 'flow', path, workspace: workspaceId } - ]) - let lastInboundSig: string | undefined = $state(undefined) - - // Store → editor. Re-runs when the handle's draft changes (AI write from - // this session's chat or another session). flowStore reads are untracked - // so the editor's own mutations don't refire this effect. - $effect(() => { - if (!workspaceId || !path) return - const incoming = draftHandles[0]?.draft - if (!incoming) return - const sig = flowDraftSig(incoming) - untrack(() => { - if (runtime.loadedPath !== path) return - if (sig === lastInboundSig) return - const current = runtime.flowStore.val - if (!current) return - lastInboundSig = sig - runtime.flowStore.val = { - ...current, - value: incoming.value, - schema: incoming.schema ?? current.schema, - summary: incoming.summary ?? current.summary - } - // flowStateStore is keyed by module_id; after an AI write the set - // of module ids may differ, so rebuild the UI state. This wipes - // per-module test args / preview output for the new flow — a - // known v1 trade-off, see the plan's caveats. - void initFlowState(runtime.flowStore.val, runtime.flowStateStore) - }) - }) - - // Editor → store. Re-runs on any deep mutation of flowStore.val - // (modules, schema, module bodies). The store read is untracked. - // Debounced 150ms so a typing burst inside an inline rawscript editor - // results in one serialise-and-write instead of one per keystroke. - let outboundTimer: ReturnType | undefined - $effect(() => { - if (!workspaceId || !path) return - if (runtime.loadedPath !== path) return - const flow = runtime.flowStore.val - if (!flow) return - const sig = flowDraftSig(flow) - if (sig === lastInboundSig) return - if (outboundTimer) clearTimeout(outboundTimer) - outboundTimer = setTimeout(() => { - untrack(() => { - const current = UserDraft.get('flow', path, { workspace: workspaceId }) - if (current && flowDraftSig(current) === sig) return - UserDraft.save('flow', path, flow, { workspace: workspaceId }) - }) - }, 150) - return () => { - if (outboundTimer) clearTimeout(outboundTimer) - } - }) {#if runtime.savedFlow.val} @@ -152,30 +42,36 @@ isFlow /> {/if} -{#if runtime.loadingFlow && !runtime.loadedPath} -
Loading flow {path}…
-{:else if runtime.notFound && !runtime.loadedPath} - -{:else} - - runtime.scheduleForkComparisonRefresh()} - onDeploy={() => { - // FlowBuilder has no deploy toast and the session stays put, so toast - // here, then sync the preview to deployed (pulls the new locks + version_id). - sendUserToast('Deployed') - runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) - }} - /> -{/if} + runtime.flowStore.val?.path ?? path} +> + {#snippet editor()} + + runtime.scheduleForkComparisonRefresh()} + onDeploy={() => { + // FlowBuilder has no deploy toast and the session stays put, so toast + // here, then sync the preview to deployed (pulls the new locks + version_id). + sendUserToast('Deployed') + runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) + }} + /> + {/snippet} + diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index af77298a87..9ed75f9702 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -2,12 +2,8 @@ import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' - import { untrack } from 'svelte' import type { SessionRuntime } from './sessionRuntime.svelte' - import { UserDraft } from '$lib/userDraft.svelte' - import type { RawAppDraft } from './appDraftCodec' - import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft } from './appDraftCodec' - import SessionItemNotFound from './SessionItemNotFound.svelte' + import SessionEditorTarget from './SessionEditorTarget.svelte' let { runtime, @@ -20,107 +16,17 @@ path: string workspaceId: string onNavigate?: (item: WorkspaceItem) => void - /** - * Only the visible session should claim the workspace's live-editor - * slot — without this, a hidden warm-mounted session can overwrite the - * active session's UserDraft live-editor target (one slot per - * (workspace, kind)), so chat actions like discard / "the open editor" - * resolve to the wrong session. - */ + /** Forwarded to SessionEditorTarget — only the visible session claims the + * workspace's single live-editor slot. */ isActiveSession?: boolean } = $props() let diffDrawer: DiffDrawer | undefined = $state() - $effect(() => { - if (workspaceId && path) { - untrack(() => runtime.loadRawApp(workspaceId, path)) - } - }) - async function restoreFromCurrentTarget() { diffDrawer?.closeDrawer() await runtime.loadRawApp(workspaceId, path) } - - // Mark this editor as the live editor draft for the session's workspace - // so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve - // to this path — same registration the regular /apps_raw/edit page does. - // Gated on `isActiveSession`: warm-but-hidden session editors must not - // claim the workspace's single live-editor slot, else chat actions on the - // visible session resolve to the hidden one's path. - $effect(() => { - if (!workspaceId || !path) return - if (!isActiveSession) return - UserDraft.setLiveEditorDraft({ - workspace: workspaceId, - itemKind: 'raw_app', - storagePath: path, - effectivePath: runtime.rawApp.val?.path ?? path - }) - return () => - UserDraft.clearLiveEditorDraft('raw_app', { workspace: workspaceId, storagePath: path }) - }) - - // Bidirectional sync between this preview and `UserDraft`. - // We hold a *live* handle (useMany) rather than reading via the static - // `UserDraft.get`: the handle materializes UserDraft's shared reactive - // `$state` cell for (workspace, 'raw_app', path), and that cell is what - // lets the chat's writes (UserDraft.save / setDraftAndMeta, from - // write_app_file / patch_app_file / write_app_runnable) reach this preview. - // Without a live entry those writes only touch localStorage and the inbound - // effect below never re-fires. A reactive getter is used (not `use()`) - // because switching open_preview to another app swaps `path` without - // remounting this view, so the handle must re-acquire. - // - // Same one-way-reactive discipline as ScriptEditorView: inbound tracks only - // the handle's draft, outbound tracks only rawApp.val; each side's read of - // the other goes through untrack() to break the keystroke-revert race. - const draftHandles = UserDraft.useMany(() => [ - { itemKind: 'raw_app', path, workspace: workspaceId } - ]) - let lastInboundSig: string | undefined = $state(undefined) - - // Store → editor. Re-runs when the handle's draft changes (chat write, - // other session edit). - $effect(() => { - if (!workspaceId || !path) return - const incoming = draftHandles[0]?.draft - if (!incoming) return - const sig = JSON.stringify(incoming) - untrack(() => { - if (runtime.loadedRawAppPath !== path) return - if (sig === lastInboundSig) return - const current = runtime.rawApp.val - if (!current) return - lastInboundSig = sig - runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming) - }) - }) - - // Editor → store. Debounced 150ms so a typing burst inside a frontend - // file's Monaco editor coalesces into one store write. - let outboundTimer: ReturnType | undefined - $effect(() => { - if (!workspaceId || !path) return - if (runtime.loadedRawAppPath !== path) return - const raw = runtime.rawApp.val - if (!raw) return - const draft = runtimeRawAppToDraft(raw) - const sig = JSON.stringify(draft) - if (sig === lastInboundSig) return - if (outboundTimer) clearTimeout(outboundTimer) - outboundTimer = setTimeout(() => { - untrack(() => { - const current = UserDraft.get('raw_app', path, { workspace: workspaceId }) - if (current && JSON.stringify(current) === sig) return - UserDraft.save('raw_app', path, draft, { workspace: workspaceId }) - }) - }, 150) - return () => { - if (outboundTimer) clearTimeout(outboundTimer) - } - }) {#if runtime.savedRawApp.val} @@ -130,29 +36,37 @@ restoreDraft={restoreFromCurrentTarget} /> {/if} -{#if runtime.loadingRawApp && !runtime.loadedRawAppPath} -
Loading raw app {path}…
-{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath} - -{:else if runtime.rawApp.val} - { - // Sync the preview to deployed (raw apps deploy only from this editor). - runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) - }} - defaultSidebarCollapsed - sidebarStorageKey="raw-app-sidebar-collapsed-preview" - defaultSplitWithPreview={false} - /> -{/if} + runtime.rawApp.val?.path ?? path} +> + {#snippet editor()} + {#if runtime.rawApp.val} + { + // Sync the preview to deployed (raw apps deploy only from this editor). + runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) + }} + defaultSidebarCollapsed + sidebarStorageKey="raw-app-sidebar-collapsed-preview" + defaultSplitWithPreview={false} + /> + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index caadb8a5e0..bebd6d3cce 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -2,11 +2,10 @@ import ScriptBuilder from '$lib/components/ScriptBuilder.svelte' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' - import { untrack } from 'svelte' import type { SessionRuntime } from './sessionRuntime.svelte' import { DraftService, ScriptService, type NewScript } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' - import SessionItemNotFound from './SessionItemNotFound.svelte' + import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' let { @@ -22,24 +21,13 @@ workspaceId: string onNavigate?: (item: WorkspaceItem) => void initialTestPanelCollapsed?: boolean - /** - * Only the visible session should claim the workspace's live-editor - * slot — without this, a hidden warm-mounted session can overwrite the - * active session's UserDraft live-editor target (one slot per - * (workspace, kind)), so chat actions like discard / "the open editor" - * resolve to the wrong session. - */ + /** Forwarded to SessionEditorTarget — only the visible session claims the + * workspace's single live-editor slot. */ isActiveSession?: boolean } = $props() let diffDrawer: DiffDrawer | undefined = $state() - $effect(() => { - if (workspaceId && path) { - untrack(() => runtime.loadScript(workspaceId, path)) - } - }) - // Restore actions for the diff drawer. The previous shared // `loadScript`-based handler was a no-op: loadScript early-returns on the // already-loaded path (and would re-read the local draft anyway). Instead @@ -78,147 +66,65 @@ workspace: workspaceId }) } - - // Mark this editor as the live editor draft for the session's workspace - // so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve - // to this path — same registration the regular /scripts/edit page does. - // Gated on `isActiveSession`: warm-but-hidden session editors must not - // claim the workspace's single live-editor slot, else chat actions on the - // visible session resolve to the hidden one's path. - $effect(() => { - if (!workspaceId || !path) return - if (!isActiveSession) return - UserDraft.setLiveEditorDraft({ - workspace: workspaceId, - itemKind: 'script', - storagePath: path, - effectivePath: runtime.scriptStore.val?.path ?? path - }) - return () => - UserDraft.clearLiveEditorDraft('script', { workspace: workspaceId, storagePath: path }) - }) - - // Bidirectional sync between this preview and `UserDraft`. - // The same path under the same workspace is shared with the session's - // chat (read_workspace_item / write_script / edit_script) and any other - // open editor on the same workspace. - // - // We hold a *live* handle (useMany) instead of reading via the static - // `UserDraft.get`. The handle materializes UserDraft's shared reactive - // `$state` cell for (workspace, 'script', path) — and that cell is what - // lets the chat's writes (UserDraft.save, from write_script / edit_script) - // reach this preview. Without a live entry those writes only touch - // localStorage and the inbound effect below never re-fires. A reactive - // getter is used (not `use()`) because switching open_preview to another - // script swaps `path` without remounting this view, so the handle must - // re-acquire. - // - // One-way-reactive discipline: inbound tracks ONLY the handle's `draft` - // (and reads `script.content` via untrack); outbound tracks ONLY - // `script.content` (and reads UserDraft via untrack). Without that - // asymmetry, a user keystroke would re-fire the inbound effect with the - // pre-keystroke stored value and revert the edit. - const draftHandles = UserDraft.useMany(() => [ - { itemKind: 'script', path, workspace: workspaceId } - ]) - let lastInboundContent: string | undefined = $state(undefined) - - // Store → editor. Re-runs when the handle's draft changes (chat write, - // other session edit, …). `script.content` is read inside untrack so user - // keystrokes don't refire this effect. - $effect(() => { - if (!workspaceId || !path) return - const draft = draftHandles[0]?.draft - if (!draft || typeof draft.content !== 'string') return - const incoming = draft.content - untrack(() => { - if (runtime.loadedScriptPath !== path) return - const script = runtime.scriptStore.val - if (!script) return - if (incoming === script.content) return - lastInboundContent = incoming - script.content = incoming - if (draft.language) script.language = draft.language - if (draft.summary !== undefined) script.summary = draft.summary - }) - }) - - // Editor → store. Re-runs on `script.content` mutation (user typing - // or inbound write). UserDraft is read inside untrack so writing here - // doesn't ping-pong the inbound effect. `UserDraft.save` persists - // immediately and, now that the entry is live, updates the same cell the - // inbound effect reads (the content guard there makes it a no-op). - $effect(() => { - if (!workspaceId || !path) return - if (runtime.loadedScriptPath !== path) return - const script = runtime.scriptStore.val - if (!script) return - const content = script.content - if (content === lastInboundContent) return - untrack(() => { - const current = UserDraft.get('script', path, { workspace: workspaceId }) - if (current && current.content === content) return - UserDraft.save( - 'script', - path, - { ...(current ?? script), ...script }, - { - workspace: workspaceId - } - ) - }) - }) {#if runtime.savedScript.val} {/if} -{#if runtime.loadingScript && !runtime.loadedScriptPath} -
Loading script {path}…
-{:else if runtime.notFoundScript && !runtime.loadedScriptPath} - -{:else if runtime.scriptStore.val} - - { - runtime.scheduleForkComparisonRefresh() - // Re-pin parent_hash to the latest version so the next Deploy's conflict - // check (which runs before deploy, while the session stays mounted) - // doesn't misfire. - try { - const latest = await ScriptService.getScriptLatestVersion({ - workspace: workspaceId, - path: e.path - }) - const cur = runtime.scriptStore.val - if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash - } catch (err) { - console.error('Failed to sync parent_hash after save draft', err) - } - }} - onDeploy={(e) => { - // Fires on every deploy (primary, "Deploy & Stay here", and lib — we - // ignore e.stay since the session always stays). Toast, then sync the - // preview to the deployed version. - sendUserToast('Deployed') - runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) - }} - /> -{/if} + runtime.scriptStore.val?.path ?? path} +> + {#snippet editor()} + {#if runtime.scriptStore.val} + + { + runtime.scheduleForkComparisonRefresh() + // Re-pin parent_hash to the latest version so the next Deploy's conflict + // check (which runs before deploy, while the session stays mounted) + // doesn't misfire. + try { + const latest = await ScriptService.getScriptLatestVersion({ + workspace: workspaceId, + path: e.path + }) + const cur = runtime.scriptStore.val + if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash + } catch (err) { + console.error('Failed to sync parent_hash after save draft', err) + } + }} + onDeploy={(e) => { + // Fires on every deploy (primary, "Deploy & Stay here", and lib — we + // ignore e.stay since the session always stays). Toast, then sync the + // preview to the deployed version. + sendUserToast('Deployed') + runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) + }} + /> + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte new file mode 100644 index 0000000000..55bd697bb9 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte @@ -0,0 +1,128 @@ + + +{#snippet loadingOverlay(asOverlay: boolean)} +
+ +
+{/snippet} + +{#if slot.notFound && slot.loadedPath !== path} + + +{:else if slot.loadedPath === undefined} + + {@render loadingOverlay(false)} +{:else} + + {#key slot.loadedPath} + {@render editor()} + {/key} + {#if showOverlay} + {@render loadingOverlay(true)} + {/if} +{/if} diff --git a/frontend/src/lib/components/sessions/sessionDraftCodecs.ts b/frontend/src/lib/components/sessions/sessionDraftCodecs.ts new file mode 100644 index 0000000000..900b6dc2e4 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionDraftCodecs.ts @@ -0,0 +1,76 @@ +import type { Flow, NewScript } from '$lib/gen' +import { initFlowState } from '$lib/components/flows/flowState' +import { flowDraftSig } from './flowDraftSig' +import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft, type RawAppDraft } from './appDraftCodec' +import type { SessionRuntime } from './sessionRuntime.svelte' +import type { DraftSyncCodec } from './useUserDraftSync.svelte' + +// Outbound debounce, uniform across kinds (script was previously immediate; +// unified to 150ms so a typing burst coalesces into one persist like flow/raw_app). +const DEBOUNCE_MS = 150 + +export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec { + return { + itemKind: 'flow', + sig: flowDraftSig, + debounceMs: DEBOUNCE_MS, + applyDraftToStore(incoming) { + const current = runtime.flowStore.val + if (!current) return + runtime.flowStore.val = { + ...current, + value: incoming.value, + schema: incoming.schema ?? current.schema, + summary: incoming.summary ?? current.summary + } + // flowStateStore is keyed by module_id; after an AI write the set of + // module ids may differ, so rebuild the UI state. This wipes per-module + // test args / preview output — a known v1 trade-off. + void initFlowState(runtime.flowStore.val, runtime.flowStateStore) + }, + storeToDraft() { + return runtime.flowStore.val + } + } +} + +export function makeScriptCodec(runtime: SessionRuntime): DraftSyncCodec { + return { + itemKind: 'script', + sig: (d) => d.content ?? '', + debounceMs: DEBOUNCE_MS, + applyDraftToStore(incoming) { + const script = runtime.scriptStore.val + if (!script) return + if (typeof incoming.content !== 'string') return + script.content = incoming.content + if (incoming.language) script.language = incoming.language + if (incoming.summary !== undefined) script.summary = incoming.summary + }, + storeToDraft(current) { + const script = runtime.scriptStore.val + if (!script) return undefined + // Merge over the existing entry so fields the preview doesn't edit + // (set by the chat) survive a content-only save. + return { ...(current ?? script), ...script } + } + } +} + +export function makeRawAppCodec(runtime: SessionRuntime): DraftSyncCodec { + return { + itemKind: 'raw_app', + sig: (d) => JSON.stringify(d), + debounceMs: DEBOUNCE_MS, + applyDraftToStore(incoming) { + const current = runtime.rawApp.val + if (!current) return + runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming) + }, + storeToDraft() { + const raw = runtime.rawApp.val + if (!raw) return undefined + return runtimeRawAppToDraft(raw) + } + } +} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index d805c562d7..8bfe6dda30 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -39,23 +39,34 @@ import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib' import type { DisplayMessage } from '$lib/components/copilot/chat/shared' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' +// Per-kind load state for a session's editor target. Pure state container the +// load methods write into; the editor-target gate reads it to decide between +// the loading overlay, the not-found state, and a remount of the heavy editor. +// `loadedPath` flips to the requested path only once the load settles (data +// ready), which is what lets the gate remount on data-ready rather than on the +// (synchronous) target swap. +export interface LoadSlot { + loadedPath: string | undefined + loading: boolean + notFound: boolean +} + +export type SessionTargetKind = 'flow' | 'script' | 'raw_app' + export interface SessionRuntime { readonly sessionId: string readonly manager: AIChatManager + // Kind-agnostic accessor over the per-kind load slots, for consumers (the + // editor-target gate) that only need load state and not the typed store. + slot(kind: SessionTargetKind): LoadSlot // Flow target state readonly flowStore: StateStore readonly flowStateStore: { val: Record } readonly savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } - readonly loadingFlow: boolean - readonly notFound: boolean - readonly loadedPath: string | undefined loadFlow(workspace: string, path: string, force?: boolean): Promise // Script target state (parallel to flow, populated only for script-targeted sessions) readonly scriptStore: { val: NewScript | undefined } readonly savedScript: { val: NewScriptWithDraft | undefined } - readonly loadingScript: boolean - readonly notFoundScript: boolean - readonly loadedScriptPath: string | undefined loadScript(workspace: string, path: string, force?: boolean): Promise // Note: legacy drag-and-drop apps are intentionally NOT hosted in the // session preview pane (only code-based raw apps are), so there's no @@ -90,9 +101,6 @@ export interface SessionRuntime { } | undefined } - readonly loadingRawApp: boolean - readonly notFoundRawApp: boolean - readonly loadedRawAppPath: string | undefined loadRawApp(workspace: string, path: string, force?: boolean): Promise // Discard the local draft + refresh the fork diff + force-reload the editor, // so the preview matches the deployed version. Used by editor onDeploy + the @@ -173,7 +181,9 @@ function normalizeGeneratedSummary(summary: string | undefined): string | undefi return title.slice(0, GENERATED_SUMMARY_MAX_LENGTH).trim() } -async function generateSessionSummary(displayMessages: DisplayMessage[]): Promise { +async function generateSessionSummary( + displayMessages: DisplayMessage[] +): Promise { const transcript = buildSummaryTranscript(displayMessages) if (!transcript) return undefined const abortController = new AbortController() @@ -251,21 +261,15 @@ function createRuntime(session: Session): SessionRuntime { val: undefined }) - let loadingFlow = $state(false) - let notFound = $state(false) - let loadedPath = $state(undefined) + const flowSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false }) const scriptStore: { val: NewScript | undefined } = $state({ val: undefined }) const savedScript: { val: NewScriptWithDraft | undefined } = $state({ val: undefined }) - let loadingScript = $state(false) - let notFoundScript = $state(false) - let loadedScriptPath = $state(undefined) + const scriptSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false }) const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined }) const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined }) - let loadingRawApp = $state(false) - let notFoundRawApp = $state(false) - let loadedRawAppPath = $state(undefined) + const rawAppSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false }) const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined }) let loadingForkComparison = $state(false) @@ -298,25 +302,19 @@ function createRuntime(session: Session): SessionRuntime { return { sessionId: session.id, manager, + slot(kind: SessionTargetKind): LoadSlot { + return kind === 'flow' ? flowSlot : kind === 'script' ? scriptSlot : rawAppSlot + }, flowStore, flowStateStore, savedFlow, - get loadingFlow() { - return loadingFlow - }, - get notFound() { - return notFound - }, - get loadedPath() { - return loadedPath - }, async loadFlow(workspace: string, path: string, force = false) { - if (loadedPath === path && !force) return + if (flowSlot.loadedPath === path && !force) return // See loadScript: forced reload remounts via the render gate. - if (force) loadedPath = undefined - loadingFlow = true - notFound = false + if (force) flowSlot.loadedPath = undefined + flowSlot.loading = true + flowSlot.notFound = false try { // Draft first. UserDraft is the shared authoritative content // source — the chat (write_flow / patch_flow_json / @@ -348,7 +346,7 @@ function createRuntime(session: Session): SessionRuntime { await initFlow(aiDraft, flowStore, flowStateStore) if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId - loadedPath = path + flowSlot.loadedPath = path return } @@ -360,35 +358,27 @@ function createRuntime(session: Session): SessionRuntime { UserDraft.save('flow', path, flow, { workspace }) await initFlow(flow, flowStore, flowStateStore) if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId - loadedPath = path + flowSlot.loadedPath = path } catch (err) { console.error('Failed to load flow', err) - notFound = true + flowSlot.notFound = true } finally { - loadingFlow = false + flowSlot.loading = false } }, scriptStore, savedScript, - get loadingScript() { - return loadingScript - }, - get notFoundScript() { - return notFoundScript - }, - get loadedScriptPath() { - return loadedScriptPath - }, async loadScript(workspace: string, path: string, force = false) { - if (loadedScriptPath === path && !force) return - // Forced reload: clearing loadedScriptPath drops us into the - // `{#if loading && !loadedScriptPath}` gate, which unmounts then remounts - // the editor — avoids the Monaco init race a synchronous {#key} would hit. - if (force) loadedScriptPath = undefined - loadingScript = true - notFoundScript = false + if (scriptSlot.loadedPath === path && !force) return + // Forced reload: clearing the slot's loadedPath drops us into + // SessionEditorTarget's `{:else if slot.loadedPath === undefined}` gate, + // which unmounts then remounts the editor — avoids the Monaco init race a + // synchronous {#key} would hit. + if (force) scriptSlot.loadedPath = undefined + scriptSlot.loading = true + scriptSlot.notFound = false try { // Draft first. UserDraft is the shared authoritative content // source — the chat (write_script / edit_script) and the @@ -433,7 +423,7 @@ function createRuntime(session: Session): SessionRuntime { if (aiDraft.language) baseline.language = aiDraft.language if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary scriptStore.val = baseline - loadedScriptPath = path + scriptSlot.loadedPath = path return } @@ -449,33 +439,24 @@ function createRuntime(session: Session): SessionRuntime { baseline.parent_hash = result.hash UserDraft.save('script', path, baseline, { workspace }) scriptStore.val = baseline - loadedScriptPath = path + scriptSlot.loadedPath = path } catch (err) { console.error('Failed to load script', err) - notFoundScript = true + scriptSlot.notFound = true } finally { - loadingScript = false + scriptSlot.loading = false } }, rawApp, savedRawApp, - get loadingRawApp() { - return loadingRawApp - }, - get notFoundRawApp() { - return notFoundRawApp - }, - get loadedRawAppPath() { - return loadedRawAppPath - }, async loadRawApp(workspace: string, path: string, force = false) { - if (loadedRawAppPath === path && !force) return + if (rawAppSlot.loadedPath === path && !force) return // See loadScript: forced reload remounts via the render gate. - if (force) loadedRawAppPath = undefined - loadingRawApp = true - notFoundRawApp = false + if (force) rawAppSlot.loadedPath = undefined + rawAppSlot.loading = true + rawAppSlot.notFound = false try { // Draft first. UserDraft is the shared authoritative content // source — the chat (init_app / write_app_file / ...) and the @@ -513,7 +494,7 @@ function createRuntime(session: Session): SessionRuntime { }, aiDraft ) - loadedRawAppPath = path + rawAppSlot.loadedPath = path return } @@ -558,12 +539,12 @@ function createRuntime(session: Session): SessionRuntime { } UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace }) rawApp.val = runtimeValue - loadedRawAppPath = path + rawAppSlot.loadedPath = path } catch (err) { console.error('Failed to load raw app', err) - notFoundRawApp = true + rawAppSlot.notFound = true } finally { - loadingRawApp = false + rawAppSlot.loading = false } }, @@ -751,11 +732,7 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => { const session = sessionState.sessions.find((s) => s.id === sessionId) const runtime = runtimes.get(sessionId) if (!session?.workspace_id || !runtime) return - const open = - (kind === 'script' && runtime.loadedScriptPath === path) || - (kind === 'flow' && runtime.loadedPath === path) || - (kind === 'raw_app' && runtime.loadedRawAppPath === path) - if (!open) return + if (runtime.slot(kind).loadedPath !== path) return runtime.syncPreviewWithDeployed(session.workspace_id, kind, path) }) diff --git a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts new file mode 100644 index 0000000000..80075e2936 --- /dev/null +++ b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts @@ -0,0 +1,138 @@ +import { untrack } from 'svelte' +import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte' + +/** + * Per-kind projection between a `UserDraft` draft and a session editor's + * runtime store. Carries the kind's behavioral quirks (flow's `initFlowState` + * rebuild, script's merge-save) so {@link useUserDraftSync} stays generic. + */ +export interface DraftSyncCodec { + itemKind: UserDraftItemKind + /** + * Inbound: write an incoming draft into the runtime store (and run any + * side effects, e.g. flow's `initFlowState`). Reads the store internally; + * a no-op when the store isn't populated. + */ + applyDraftToStore(draft: Draft): void + /** + * Outbound: derive the draft to persist from the current store, or + * `undefined` when the store isn't populated. `current` is the existing + * UserDraft entry (script's merge-save needs it; flow/raw_app ignore it). + */ + storeToDraft(current: Draft | undefined): Draft | undefined + /** Signature over a draft, comparable across both directions; drives de-dup. */ + sig(draft: Draft): string + /** Outbound debounce; coalesces a typing burst into one persist. */ + debounceMs: number +} + +export interface UserDraftSyncOptions { + /** Reactive editor path (the target being edited). */ + path: () => string + /** Reactive workspace id (the session's, possibly forked, workspace). */ + workspace: () => string | undefined + /** + * Reactive inert-gate: both effects no-op unless the runtime has settled on + * this exact path (`slot.loadedPath === path`). Replaces the old per-view + * `loadedX !== path` guards. + */ + ready: () => boolean + codec: DraftSyncCodec +} + +/** + * Bidirectional sync between a session editor's runtime store and the shared + * `UserDraft` cell for `(workspace, kind, path)`. Holding a *live* handle + * (`useMany`) is what lets the chat's writes (`write_script`, `patch_flow_json`, + * …) reach the open preview — a plain `UserDraft.get` would only see localStorage. + * + * - **inbound** (`handle.draft → store`): reflects external writes into the editor. + * - **outbound** (`store → handle`, debounced): persists editor edits. + * + * One-way-reactive discipline: inbound tracks ONLY the handle's draft (reading + * the store via `untrack`); outbound tracks ONLY the store (reading UserDraft via + * `untrack`). Without that asymmetry a keystroke would re-fire the inbound effect + * with the pre-keystroke value and revert the edit. `lastInboundSig` de-dups the + * echo so an outbound save doesn't bounce back through inbound. + * + * Must be called once during component init (registers `useMany` + two `$effect`s). + */ +export function useUserDraftSync(opts: UserDraftSyncOptions): void { + const { codec } = opts + const handles = UserDraft.useMany(() => { + const p = opts.path() + const ws = opts.workspace() + return p && ws ? [{ itemKind: codec.itemKind, path: p, workspace: ws }] : [] + }) + let lastInboundSig: string | undefined = $state(undefined) + + // inbound: handle.draft → store. Re-runs when the handle's draft changes + // (chat write / another session's edit). The store read happens inside + // applyDraftToStore under untrack so the editor's own mutations don't refire. + $effect(() => { + const incoming = handles[0]?.draft + if (incoming == null) return + const sig = codec.sig(incoming) + untrack(() => { + if (!opts.ready()) return + // Centralized sig-based echo de-dup for all kinds. The flow/raw_app + // originals already de-duped on `lastInboundSig`; the script original + // instead compared `incoming === script.content` (store equality), and + // `lastInboundSig` is intentionally not reset on a target swap. Both are + // observably equivalent here: `applyDraftToStore` is idempotent (assigning + // an unchanged value is a no-op under Svelte reactivity), so a redundant + // re-apply only advances the sig, never reverts an edit or fires a save. + if (sig === lastInboundSig) return + lastInboundSig = sig + codec.applyDraftToStore(incoming) + }) + }) + + // outbound: store → handle (debounced). Re-runs on any tracked store + // mutation. `lastInboundSig` (read tracked here) makes the echo from an + // inbound apply a no-op, terminating the loop. + let outboundTimer: ReturnType | undefined + // The latest scheduled-but-unwritten save (captures its own path/workspace), + // so a target swap or unmount can flush it instead of dropping the last + // `debounceMs` of edits. See the flush effect below. + let pendingFlush: (() => void) | undefined + $effect(() => { + if (!opts.ready()) return + const draft = codec.storeToDraft(undefined) + if (draft == null) return + const sig = codec.sig(draft) + if (sig === lastInboundSig) return + const path = opts.path() + const workspace = opts.workspace() + if (!path || !workspace) return + const save = () => { + if (outboundTimer) { + clearTimeout(outboundTimer) + outboundTimer = undefined + } + pendingFlush = undefined + untrack(() => { + const current = UserDraft.get(codec.itemKind, path, { workspace }) + if (current && codec.sig(current) === sig) return + const toSave = codec.storeToDraft(current) ?? draft + UserDraft.save(codec.itemKind, path, toSave, { workspace }) + }) + } + pendingFlush = save + if (outboundTimer) clearTimeout(outboundTimer) + outboundTimer = setTimeout(save, codec.debounceMs) + }) + + // Flush a pending debounced write when the target path/workspace changes + // (breadcrumb swap) or on unmount. Tracks ONLY path/workspace — a normal + // typing burst (store mutation) re-runs the outbound effect above, not this + // one, so it never flushes mid-burst and the debounce is preserved. Without + // this, switching within the debounce window would silently drop the last + // edits (scripts previously saved immediately, so this is a regression guard + // for the new uniform debounce as well as a fix for flow/raw_app). + $effect(() => { + opts.path() + opts.workspace() + return () => pendingFlush?.() + }) +}