mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 08:01:38 +00:00
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:
@@ -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?.()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user