From 6d522b3989ace1f214bd049d910bc0d2a2a6893e Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 8 Jun 2026 18:19:19 +0200 Subject: [PATCH] fix: refresh session editor preview on breadcrumb target switch (#9475) * fix: refresh session editor preview on breadcrumb target switch Consolidate the three session editor views into a SessionEditorTarget deep module that remounts the heavy editor on a data-ready target swap ({#key slot.loadedPath}), so stale mount-time state (e.g. Path.svelte's settings-panel path) re-derives. Adds LoadSlot to the runtime and a useUserDraftSync composable + per-kind codecs. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: flush pending session draft write on target switch A breadcrumb target swap (or unmount) within the 150ms outbound debounce window cleared the pending UserDraft write instead of flushing it, dropping the last edits. Scripts previously saved immediately so this was a regression from the new uniform debounce; flow/raw_app already had the latent drop. A dedicated path/workspace-scoped effect now flushes the pending write on switch/unmount without disturbing the debounce during a typing burst. Also refreshes a stale loadScript comment that named removed symbols (addresses PR review nits). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/sessions/FlowEditorView.svelte | 176 +++----------- .../sessions/RawAppEditorView.svelte | 160 +++---------- .../sessions/ScriptEditorView.svelte | 214 +++++------------- .../sessions/SessionEditorTarget.svelte | 128 +++++++++++ .../components/sessions/sessionDraftCodecs.ts | 76 +++++++ .../sessions/sessionRuntime.svelte.ts | 133 +++++------ .../sessions/useUserDraftSync.svelte.ts | 138 +++++++++++ 7 files changed, 530 insertions(+), 495 deletions(-) create mode 100644 frontend/src/lib/components/sessions/SessionEditorTarget.svelte create mode 100644 frontend/src/lib/components/sessions/sessionDraftCodecs.ts create mode 100644 frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts 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?.() + }) +}