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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-06-08 18:19:19 +02:00
committed by GitHub
parent 192574ab8f
commit 6d522b3989
7 changed files with 530 additions and 495 deletions
@@ -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<Flow>`.
// 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<Flow>(() => [
{ 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<typeof setTimeout> | 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>('flow', path, { workspace: workspaceId })
if (current && flowDraftSig(current) === sig) return
UserDraft.save('flow', path, flow, { workspace: workspaceId })
})
}, 150)
return () => {
if (outboundTimer) clearTimeout(outboundTimer)
}
})
</script>
{#if runtime.savedFlow.val}
@@ -152,30 +42,36 @@
isFlow
/>
{/if}
{#if runtime.loadingFlow && !runtime.loadedPath}
<div class="p-4 text-secondary text-sm">Loading flow {path}</div>
{:else if runtime.notFound && !runtime.loadedPath}
<SessionItemNotFound kind="flow" {path} {onNavigate} />
{:else}
<!-- customUi hides the in-editor "Flow AI Chat" button: the session already
has its own AI chat in the left pane, so the toggle is redundant here. -->
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={path}
newFlow={!runtime.savedFlow.val}
{selectedId}
loading={runtime.loadingFlow && !runtime.loadedPath}
bind:savedFlow={runtime.savedFlow.val}
{diffDrawer}
{onNavigate}
customUi={{ topBar: { aiBuilder: false } }}
onSaveDraft={() => 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}
<SessionEditorTarget
{runtime}
kind="flow"
{path}
{workspaceId}
{onNavigate}
{isActiveSession}
effectivePath={() => runtime.flowStore.val?.path ?? path}
>
{#snippet editor()}
<!-- customUi hides the in-editor "Flow AI Chat" button: the session already
has its own AI chat in the left pane, so the toggle is redundant here. -->
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={path}
newFlow={!runtime.savedFlow.val}
{selectedId}
loading={false}
bind:savedFlow={runtime.savedFlow.val}
{diffDrawer}
{onNavigate}
customUi={{ topBar: { aiBuilder: false } }}
onSaveDraft={() => 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}
</SessionEditorTarget>
@@ -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<RawAppDraft>`.
// 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<RawAppDraft>(() => [
{ 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<typeof setTimeout> | 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<RawAppDraft>('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)
}
})
</script>
{#if runtime.savedRawApp.val}
@@ -130,29 +36,37 @@
restoreDraft={restoreFromCurrentTarget}
/>
{/if}
{#if runtime.loadingRawApp && !runtime.loadedRawAppPath}
<div class="p-4 text-secondary text-sm">Loading raw app {path}</div>
{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath}
<SessionItemNotFound kind="raw_app" {path} {onNavigate} />
{:else if runtime.rawApp.val}
<RawAppEditor
bind:files={runtime.rawApp.val.files}
bind:runnables={runtime.rawApp.val.runnables}
bind:data={runtime.rawApp.val.data}
bind:summary={runtime.rawApp.val.summary}
newPath={runtime.rawApp.val.path}
{path}
policy={runtime.rawApp.val.policy}
bind:savedApp={runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val}
{diffDrawer}
{onNavigate}
onDeploy={(e) => {
// 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}
<SessionEditorTarget
{runtime}
kind="raw_app"
{path}
{workspaceId}
{onNavigate}
{isActiveSession}
effectivePath={() => runtime.rawApp.val?.path ?? path}
>
{#snippet editor()}
{#if runtime.rawApp.val}
<RawAppEditor
bind:files={runtime.rawApp.val.files}
bind:runnables={runtime.rawApp.val.runnables}
bind:data={runtime.rawApp.val.data}
bind:summary={runtime.rawApp.val.summary}
newPath={runtime.rawApp.val.path}
{path}
policy={runtime.rawApp.val.policy}
bind:savedApp={runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val}
{diffDrawer}
{onNavigate}
onDeploy={(e) => {
// 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}
</SessionEditorTarget>
@@ -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<NewScript>`.
// 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<NewScript>(() => [
{ 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<NewScript>('script', path, { workspace: workspaceId })
if (current && current.content === content) return
UserDraft.save<NewScript>(
'script',
path,
{ ...(current ?? script), ...script },
{
workspace: workspaceId
}
)
})
})
</script>
{#if runtime.savedScript.val}
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
{/if}
{#if runtime.loadingScript && !runtime.loadedScriptPath}
<div class="p-4 text-secondary text-sm">Loading script {path}</div>
{:else if runtime.notFoundScript && !runtime.loadedScriptPath}
<SessionItemNotFound kind="script" {path} {onNavigate} />
{:else if runtime.scriptStore.val}
<!--
A script with no backend version yet (AI-created, never saved or deployed
→ savedScript undefined) is a *new* script: pass an empty initialPath so
ScriptBuilder behaves exactly like /scripts/add — Save draft is enabled and
creates it on first save. On that save ScriptBuilder writes savedScript back
through the bind and sets its own initialPath to the path, flipping us into
edit mode (Save draft + Diff) without navigating away.
-->
<ScriptBuilder
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={runtime.savedScript.val ? path : ''}
initialPathChosen={true}
neverShowMeta={true}
fullyLoaded={!runtime.loadingScript}
disableHistoryChange={true}
{diffDrawer}
{onNavigate}
{initialTestPanelCollapsed}
onSaveDraft={async (e) => {
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}
<SessionEditorTarget
{runtime}
kind="script"
{path}
{workspaceId}
{onNavigate}
{isActiveSession}
effectivePath={() => runtime.scriptStore.val?.path ?? path}
>
{#snippet editor()}
{#if runtime.scriptStore.val}
<!--
A script with no backend version yet (AI-created, never saved or deployed
→ savedScript undefined) is a *new* script: pass an empty initialPath so
ScriptBuilder behaves exactly like /scripts/add — Save draft is enabled and
creates it on first save. On that save ScriptBuilder writes savedScript back
through the bind and sets its own initialPath to the path, flipping us into
edit mode (Save draft + Diff) without navigating away.
-->
<ScriptBuilder
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={runtime.savedScript.val ? path : ''}
initialPathChosen={true}
neverShowMeta={true}
fullyLoaded={!runtime.slot('script').loading}
disableHistoryChange={true}
{diffDrawer}
{onNavigate}
{initialTestPanelCollapsed}
onSaveDraft={async (e) => {
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}
</SessionEditorTarget>
@@ -0,0 +1,128 @@
<script lang="ts">
import { untrack, type Snippet } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { UserDraft } from '$lib/userDraft.svelte'
import type { SessionRuntime, SessionTargetKind } from './sessionRuntime.svelte'
import { useUserDraftSync, type DraftSyncCodec } from './useUserDraftSync.svelte'
import { makeFlowCodec, makeScriptCodec, makeRawAppCodec } from './sessionDraftCodecs'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
runtime,
kind,
path,
workspaceId,
effectivePath,
editor,
onNavigate,
isActiveSession = true
}: {
runtime: SessionRuntime
kind: SessionTargetKind
path: string
workspaceId: string
/**
* The path the live-editor draft should resolve to (the loaded item's own
* path, which may differ from the storage `path` when a draft renames it).
* A getter so the registration tracks it as the store settles.
*/
effectivePath: () => string
/** The heavy editor for this kind; remounted on a data-ready target swap. */
editor: Snippet
onNavigate?: (item: WorkspaceItem) => void
/**
* Only the visible session claims the workspace's single live-editor slot
* (one per (workspace, kind)); a warm-but-hidden session must not, else
* chat actions on the visible session resolve to the hidden one's path.
*/
isActiveSession?: boolean
} = $props()
const slot = $derived(runtime.slot(kind))
function triggerLoad(): Promise<void> {
if (kind === 'flow') return runtime.loadFlow(workspaceId, path)
if (kind === 'script') return runtime.loadScript(workspaceId, path)
return runtime.loadRawApp(workspaceId, path)
}
function buildCodec(): DraftSyncCodec<any> {
if (kind === 'flow') return makeFlowCodec(runtime)
if (kind === 'script') return makeScriptCodec(runtime)
return makeRawAppCodec(runtime)
}
$effect(() => {
if (workspaceId && path) {
untrack(() => void triggerLoad())
}
})
// 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 edit pages do. Gated on
// `isActiveSession` (see prop doc).
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: kind,
storagePath: path,
effectivePath: effectivePath()
})
return () => UserDraft.clearLiveEditorDraft(kind, { workspace: workspaceId, storagePath: path })
})
useUserDraftSync({
path: () => path,
workspace: () => workspaceId,
ready: () => slot.loadedPath === path,
codec: buildCodec()
})
// Debounced loading affordance for a breadcrumb swap: while the loaded path
// lags the requested `path` (data not landed), keep the old editor visible
// for ~150ms, then dim it under a spinner. Cleared the moment the load
// settles. The first-load / force-reload window (loadedPath === undefined)
// uses the un-debounced branch in the markup instead.
let showOverlay = $state(false)
$effect(() => {
const stale = slot.loadedPath !== undefined && slot.loadedPath !== path
showOverlay = false
if (!stale) return
const t = setTimeout(() => (showOverlay = true), 150)
return () => clearTimeout(t)
})
</script>
{#snippet loadingOverlay(asOverlay: boolean)}
<div
class="flex items-center justify-center text-secondary {asOverlay
? 'absolute inset-0 z-10 bg-surface/70'
: 'h-full w-full bg-surface'}"
>
<Loader2 size={20} class="animate-spin" />
</div>
{/snippet}
{#if slot.notFound && slot.loadedPath !== path}
<!-- notFound-on-mismatch: the requested target 404'd. Covers first load and a
failed switch (where loadedPath still points at the previous target). -->
<SessionItemNotFound {kind} {path} {onNavigate} />
{:else if slot.loadedPath === undefined}
<!-- First load OR force-reload (loadedPath: B → undefined → B): no editor is
mounted, so the editor's deferred Monaco init can't race a teardown. -->
{@render loadingOverlay(false)}
{:else}
<!-- Breadcrumb swap (A → B): loadedPath stays A here, so editor A remains
visible until B lands; {#key} then remounts to B and its descendants
(e.g. Path.svelte's meta) re-derive from the new target. -->
{#key slot.loadedPath}
{@render editor()}
{/key}
{#if showOverlay}
{@render loadingOverlay(true)}
{/if}
{/if}
@@ -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<Flow> {
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<NewScript> {
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<RawAppDraft> {
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)
}
}
}
@@ -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<Flow>
readonly flowStateStore: { val: Record<string, any> }
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<void>
// 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<void>
// 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<void>
// 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<string | undefined> {
async function generateSessionSummary(
displayMessages: DisplayMessage[]
): Promise<string | undefined> {
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<string | undefined>(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<string | undefined>(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<string | undefined>(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<NewScript>('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)
})
@@ -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<Draft> {
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<Draft> {
/** 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<Draft>
}
/**
* 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<Draft>(opts: UserDraftSyncOptions<Draft>): void {
const { codec } = opts
const handles = UserDraft.useMany<Draft>(() => {
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<typeof setTimeout> | 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<Draft>(codec.itemKind, path, { workspace })
if (current && codec.sig(current) === sig) return
const toSave = codec.storeToDraft(current) ?? draft
UserDraft.save<Draft>(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?.()
})
}