feat(sessions): scoped preview refresh + multi-target live editors + pipeline preview (#10006)

* perf(sessions): scope preview-tab refresh to items a chat tool touched

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sessions): drop dead editor pane, scope raw-app reload by path

Multi-target migration P0. SessionWrapper's inline editor pane was dead (the sessions page always mounts it with hideEditor); remove it and the single-target machinery (setSessionTarget/pickEditorTarget/target-keyed editor views). Scope the raw-app file/runnable preview reload to args.path (the app's workspace path) instead of the session target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sessions): back editor state with per-(kind,path) cells

Multi-target migration P1. Replace the three per-kind singleton stores/slots with per-(kind,path) cell maps, created on demand and kept (eviction deferred to P3). The runtime's public interface is unchanged: the flowStore/scriptStore/savedScript/rawApp/... getters and slot(kind) now forward to the 'active cell' per kind (a single-target shim, tracked by activePath, removed in P2 when the UI mounts one editor per tab). loadFlow/loadScript/loadRawApp and syncPreviewWithDeployed operate on the resolved cell; load logic and semantics are otherwise unchanged, so loading one item no longer clobbers another's state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sessions): mount every editable preview tab as its own live editor

Multi-target migration P2 — the behavioral flip. resolvePreviewTab no longer takes a target: any editable route (script/flow/raw_app) resolves to an in-process editor, so several items are live at once (iframes remain only for real pages and regular non-raw apps). Each editor binds its own per-(kind,path) cell; the draft codecs close over that cell's store so two editors never cross-write. The single-target shim (activePath + the flowStore/scriptStore/... getters + slot(kind)) is removed; runtime exposes flowCell/scriptCell/rawAppCell(path). Tab open/navigate dedupe by (kind,path) and no longer setTarget. setLiveEditorDraft is gated on the visible tab (isActiveTab) so N editors don't clobber the one-per-(workspace,kind) live-draft slot (path re-key deferred to P4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(sessions): evict unreferenced editor cells; drop dead warm-editor LRU

Multi-target migration P3. Bound the per-(kind,path) editor cell maps: pruneEditorCells drops every cell no open preview tab still references, wired to a new onTabsChanged adapter callback fired on each tab-set change — so closing or navigating a tab away from an item reclaims its cell (dedupe keeps <=1 editor tab per item, so a pruned item has no live editor to strand). Also remove the now-dead editorWarmIds/promoteEditorWarm/MAX_WARM_EDITORS warm-editor LRU: its only reader (SessionWrapper.mountEditor) was removed in P0, and mounted editors are already capped per-tab by mountedTabKeys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sessions): retire session.target; preview is fully tab-driven

Multi-target migration P4 (final). Remove the session.target field and setSessionTarget: the preview is driven entirely by the tab model now (P2). hydratePreviewTabs no longer seeds a tab from target (saved previewTabs only); openEditorInSession seeds the preview via resetSessionPreviewTabs; normalizeLegacySession drops the retired target field from old records. The setLiveEditorDraft focus gate (isActiveTab, one-per-(workspace,kind)) is kept as-is; a per-path re-key is a possible future refinement, not needed for correctness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sessions): describe editor cells as-is, not by their refactor history

Address standards review: AGENTS.md requires comments describe the code as it is, not its drafting history. Drop the 'used to be per-kind singletons' / 'pre-refactor empty editor' / 'now' phrasings from the cell comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sessions): update stale runtime.rawApp.val comments to cell.store

Address spec review: two comments still referenced the removed runtime.rawApp.val accessor; the live code uses the per-cell store now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sessions): fix editor-cell comments after main merge

Main's #9993 added svelte-ignore comments describing the old
runtime.savedFlow.val / runtime.rawApp.val singleton bindings. The
multi-target refactor binds each tab's own editor cell (cell.store /
cell.saved), so update the comment text to match; the ownership_invalid_binding
directives themselves remain correct (the targets are still runtime-owned).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sessions): restore data-pipeline preview as a live editor tab

The multi-target refactor removed the old single-target editor pane —
PipelineEditorView's only mount point — so open_preview(kind="pipeline")
opened nothing, even though the chat tool and system prompt still make it
the first step of pipeline authoring.

Route a /pipeline/<folder> preview tab to the in-process graph editor:
- previewRouter: parsePipelineRoute + resolvePreviewTab map the folder to a
  pipeline editor slot; PreviewSlot.editorKind gains 'pipeline'.
- previewTargetForSessionTarget('pipeline') returns the folder route target
  (was undefined); open() keeps a single pipeline tab and retargets it to the
  requested folder, since all pipeline tabs share one runtime.pipelineEditorState.
- PreviewTabHost mounts PipelineEditorView for the pipeline slot.
- PipelineEditorView gains an `active` prop; AI-helper registration and the
  live-badge poll now gate on isActiveSession && active.

Register the pipeline tools on the session's own chat, not the singleton:
PreviewTabHost mounts the view outside the SessionWrapper subtree that
provides the scoped aiChatManager context, so getAiChatManager() fell back to
the app-wide singleton — build_pipeline_node / edit_pipeline_node never
reached the session chat and the model fell back to write_script (whose draft
never appears on the canvas). Use runtime.manager directly instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sessions): scope list-page preview refresh to the page each tool changes

The scoped-refresh pass reloaded every open list-page preview tab on any
workspace mutation (reloadPages: boolean), so creating a schedule also
refreshed the Resources / Variables tabs.

Replace the blanket flag with the specific page paths each tool can change:
write_schedule → /schedules, write_resource → /resources, write_variable →
/variables, create_folder → /folders, write_trigger → the trigger kind's page;
delete/deploy/discard/rebase map their `type` to its page (none for
script/flow/app). Item-editor writes now reload no pages — their live editor
self-syncs. reloadTabs refreshes a list-page tab only when its own path is in
the touched set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sessions): drop the inert item-reload path; extract a tested previewReload module

Post multi-target, every editable item is a live editor whose reload() no-ops,
and the one iframe item kind (legacy drag-drop apps) is never emitted as a
scope — so the whole `scopes` half of the preview-reload machinery could never
fire. Remove it (PreviewKind, PreviewScope, scopeKey, itemTypeToPreviewKind,
pendingScopes, and the item-route branch of reloadTabs); the `pages` path
already covers every real reload.

Lift the surviving pure logic out of the 900-line route component into
previewReload.ts — toolReloadEffect(name,args) -> {pages} and a new
tabsToReload(tabs,pages) mirroring selectPreviewTabsToClose — and cover it with
previewReload.test.ts (per-tool page mapping, item kinds reload nothing, the
unknown/local-tool silent-stale guard, loc-over-url matching).

Also clear session.target leftovers: delete the unread EDITOR_TARGET_KINDS
export and rewrite five comments that still described the removed single-target
pane / target-record write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sessions): state the preview-reload self-sync invariant once

Consolidate the "live editors self-sync, only list pages reload" rationale
to previewReload.ts and drop the drafting-history phrasings the review
flagged: the update_user_instructions incident and the "(not the runtime)"
contrast in sessionDraftCodecs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sessions): follow the editor cell when a live tab retargets

Address PR review findings on the multi-target preview.

P1 (Codex) — draft sync stayed bound to the old cell after an in-place tab
retarget. useUserDraftSync captured `codec` once, but navigate() re-points a
live editor tab (script/flow/raw_app) to another item without remounting, so
path/workspace/ready followed the new item while the codec still read/wrote the
previous cell's store — cross-writing drafts. Make `codec` a reactive getter
like the hook's other inputs; SessionEditorTarget rebuilds it per path.

P2 (Claude) — navigate() now enforces the single-pipeline-tab invariant that
open() does: retargeting to a /pipeline/<folder> route focuses and re-points the
existing pipeline tab instead of turning the active tab into a second editor
racing the shared pipelineEditorState.

P2 (Claude) — the deploy-in-session handler peeked an editor slot via the
create-on-miss cell accessors, allocating an empty cell for items with no open
tab. Add a non-creating runtime.loadedEditorPath(kind, path) and use it.

P2 (Claude) — correct a SessionPicker comment left stale by the session.target
removal (the preview no longer seeds from a target).

Tests: two navigate() pipeline-invariant cases. npm run check 0 errors; 167
session unit tests pass.

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-07-08 14:34:13 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent fb12b23e01
commit 32c398f27d
23 changed files with 789 additions and 688 deletions
@@ -15,7 +15,8 @@
path,
workspaceId,
onNavigate,
isActiveSession = true
isActiveSession = true,
active = true
}: {
runtime: SessionRuntime
path: string
@@ -24,8 +25,12 @@
/** Forwarded to SessionEditorTarget — only the visible session claims the
* workspace's single live-editor slot. */
isActiveSession?: boolean
/** Whether this is the visible preview tab (forwarded as isActiveTab). */
active?: boolean
} = $props()
// This tab's own flow cell; each open flow editor binds its own store.
const cell = $derived(runtime.flowCell(path))
let selectedId = $state('settings-metadata')
let diffDrawer: DiffDrawer | undefined = $state()
@@ -35,7 +40,7 @@
// baseline — useUserDraftSync's inbound effect then syncs the editor preview.
// Mirrors ScriptEditorView.
async function restoreDeployed() {
const saved = runtime.savedFlow.val
const saved = cell.saved.val
if (!saved) {
sendUserToast('Could not restore to deployed', true)
return
@@ -61,7 +66,7 @@
}
</script>
{#if runtime.savedFlow.val}
{#if cell.saved.val}
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} isFlow />
{/if}
<SessionEditorTarget
@@ -71,7 +76,8 @@
{workspaceId}
{onNavigate}
{isActiveSession}
effectivePath={() => runtime.flowStore.val?.path ?? path}
isActiveTab={active}
effectivePath={() => cell.store.val?.path ?? path}
>
{#snippet editor()}
<!-- customUi hides the in-editor "Flow AI Chat" button: the session already
@@ -83,20 +89,21 @@
its intended name in `draft_path`; seed the builder from `draft_path`
(as the full-page editor does) so the Path widget and deploy use the
friendly name rather than creating a flow literally named draft_<uuid>. -->
<!-- bind:savedFlow targets runtime.savedFlow.val, reactive state owned by the
SessionRuntime class (created in createRuntime), not by a component
ancestor — so Svelte's ownership check flags a false positive here. -->
<!-- bind:savedFlow / flowStore target this tab's editor cell (cell.saved /
cell.store), reactive state owned by the SessionRuntime class (via
flowCell), not by a component ancestor — so Svelte's ownership check
flags a false positive here. -->
<!-- svelte-ignore ownership_invalid_binding -->
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={(runtime.savedFlow.val as any)?.draft_path ?? path}
flowStore={cell.store}
flowStateStore={cell.stateStore}
initialPath={(cell.saved.val as any)?.draft_path ?? path}
autosaveWorkspace={workspaceId}
autosavePath={path}
newFlow={!runtime.savedFlow.val || runtime.savedFlow.val.no_deployed === true}
newFlow={!cell.saved.val || cell.saved.val.no_deployed === true}
{selectedId}
loading={false}
bind:savedFlow={runtime.savedFlow.val}
bind:savedFlow={cell.saved.val}
{diffDrawer}
{onNavigate}
customUi={{ topBar: { aiBuilder: false } }}
@@ -4,7 +4,6 @@
import { Loader2, Workflow } from 'lucide-svelte'
import PipelineGraphEditor from '$lib/components/assets/AssetGraph/PipelineGraphEditor.svelte'
import PipelineTriggerEditors from '$lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte'
import { getAiChatManager } from '$lib/components/copilot/chat/aiChatManagerContext'
import { resolveGraph } from '$lib/components/assets/AssetGraph/resolveGraph'
import { useActiveRunnableIds } from '$lib/components/assets/AssetGraph/activeRunnables.svelte'
import type {
@@ -21,7 +20,8 @@
runtime,
path,
workspaceId,
isActiveSession = true
isActiveSession = true,
active = true
}: {
/** Per-session runtime; owns the pipeline editor state so it survives the
* editor pane unmounting on hide/show. */
@@ -31,10 +31,22 @@
workspaceId: string
/** Only the visible session registers the pipeline tools on its manager. */
isActiveSession?: boolean
/** Whether this is the foreground preview tab. */
active?: boolean
} = $props()
// The session's scoped chat manager (falls back to the singleton off-session).
const aiChatManager = getAiChatManager()
// This session's own chat manager — used directly rather than via
// getAiChatManager()'s context lookup: PreviewTabHost mounts this view in the
// preview panel, outside the SessionWrapper subtree that provides the scoped
// manager context, so a lookup would fall back to the app-wide singleton and
// register the pipeline tools (build_pipeline_node / edit_pipeline_node) on the
// wrong chat — leaving this session's chat unable to build canvas nodes.
const aiChatManager = runtime.manager
// Only the foreground tab of the foreground session owns the chat's pipeline
// tools and runs the live-badge poll — a background tab (another preview tab is
// showing, or another session is active) must not shadow that context or poll.
const engaged = $derived(isActiveSession && active)
// Externalized editor state — lives on the runtime so the drafts persist across
// hide/show of the preview pane (the pane unmounts on hide).
@@ -55,10 +67,10 @@
activeRunnableJobId = undefined
// Re-scope the Global pipeline prompt to the new folder (the helper
// methods already read the reactive path, but the system message
// string was built for the old one). Only when this session is the
// active one — its helpers are the registered set; a hidden session
// reconfigures when it next becomes active.
if (isActiveSession) aiChatManager.rebuildGlobalSystemMessage()
// string was built for the old one). Only when this tab is engaged —
// its helpers are the registered set; a background tab reconfigures
// when it next becomes the foreground one.
if (engaged) aiChatManager.rebuildGlobalSystemMessage()
}
pe.folder = folder
}
@@ -210,9 +222,9 @@
let activeRunnableJobId = $state<string | undefined>(undefined)
$effect(() => {
pathPrefix // re-scope the poll when the folder changes
// Only the visible session needs live badges/event-log; hidden warm panes
// (up to MAX_WARM_EDITORS) shouldn't poll in the background.
activeRunnables.setObserving(isActiveSession)
// Only the engaged (foreground) tab needs live badges/event-log; a
// background tab or a hidden session shouldn't poll.
activeRunnables.setObserving(engaged)
return () => activeRunnables.dispose()
})
$effect(() => {
@@ -270,11 +282,12 @@
}
}
// Register the pipeline tools on this session's manager while the view is the
// active one. setPipelineHelpers rebuilds the global tool set to include the
// pipeline tools and tears them down on cleanup.
// Register the pipeline tools on this session's manager while this tab is the
// engaged (foreground) one. setPipelineHelpers rebuilds the global tool set to
// include the pipeline tools and tears them down on cleanup — so switching to
// another preview tab or session releases them.
$effect(() => {
if (!isActiveSession) return
if (!engaged) return
return aiChatManager.setPipelineHelpers(helpers)
})
</script>
@@ -13,6 +13,7 @@
import ScriptEditorView from './ScriptEditorView.svelte'
import FlowEditorView from './FlowEditorView.svelte'
import RawAppEditorView from './RawAppEditorView.svelte'
import PipelineEditorView from './PipelineEditorView.svelte'
let {
tab,
@@ -33,15 +34,16 @@
mounted: boolean
/** Short tab label, for the iframe title. */
label: string
/** A link click inside a live editor re-points the session target + tab. */
/** A link click inside a live editor re-points the active preview tab. */
onNavigate: (item: WorkspaceItem) => void
/** Iframe finished loading — the page reads back its observed location. */
onLoad: (frame: HTMLIFrameElement) => void
} = $props()
// Editor vs iframe is decided purely from the tab URL + the session's target
// (see resolvePreviewTab). Only the target tab of a wrappable kind goes live.
const slot = $derived(resolvePreviewTab(tab.url, session?.target))
// Editor vs iframe is decided purely from the tab URL (see resolvePreviewTab):
// any editable item (script/flow/raw app) or a pipeline folder mounts its own
// live editor.
const slot = $derived(resolvePreviewTab(tab.url))
const workspaceId = $derived(
session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : ''
)
@@ -80,7 +82,14 @@
{#if slot.kind === 'editor' && mounted && runtime}
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}" aria-hidden={!active}>
{#if slot.editorKind === 'flow'}
<FlowEditorView {runtime} path={slot.path} {workspaceId} {onNavigate} {isActiveSession} />
<FlowEditorView
{runtime}
path={slot.path}
{workspaceId}
{onNavigate}
{isActiveSession}
{active}
/>
{:else if slot.editorKind === 'script'}
<ScriptEditorView
{runtime}
@@ -88,10 +97,20 @@
{workspaceId}
{onNavigate}
{isActiveSession}
{active}
initialTestPanelCollapsed
/>
{:else if slot.editorKind === 'pipeline'}
<PipelineEditorView {runtime} path={slot.path} {workspaceId} {isActiveSession} {active} />
{:else}
<RawAppEditorView {runtime} path={slot.path} {workspaceId} {onNavigate} {isActiveSession} />
<RawAppEditorView
{runtime}
path={slot.path}
{workspaceId}
{onNavigate}
{isActiveSession}
{active}
/>
{/if}
</div>
{:else if mounted}
@@ -17,7 +17,8 @@
path,
workspaceId,
onNavigate,
isActiveSession = true
isActiveSession = true,
active = true
}: {
runtime: SessionRuntime
path: string
@@ -26,13 +27,17 @@
/** Forwarded to SessionEditorTarget — only the visible session claims the
* workspace's single live-editor slot. */
isActiveSession?: boolean
/** Whether this is the visible preview tab (forwarded as isActiveTab). */
active?: boolean
} = $props()
// This tab's own raw-app cell; each open app editor binds its own store.
const cell = $derived(runtime.rawAppCell(path))
let diffDrawer: DiffDrawer | undefined = $state()
// Path typed in the editor header, surfaced when it differs from the stored
// path. Mirror it into the runtime draft as `draft_path` so the rename
// mutates runtime.rawApp.val → the autosave sig changes → the draft is saved
// mutates this cell's store → the autosave sig changes → the draft is saved
// (and the home/review/Drafts lists show the friendly name). Mirrors the
// full-page /apps_raw/edit route.
let pendingDraftPath = $state<string | undefined>(undefined)
@@ -45,7 +50,7 @@
$effect(() => {
const dp = pendingDraftPath
untrack(() => {
const val = runtime.rawApp.val
const val = cell.store.val
if (!val) return
if (dp !== undefined) {
surfacedDraftPath = true
@@ -80,7 +85,7 @@
}
</script>
{#if runtime.savedRawApp.val}
{#if cell.saved.val}
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
{/if}
<SessionEditorTarget
@@ -90,31 +95,33 @@
{workspaceId}
{onNavigate}
{isActiveSession}
effectivePath={() => runtime.rawApp.val?.path ?? path}
isActiveTab={active}
effectivePath={() => cell.store.val?.path ?? path}
>
{#snippet editor()}
{#if runtime.rawApp.val}
{#if cell.store.val}
<!-- newApp: a draft-only app (no_deployed=true) has a truthy synthesized
savedApp but no deployed row, so it must deploy via createApp — keying
on !savedApp alone would updateApp a never-deployed path and 404
"not found". -->
<!-- These bind: targets live on runtime.rawApp.val, reactive state owned by
the SessionRuntime class (created in createRuntime), not by a component
ancestor — so Svelte's ownership check flags a false positive here. -->
<!-- These bind: targets live on this tab's editor cell (cell.store.val /
cell.saved.val), reactive state owned by the SessionRuntime class (via
rawAppCell), not by a component ancestor — so Svelte's ownership check
flags a false positive here. -->
<!-- svelte-ignore ownership_invalid_binding -->
<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}
bind:files={cell.store.val.files}
bind:runnables={cell.store.val.runnables}
bind:data={cell.store.val.data}
bind:summary={cell.store.val.summary}
bind:pendingDraftPath
newPath={runtime.rawApp.val.draft_path ?? runtime.rawApp.val.path}
newPath={cell.store.val.draft_path ?? cell.store.val.path}
{path}
autosaveWorkspace={workspaceId}
autosavePath={path}
policy={runtime.rawApp.val.policy}
bind:savedApp={runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val || runtime.savedRawApp.val.no_deployed === true}
policy={cell.store.val.policy}
bind:savedApp={cell.saved.val}
newApp={!cell.saved.val || cell.saved.val.no_deployed === true}
{diffDrawer}
{onNavigate}
onResetToDeployed={reloadDeployed}
@@ -16,7 +16,8 @@
workspaceId,
onNavigate,
initialTestPanelCollapsed = false,
isActiveSession = true
isActiveSession = true,
active = true
}: {
runtime: SessionRuntime
path: string
@@ -26,8 +27,12 @@
/** Forwarded to SessionEditorTarget — only the visible session claims the
* workspace's single live-editor slot. */
isActiveSession?: boolean
/** Whether this is the visible preview tab (forwarded as isActiveTab). */
active?: boolean
} = $props()
// This tab's own script cell; each open script editor binds its own store.
const cell = $derived(runtime.scriptCell(path))
let diffDrawer: DiffDrawer | undefined = $state()
// Restore actions for the diff drawer. The previous shared
@@ -36,7 +41,7 @@
// reset the live UserDraft handle to the target baseline — the inbound
// effect then syncs the editor preview. Mirrors /scripts/edit's restore.
async function restoreDeployed() {
const saved = runtime.savedScript.val
const saved = cell.saved.val
if (!saved) {
sendUserToast('Could not restore to deployed', true)
return
@@ -71,7 +76,7 @@
}
</script>
{#if runtime.savedScript.val}
{#if cell.saved.val}
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
{/if}
<SessionEditorTarget
@@ -81,10 +86,11 @@
{workspaceId}
{onNavigate}
{isActiveSession}
effectivePath={() => runtime.scriptStore.val?.path ?? path}
isActiveTab={active}
effectivePath={() => cell.store.val?.path ?? path}
>
{#snippet editor()}
{#if runtime.scriptStore.val}
{#if cell.store.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
@@ -94,14 +100,14 @@
edit mode (Save draft + Diff) without navigating away.
-->
<ScriptBuilder
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={runtime.savedScript.val ? path : ''}
bind:script={cell.store.val}
bind:savedScript={cell.saved.val}
initialPath={cell.saved.val ? path : ''}
autosaveWorkspace={workspaceId}
autosavePath={path}
initialPathChosen={true}
neverShowMeta={true}
fullyLoaded={!runtime.slot('script').loading}
fullyLoaded={!cell.slot.loading}
disableHistoryChange={true}
{diffDrawer}
{onNavigate}
@@ -16,7 +16,8 @@
effectivePath,
editor,
onNavigate,
isActiveSession = true
isActiveSession = true,
isActiveTab = true
}: {
runtime: SessionRuntime
kind: SessionTargetKind
@@ -31,12 +32,14 @@
/** 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.
*/
/** A warm-but-hidden session must not claim the live-editor slot. */
isActiveSession?: boolean
/**
* Only the visible editor tab claims the workspace's single live-editor slot
* (one per (workspace, kind)); with several editors open at once, a hidden
* tab must not, else chat actions resolve to the wrong item's path.
*/
isActiveTab?: boolean
} = $props()
// Mark this subtree as the session side panel: editors below detect the
@@ -47,7 +50,15 @@
// rely on its presence, not its identity.
setContext('aiChatManager', runtime.manager)
const slot = $derived(runtime.slot(kind))
// This tab's own editor cell (per (kind, path)); several tabs can be live at once.
const cell = $derived(
kind === 'flow'
? runtime.flowCell(path)
: kind === 'script'
? runtime.scriptCell(path)
: runtime.rawAppCell(path)
)
const slot = $derived(cell.slot)
function triggerLoad(): Promise<void> {
if (kind === 'flow') return runtime.loadFlow(workspaceId, path)
@@ -56,10 +67,15 @@
}
function buildCodec(): DraftSyncCodec<any> {
if (kind === 'flow') return makeFlowCodec(runtime)
if (kind === 'script') return makeScriptCodec(runtime, () => path)
return makeRawAppCodec(runtime)
if (kind === 'flow')
return makeFlowCodec(runtime.flowCell(path).store, runtime.flowCell(path).stateStore)
if (kind === 'script') return makeScriptCodec(runtime.scriptCell(path).store, () => path)
return makeRawAppCodec(runtime.rawAppCell(path).store)
}
// Rebuilds when `path` changes so the sync always binds this tab's current
// cell: retargeting the tab (in-editor link / breadcrumb) swaps `path` without
// remounting, and each codec closes over one cell's store.
const codec = $derived(buildCodec())
$effect(() => {
if (workspaceId && path) {
@@ -69,11 +85,11 @@
// 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).
// path — same registration the regular edit pages do. Only the visible tab of
// the active session claims the (workspace, kind) slot (see prop docs).
$effect(() => {
if (!workspaceId || !path) return
if (!isActiveSession) return
if (!isActiveSession || !isActiveTab) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: kind,
@@ -87,7 +103,7 @@
path: () => path,
workspace: () => workspaceId,
ready: () => slot.loadedPath === path,
codec: buildCodec()
codec: () => codec
})
// Debounced loading affordance for a breadcrumb swap: while the loaded path
@@ -311,8 +311,8 @@
// A new session opened from a Windmill page adopts that page as its first
// preview tab (resetSessionPreviewTabs handles a reused transient whose
// tabs still show a previous destination). Skip when already on the
// sessions page (nothing meaningful to capture) so the preview seeds from
// the session's editor target (or stays empty) instead.
// sessions page (nothing meaningful to capture) so the preview starts
// empty until the chat opens something.
if (!onSessionsPage) {
const url = page.url.pathname + page.url.search
resetSessionPreviewTabs(fresh.id, url)
@@ -19,21 +19,12 @@
ArrowUpRight,
EllipsisVertical,
ExternalLink,
PanelRightClose,
PanelRightOpen,
Pencil,
Settings,
Trash2
} from 'lucide-svelte'
import { type Item } from '$lib/utils'
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import WorkspaceItemDrillPicker from '$lib/components/WorkspaceItemDrillPicker.svelte'
import FlowEditorView from './FlowEditorView.svelte'
import ScriptEditorView from './ScriptEditorView.svelte'
import RawAppEditorView from './RawAppEditorView.svelte'
import PipelineEditorView from './PipelineEditorView.svelte'
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
import SessionChangesBar from './SessionChangesBar.svelte'
import {
@@ -49,37 +40,23 @@
selectSession,
sessionState,
setSessionArchived,
setSessionTarget,
syncWorkspaceTo,
type SessionTarget
syncWorkspaceTo
} from './sessionState.svelte'
import { editorWarmIds, getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
import { getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
import { goto } from '$lib/navigation'
import { base } from '$app/paths'
import { slide } from 'svelte/transition'
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
// hideEditor: never mount the inline editor pane. Used by the sessions page,
// where the edited item is shown in a live page preview (iframe) beside the
// chat instead, so the wrapper contributes only its chat column.
// headerInset: extra left padding on the chat header so it clears a floating
// control (the collapsed-rail launcher) sitting at the screen's top-left.
let {
sessionId,
hideEditor = false,
headerInset = false
}: {
sessionId: string
hideEditor?: boolean
headerInset?: boolean
} = $props()
// LRU-warm sessions get their editor pane mounted; others render
// chat-only. Reading from the reactive Set keeps SessionWrapper in
// sync with promoteEditorWarm without an explicit prop round-trip
// through the page route.
const mountEditor = $derived(editorWarmIds.has(sessionId))
// Parent keys by sessionId; this wrapper only mounts when the session exists.
// Captured at script-init so we can synchronously bind context.
const initialSession = sessionState.sessions.find((s) => s.id === sessionId)
@@ -248,43 +225,6 @@
runtime?.manager.displayMessages.some((m) => m.role === 'user') ?? false
)
// Effective workspace for routing editor views — committed if set,
// otherwise the pending pick, otherwise the current active workspace.
const effectiveWorkspaceId = $derived(
session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : ''
)
// Core mutation: assign a target via the canonical setter, then re-open
// the editor pane. Shared by every code path that swaps the session's
// editor target (drill picker, fork-bar dropdown, …).
function applyEditorTarget(target: SessionTarget, summary?: string) {
if (!session) return
setSessionTarget(session.id, target, summary)
// Picking a target also re-opens the editor pane (the user just chose
// what to view).
editorVisible = true
}
function pickEditorTarget(item: WorkspaceItem) {
// Legacy drag-and-drop apps aren't hosted in the session preview pane —
// open them in the standalone app editor instead. Only code-based raw
// apps (item.raw_app) are previewable here.
if (item.kind === 'app' && !item.raw_app) {
goto(`/apps/edit/${item.path}?workspace=${effectiveWorkspaceId}`)
return
}
// WorkspaceItem.kind is 'flow'|'script'|'app'; any 'app' reaching here is
// a raw app. The diff-API uses 'raw_app' as its kind so we align
// SessionTarget on the same canonical string.
const kind: SessionTarget['kind'] = item.kind === 'app' ? 'raw_app' : item.kind
applyEditorTarget({ kind, path: item.path }, item.summary)
}
// Editor pane visibility. Toggling this just hides/shows the pane via CSS
// — the editor stays mounted, so re-opening doesn't pay a remount cost
// and xy-flow / Monaco keep their viewport state.
let editorVisible = $state(true)
// Focus the chat input whenever this session is the active one.
// The textarea is disabled until copilotInfo loads (otherwise focus is
// a silent no-op), so we wait for that too. Triggers on initial mount,
@@ -333,13 +273,6 @@
{#if !session || !runtime}
<div class="p-8 text-secondary text-sm">Session not found</div>
{:else}
{@const hasTarget =
session.target?.kind === 'flow' ||
session.target?.kind === 'script' ||
session.target?.kind === 'raw_app' ||
session.target?.kind === 'pipeline'}
{@const hasEditor = mountEditor && hasTarget && editorVisible && !hideEditor}
{#snippet inputPreface()}
{#if !hasFirstUserMessage}
<SessionWorkspaceBar {session} />
@@ -386,11 +319,9 @@
sessions have their own empty-state affordances above. -->
{#snippet sessionEmptyHint()}{/snippet}
<!-- Undefined pane sizes (not an explicit `size`): Splitpanes auto-distributes —
a lone chat pane fills 100%, and when the editor pane mounts the two split
50/50. A reactive `size={hasEditor ? 50 : 100}` here instead races the
sibling pane appearing on reload → "Could not resize panes due to constraints"
and a wrong split. -->
<!-- The wrapper contributes only the chat column; edited items are shown in the
page's preview tabs (PreviewTabHost) beside it, not a second pane here. The
single Pane fills 100% (no explicit `size`, so Splitpanes auto-distributes). -->
<div class="flex-1 min-h-0 flex flex-col" use:splitterPointerCapture>
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
@@ -476,55 +407,6 @@
</NameIdTooltip>
</div>
{/if}
{#if !hideEditor && !session.target && hasFirstUserMessage}
<!-- Drill-picker for sessions that have started but haven't
picked an editor target yet. Hidden on fresh sessions
(no messages yet) — the workspace bar is the only
header affordance during the empty state. -->
<div class="ml-auto">
<Popover
placement="bottom-end"
usePointerDownOutside
disableFocusTrap
enableFlyTransition
class="inline-flex"
>
{#snippet trigger()}
<Button variant="default" unifiedSize="xs" startIcon={{ icon: PanelRightOpen }}>
Open editor
</Button>
{/snippet}
{#snippet content()}
<WorkspaceItemDrillPicker
onPick={(item: WorkspaceItem) => pickEditorTarget(item)}
/>
{/snippet}
</Popover>
</div>
{:else if !hideEditor && hasTarget && mountEditor && !editorVisible}
<div class="ml-auto">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: PanelRightOpen }}
onclick={() => (editorVisible = true)}
>
Show editor
</Button>
</div>
{:else if hasEditor}
<div class="ml-auto flex flex-row items-center gap-1">
<button
type="button"
onclick={() => (editorVisible = false)}
title="Close editor"
aria-label="Close editor"
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
>
<PanelRightClose size={14} />
</button>
</div>
{/if}
</header>
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
<AIChat
@@ -545,48 +427,6 @@
/>
</div>
</Pane>
{#if hasEditor && session.target}
<Pane minSize={30} class="flex flex-col min-h-0 p-2 pl-0">
<div
transition:slide={{ axis: 'x', duration: 200 }}
class="flex flex-col flex-1 min-h-0 rounded-md border border-light overflow-hidden relative"
>
{#if session.target.kind === 'flow'}
<FlowEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
onNavigate={pickEditorTarget}
isActiveSession={sessionState.currentSessionId === sessionId}
/>
{:else if session.target.kind === 'script'}
<ScriptEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
onNavigate={pickEditorTarget}
initialTestPanelCollapsed
isActiveSession={sessionState.currentSessionId === sessionId}
/>
{:else if session.target.kind === 'raw_app'}
<RawAppEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
onNavigate={pickEditorTarget}
isActiveSession={sessionState.currentSessionId === sessionId}
/>
{:else if session.target.kind === 'pipeline'}
<PipelineEditorView
{runtime}
path={session.target.path}
workspaceId={effectiveWorkspaceId}
isActiveSession={sessionState.currentSessionId === sessionId}
/>
{/if}
</div>
</Pane>
{/if}
</Splitpanes>
</div>
@@ -18,9 +18,9 @@ export type RawAppDraft = {
draft_path?: string
}
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and
// makes `policy` required for the editor's live binding.
// The shape a raw-app cell's store (`RawAppRuntimeValue` in
// sessionRuntime.svelte.ts) actually holds. Adds `path` (a key, not a draft
// field) and makes `policy` required for the editor's live binding.
export type RuntimeRawApp = {
summary: string
path: string
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
import { toolReloadEffect, tabsToReload } from './previewReload'
import type { SessionPreviewTab } from './sessionState.svelte'
describe('toolReloadEffect', () => {
it('maps a non-item mutation to its own list page only', () => {
expect(toolReloadEffect('write_schedule', { path: 'u/me/s' }).pages).toEqual(['/schedules'])
expect(toolReloadEffect('write_resource', {}).pages).toEqual(['/resources'])
expect(toolReloadEffect('write_variable', {}).pages).toEqual(['/variables'])
expect(toolReloadEffect('create_folder', { name: 'f' }).pages).toEqual(['/folders'])
})
it('maps a trigger write to its kind-specific page', () => {
expect(toolReloadEffect('write_trigger', { kind: 'kafka' }).pages).toEqual(['/kafka_triggers'])
expect(toolReloadEffect('write_trigger', { kind: 'http' }).pages).toEqual(['/routes'])
})
it('maps a generic item tool to the page for its type', () => {
expect(toolReloadEffect('deploy_workspace_item', { type: 'schedule' }).pages).toEqual([
'/schedules'
])
expect(toolReloadEffect('delete_workspace_item', { type: 'resource' }).pages).toEqual([
'/resources'
])
expect(
toolReloadEffect('discard_local_draft', { type: 'trigger', trigger_kind: 'nats' }).pages
).toEqual(['/nats_triggers'])
})
it('reloads no page for item-editor kinds (they self-sync via their live editor)', () => {
for (const type of ['script', 'flow', 'app']) {
expect(toolReloadEffect('deploy_workspace_item', { type }).pages).toEqual([])
}
for (const name of [
'write_script',
'edit_script',
'write_flow',
'init_app',
'write_app_file'
]) {
expect(toolReloadEffect(name, { path: 'u/me/x' }).pages).toEqual([])
}
})
it('reloads nothing for a purely local or unknown tool (the silent-stale guard)', () => {
expect(toolReloadEffect('update_user_instructions', {}).pages).toEqual([])
expect(toolReloadEffect('some_future_tool', { path: 'p' }).pages).toEqual([])
})
it('reloads nothing for a trigger of unknown kind rather than guessing', () => {
expect(toolReloadEffect('write_trigger', { kind: 'not_a_kind' }).pages).toEqual([])
})
})
describe('tabsToReload', () => {
const scheduleTab: SessionPreviewTab = { id: 's', url: '/schedules', loc: '/schedules' }
const resourceTab: SessionPreviewTab = { id: 'r', url: '/resources', loc: '/resources' }
const scriptTab: SessionPreviewTab = {
id: 'sc',
url: '/scripts/edit/f/foo/bar',
loc: '/scripts/edit/f/foo/bar'
}
const pipelineTab: SessionPreviewTab = { id: 'p', url: '/pipeline/crm', loc: '/pipeline/crm' }
const tabs = [scheduleTab, resourceTab, scriptTab, pipelineTab]
it('returns only the tabs whose page is in the set', () => {
expect(tabsToReload(tabs, new Set(['/schedules']))).toEqual([scheduleTab])
})
it('returns list-page tabs but never item-editor or pipeline tabs', () => {
// toolReloadEffect only ever emits list-page paths, so item/pipeline route
// paths are never in `pages` — those tabs self-sync and stay put.
expect(tabsToReload(tabs, new Set(['/schedules', '/resources']))).toEqual([
scheduleTab,
resourceTab
])
})
it('is empty when no pages were touched', () => {
expect(tabsToReload(tabs, new Set())).toEqual([])
})
it('matches on the observed loc (with query/hash stripped) over the seeded url', () => {
const navigated: SessionPreviewTab = { id: 'n', url: '/runs', loc: '/schedules?workspace=w' }
expect(tabsToReload([navigated], new Set(['/schedules']))).toEqual([navigated])
})
})
@@ -0,0 +1,75 @@
import type { SessionPreviewTab } from './sessionState.svelte'
import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewRouter'
// Which list pages a completed chat tool can change, as base-stripped paths
// (e.g. `/schedules`). This allowlist is the single source of truth for "does
// this tool change a list page a preview tab might show". A new mutating tool
// that surfaces on one of these pages must be added here or that tab silently
// goes stale — match by exact tool name, never a name regex, which mis-classifies
// purely-local tools (e.g. `update_user_instructions`) as page mutations.
//
// Item-editor writes (write_script / write_flow / init_app / write_app_*) are
// deliberately absent: every editable item is a live in-process editor that
// self-syncs from the store the chat mutates, so its tab needs no reload — and
// no list page we preview lists open drafts. They fall through to NO_RELOAD.
// This "live editors self-sync, only list pages reload" invariant is the reason
// the callers below and in the sessions page reload nothing for item tabs.
export type ToolReloadEffect = { pages: string[] }
const NO_RELOAD: ToolReloadEffect = { pages: [] }
export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
switch (name) {
case 'write_schedule':
return { pages: ['/schedules'] }
case 'write_trigger':
return { pages: triggerPages(args?.kind) }
case 'write_resource':
return { pages: ['/resources'] }
case 'write_variable':
return { pages: ['/variables'] }
case 'create_folder':
return { pages: ['/folders'] }
// Generic item tools carry a workspace-item `type`; refresh its list page
// when it lives on one (schedule/resource/variable/trigger). script/flow/app
// have their own live editor tab and no previewed list page → nothing.
case 'delete_workspace_item':
case 'discard_local_draft':
case 'deploy_workspace_item':
case 'rebase_draft':
return { pages: pagesForItemType(args?.type, args) }
default:
return NO_RELOAD
}
}
function pagesForItemType(type: unknown, args: any): string[] {
switch (type) {
case 'schedule':
return ['/schedules']
case 'resource':
return ['/resources']
case 'variable':
return ['/variables']
case 'trigger':
return triggerPages(args?.trigger_kind)
default:
return []
}
}
function triggerPages(kind: unknown): string[] {
const page = TRIGGER_PAGES[kind as TriggerKind]
return page ? [page.path] : []
}
// The open tabs a page-reload should refresh: those whose observed page path is
// in `pages`. Item-editor and pipeline tab routes are never list pages, so they
// never match (see the self-sync invariant above). Pure over a tab snapshot so
// the sessions page can reload by id and this stays unit-testable.
export function tabsToReload(
tabs: SessionPreviewTab[],
pages: ReadonlySet<string>
): SessionPreviewTab[] {
if (pages.size === 0) return []
return tabs.filter((t) => pages.has(stripBase(t.loc || t.url)))
}
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parsePreviewItemRoute, resolvePreviewTab } from './previewRouter'
import type { SessionTarget } from './sessionState.svelte'
describe('parsePreviewItemRoute', () => {
it('maps edit/get routes to item kinds', () => {
@@ -34,33 +33,28 @@ describe('parsePreviewItemRoute', () => {
})
describe('resolvePreviewTab', () => {
const scriptTarget: SessionTarget = { kind: 'script', path: 'f/foo/bar' }
it('routes a static page to the iframe fallback', () => {
expect(resolvePreviewTab('/runs', scriptTarget)).toEqual({ kind: 'iframe' })
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
})
it('routes the matching target item to a live editor', () => {
expect(resolvePreviewTab('/scripts/edit/f/foo/bar', scriptTarget)).toEqual({
it('routes any script item to a live editor', () => {
expect(resolvePreviewTab('/scripts/edit/f/foo/bar')).toEqual({
kind: 'editor',
editorKind: 'script',
path: 'f/foo/bar'
})
})
it('routes a same-kind but different item to the iframe (one editor per session)', () => {
expect(resolvePreviewTab('/scripts/edit/f/other/script', scriptTarget)).toEqual({
kind: 'iframe'
it('routes any flow item to a live editor', () => {
expect(resolvePreviewTab('/flows/edit/f/foo/bar')).toEqual({
kind: 'editor',
editorKind: 'flow',
path: 'f/foo/bar'
})
})
it('routes a different-kind item to the iframe even when it matches no target', () => {
expect(resolvePreviewTab('/flows/edit/f/foo/bar', scriptTarget)).toEqual({ kind: 'iframe' })
})
it('maps a raw-app target to the raw_app editor kind', () => {
const target: SessionTarget = { kind: 'raw_app', path: 'f/a/b' }
expect(resolvePreviewTab('/apps_raw/edit/f/a/b', target)).toEqual({
it('maps a raw app to the raw_app editor kind', () => {
expect(resolvePreviewTab('/apps_raw/edit/f/a/b')).toEqual({
kind: 'editor',
editorKind: 'raw_app',
path: 'f/a/b'
@@ -68,11 +62,18 @@ describe('resolvePreviewTab', () => {
})
it('never routes a regular drag-and-drop app to an editor (no wrapper exists)', () => {
const target = { kind: 'raw_app', path: 'f/a/b' } as SessionTarget
expect(resolvePreviewTab('/apps/edit/f/a/b', target)).toEqual({ kind: 'iframe' })
expect(resolvePreviewTab('/apps/edit/f/a/b')).toEqual({ kind: 'iframe' })
})
it('falls back to the iframe when the session has no target', () => {
expect(resolvePreviewTab('/scripts/edit/f/foo/bar', undefined)).toEqual({ kind: 'iframe' })
it('routes a pipeline folder to the pipeline editor kind', () => {
expect(resolvePreviewTab('/pipeline/my_folder')).toEqual({
kind: 'editor',
editorKind: 'pipeline',
path: 'my_folder'
})
})
it('routes the bare pipeline list page to the iframe fallback', () => {
expect(resolvePreviewTab('/pipeline')).toEqual({ kind: 'iframe' })
})
})
@@ -13,7 +13,6 @@ import {
} from 'lucide-svelte'
import type { DrillIcon } from '$lib/components/drillPicker'
import type { WorkspaceItem, WorkspaceItemKind } from '$lib/components/workspacePicker'
import type { SessionTarget } from './sessionState.svelte'
import type { SessionTargetKind } from './sessionRuntime.svelte'
/** What the preview breadcrumb picker can route to: a static workspace page
@@ -104,6 +103,8 @@ export function previewLocationLabel(url: string): string {
if (trigger) return trigger
const run = stripBase(url).match(/^\/run\/([^/?#]+)/)
if (run) return `Run ${decodeURIComponent(run[1]).slice(0, 8)}`
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) return pipelineFolder
const parsed = parsePreviewItemRoute(url)
if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath
return stripBase(url)
@@ -125,16 +126,27 @@ export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null
return { kind: 'app', raw_app: false, itemPath }
}
// How a preview tab should render: as an in-process live editor (sharing the
// session runtime's store) or as an iframe fallback. Only the three kinds with
// existing editor wrappers — and only the tab matching the session's target —
// resolve to 'editor'; everything else (static pages, regular drag-and-drop
// apps, any other item) stays an iframe.
// A `/pipeline/<folder>` route is the data-pipeline graph editor for that folder
// (the folder is a single path segment, not a workspace item path). The bare
// `/pipeline` list page is not an editor. Returns the folder name, or null.
export function parsePipelineRoute(fullPath: string): string | null {
const m = stripBase(fullPath).match(/^\/pipeline\/([^/?#]+)/)
return m ? decodeURIComponent(m[1]) : null
}
// How a preview tab should render: as an in-process live editor or an iframe
// fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts
// its per-(kind,path) cell editor; a `/pipeline/<folder>` route mounts the
// data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path`
// is the folder); everything else (static pages, regular drag-and-drop apps, any
// other route) stays an iframe.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind; path: string }
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'iframe' }
export function resolvePreviewTab(url: string, target: SessionTarget | undefined): PreviewSlot {
export function resolvePreviewTab(url: string): PreviewSlot {
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
const route = parsePreviewItemRoute(url)
if (!route) return { kind: 'iframe' }
const editorKind: SessionTargetKind | undefined =
@@ -146,11 +158,5 @@ export function resolvePreviewTab(url: string, target: SessionTarget | undefined
? 'raw_app'
: undefined
if (!editorKind) return { kind: 'iframe' }
// SessionRuntime holds one load slot per kind, so only the tab pointing at the
// session's own target claims it as a live editor; any other item previews as
// an iframe (the "one live editor per session" rule).
if (!target || target.kind !== editorKind || target.path !== route.itemPath) {
return { kind: 'iframe' }
}
return { kind: 'editor', editorKind, path: route.itemPath }
}
@@ -6,12 +6,11 @@ import { describe, it, expect, vi } from 'vitest'
vi.mock('$lib/components/flows/flowState', () => ({ initFlowState: () => Promise.resolve() }))
import { makeScriptCodec } from './sessionDraftCodecs'
import type { SessionRuntime } from './sessionRuntime.svelte'
import type { NewScript } from '$lib/gen'
// Minimal runtime stub: the script codec only touches `runtime.scriptStore.val`.
function runtimeWith(script: Partial<NewScript> & { path: string }): SessionRuntime {
return { scriptStore: { val: script as NewScript } } as unknown as SessionRuntime
// The script codec closes over one cell's store — a plain `{ val }` object.
function storeWith(script: Partial<NewScript> & { path: string }): { val: NewScript } {
return { val: script as NewScript }
}
const STORAGE = 'u/admin/draft_abc'
@@ -19,7 +18,7 @@ const STORAGE = 'u/admin/draft_abc'
describe('makeScriptCodec — draft_path (path rename)', () => {
it('writes draft_path when the typed path differs from the storage key', () => {
const codec = makeScriptCodec(
runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' }),
storeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' }),
() => STORAGE
)
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
@@ -28,7 +27,7 @@ describe('makeScriptCodec — draft_path (path rename)', () => {
it('drops draft_path when the typed path equals the storage key', () => {
const codec = makeScriptCodec(
runtimeWith({ path: STORAGE, content: 'c', summary: 's' }),
storeWith({ path: STORAGE, content: 'c', summary: 's' }),
() => STORAGE
)
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
@@ -36,9 +35,9 @@ describe('makeScriptCodec — draft_path (path rename)', () => {
})
it('signature changes on a rename, so the outbound sync persists it', () => {
const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' }), () => STORAGE)
const before = makeScriptCodec(storeWith({ path: STORAGE, content: 'c' }), () => STORAGE)
const after = makeScriptCodec(
runtimeWith({ path: 'u/admin/renamed', content: 'c' }),
storeWith({ path: 'u/admin/renamed', content: 'c' }),
() => STORAGE
)
expect(before.sig(before.storeToDraft(undefined)!)).not.toBe(
@@ -2,35 +2,41 @@ 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 { RawAppRuntimeValue } from './sessionRuntime.svelte'
import type { StateStore } from '$lib/utils'
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> {
// Each codec closes over one editor cell's store, so two live editors of the
// same kind sync to their own drafts without crossing.
export function makeFlowCodec(
store: StateStore<Flow>,
stateStore: { val: Record<string, any> }
): DraftSyncCodec<Flow> {
return {
itemKind: 'flow',
sig: flowDraftSig,
debounceMs: DEBOUNCE_MS,
applyDraftToStore(incoming) {
const current = runtime.flowStore.val
const current = store.val
if (!current) return
runtime.flowStore.val = {
store.val = {
...current,
value: incoming.value,
schema: incoming.schema ?? current.schema,
summary: incoming.summary ?? current.summary,
description: incoming.description ?? current.description
}
// flowStateStore is keyed by module_id; after an AI write the set of
// stateStore 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)
void initFlowState(store.val, stateStore)
},
storeToDraft() {
return runtime.flowStore.val
return store.val
}
}
}
@@ -40,7 +46,7 @@ export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec<Flow> {
type ScriptDraft = NewScript & { draft_path?: string }
export function makeScriptCodec(
runtime: SessionRuntime,
store: { val: NewScript | undefined },
// The draft's storage key (the URL path). A never-deployed script is parked
// here at `…/draft_<uuid>` while the user's typed name lives in `script.path`.
storagePath: () => string
@@ -63,7 +69,7 @@ export function makeScriptCodec(
}),
debounceMs: DEBOUNCE_MS,
applyDraftToStore(incoming) {
const script = runtime.scriptStore.val
const script = store.val
if (!script) return
if (typeof incoming.content !== 'string') return
script.content = incoming.content
@@ -71,7 +77,7 @@ export function makeScriptCodec(
if (incoming.summary !== undefined) script.summary = incoming.summary
},
storeToDraft(current) {
const script = runtime.scriptStore.val
const script = store.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.
@@ -89,18 +95,20 @@ export function makeScriptCodec(
}
}
export function makeRawAppCodec(runtime: SessionRuntime): DraftSyncCodec<RawAppDraft> {
export function makeRawAppCodec(store: {
val: RawAppRuntimeValue | undefined
}): DraftSyncCodec<RawAppDraft> {
return {
itemKind: 'raw_app',
sig: (d) => JSON.stringify(d),
debounceMs: DEBOUNCE_MS,
applyDraftToStore(incoming) {
const current = runtime.rawApp.val
const current = store.val
if (!current) return
runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming)
store.val = applyDraftToRuntimeRawApp(current, incoming)
},
storeToDraft() {
const raw = runtime.rawApp.val
const raw = store.val
if (!raw) return undefined
return runtimeRawAppToDraft(raw)
}
@@ -3,21 +3,21 @@ import { randomUUID } from '$lib/utils/uuid'
import { editPathFor, type WorkspaceItem } from '$lib/components/workspacePicker'
import {
matchPreviewPage,
parsePipelineRoute,
parsePreviewItemRoute,
previewLocationLabel,
resolvePreviewTab,
stripBase,
type PreviewTarget
} from './previewRouter'
import { sessionTargetHref } from './sessionMode.svelte'
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
// The single live owner of a session's preview tabs. Runs behind a small
// interface both the sessions page (renderer) and the `open_preview` tool cross,
// so there is exactly one live copy of the tab model instead of three drifting
// ones synced by effects. Persistence and the session-record `target` write are
// injected as an adapter, so the class is pure runes with no sessionState / IDB
// coupling (mirrors PipelineEditorState). Held on SessionRuntime.previewTabs.
// ones synced by effects. Persistence (and cell pruning) are injected as an
// adapter, so the class is pure runes with no sessionState / IDB coupling
// (mirrors PipelineEditorState). Held on SessionRuntime.previewTabs.
export type PreviewTabsSnapshot = {
tabs: SessionPreviewTab[]
@@ -29,9 +29,17 @@ export type PreviewTabsAdapter = {
// Write-behind the full tab model onto the durable backing (debounced by the
// owner). Fire-and-forget.
persist: (snapshot: PreviewTabsSnapshot) => void
// Point the session's live editor at `target`. Called atomically with the tab
// open/navigate that shows the item, so tab and target can never drift apart.
setTarget: (target: SessionTarget) => void
// Fired synchronously on every tab-set change, so the runtime can drop editor
// cells no open tab references anymore (a closed / navigated-away item).
onTabsChanged?: () => void
}
// True when a tab's URL is the live editor for a specific editable item. Every
// editable route resolves to an editor, so this doubles as the "same item" dedupe
// test in open()/navigate().
function isEditorTabFor(url: string, target: SessionTarget): boolean {
const slot = resolvePreviewTab(url)
return slot.kind === 'editor' && slot.editorKind === target.kind && slot.path === target.path
}
// URL a tab should load for a destination: a page's href, or an item's edit route.
@@ -56,8 +64,8 @@ export function canonicalizeObservedLoc(loc: string): string {
}
// The editor target a destination maps to, or undefined when it isn't an item we
// host live (static pages, legacy drag-and-drop apps). Drives the "set the
// session target iff the destination is an editable item" rule.
// host live (static pages, legacy drag-and-drop apps). Drives the open()/navigate()
// dedupe — one editor tab per (kind, path).
function editorTargetFor(target: PreviewTarget): SessionTarget | undefined {
if (target.type !== 'item') return undefined
const item = target.item
@@ -68,13 +76,16 @@ function editorTargetFor(target: PreviewTarget): SessionTarget | undefined {
}
// Adapt a session editor target (`open_preview` tool arg) to a preview
// destination. Pipeline targets have no full-page route, so they can't be
// previewed as a tab (returns undefined).
// destination. A pipeline target's `path` is a folder name, not a workspace
// item — it maps to the `/pipeline/<folder>` route, which resolvePreviewTab
// mounts as the in-process graph editor.
export function previewTargetForSessionTarget(
kind: SessionTarget['kind'],
path: string
): PreviewTarget | undefined {
if (kind === 'pipeline') return undefined
if (kind === 'pipeline') {
return { type: 'page', href: `${base}/pipeline/${encodeURIComponent(path)}`, label: path }
}
const item: WorkspaceItem =
kind === 'raw_app'
? { kind: 'app', raw_app: true, path, summary: '' }
@@ -82,14 +93,12 @@ export function previewTargetForSessionTarget(
return { type: 'item', item }
}
// Build the initial tab model for a session: its saved tabs, else a single tab
// on its editor target, else empty. Default collapse: collapsed only for a
// session with nothing to preview.
// Build the initial tab model for a session: its saved tabs, else empty. Default
// collapse: collapsed only for a session with nothing to preview.
export function hydratePreviewTabs(session: {
previewTabs?: SessionPreviewTab[]
activePreviewTabId?: string
previewCollapsed?: boolean
target?: SessionTarget
}): PreviewTabsSnapshot {
// Saved tabs come straight from IndexedDB — drop malformed records (missing
// id/url) and duplicate ids, which would break the page's keyed {#each}.
@@ -107,14 +116,6 @@ export function hydratePreviewTabs(session: {
const activeId = wantActive && tabs.some((t) => t.id === wantActive) ? wantActive : tabs[0].id
return { tabs, activeId, collapsed: session.previewCollapsed ?? false }
}
const seedUrl = sessionTargetHref(session.target)
if (seedUrl) {
return {
tabs: [{ id: 'session', url: seedUrl, loc: seedUrl }],
activeId: 'session',
collapsed: session.previewCollapsed ?? false
}
}
return { tabs: [], activeId: '', collapsed: session.previewCollapsed ?? true }
}
@@ -158,21 +159,16 @@ export class SessionPreviewTabs {
}
// Open — or focus, if already shown — a tab for a destination, and reveal the
// panel. An editable item is made the session's live editor (setTarget) and
// deduped against the tab already hosting it; anything else dedupes on the
// tab's observed location.
// panel. An editable item dedupes against the tab already hosting that same
// (kind, path); anything else dedupes on the tab's observed location.
open(target: PreviewTarget): { status: 'opened' | 'focused' } {
const editorTarget = editorTargetFor(target)
if (editorTarget) this.#adapter.setTarget(editorTarget)
// A fresh session starts collapsed, so without this the tab opens behind a
// collapsed panel and the user sees nothing change.
this.#collapsed = false
if (editorTarget) {
// resolvePreviewTab(url, target) is 'editor' exactly for the tab showing
// `target`'s item, so it doubles as the dedupe test.
const existing = this.#tabs.find(
(t) => resolvePreviewTab(t.url, editorTarget).kind === 'editor'
)
// One editor tab per item: focus the tab already hosting this exact item.
const existing = this.#tabs.find((t) => isEditorTabFor(t.url, editorTarget))
if (existing) {
this.#activeId = existing.id
this.#flush()
@@ -180,6 +176,23 @@ export class SessionPreviewTabs {
}
}
const url = targetUrl(target)
// Pipeline previews all share one runtime.pipelineEditorState, so keep at
// most one pipeline tab: re-point the existing one to the requested folder
// rather than opening a second pipeline editor that would fight over the
// shared state (`focused` when it already showed this folder, else `opened`
// since the view now shows a different pipeline).
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
const existing = this.#tabs.find((t) => parsePipelineRoute(t.url) !== null)
if (existing) {
const same = existing.url === url
existing.url = url
existing.loc = url
this.#activeId = existing.id
this.#flush()
return { status: same ? 'focused' : 'opened' }
}
}
// Focus the tab currently *showing* this destination instead of opening a
// duplicate. Matched on the observed `loc`, not `url`: a tab that was
// opened here but navigated away no longer counts as showing it.
@@ -197,21 +210,16 @@ export class SessionPreviewTabs {
}
// Re-point the active tab at a destination (breadcrumb pick / in-editor link /
// iframe-posted editor navigation). Same target rule as open: an editable item
// becomes the session's live editor.
// iframe-posted editor navigation).
navigate(target: PreviewTarget): void {
const t = this.#tabs.find((x) => x.id === this.#activeId)
if (!t) return
const editorTarget = editorTargetFor(target)
if (editorTarget) {
this.#adapter.setTarget(editorTarget)
// Same dedupe as open(): if another tab already hosts `target` as the
// live editor, focus it instead of re-pointing this one — two tabs
// resolving 'editor' for one target would mount two editor instances
// on the same runtime slot.
const existing = this.#tabs.find(
(x) => resolvePreviewTab(x.url, editorTarget).kind === 'editor'
)
// Same dedupe as open(): if another tab already hosts this exact item,
// focus it instead of re-pointing this one — two tabs for one item would
// mount two editors racing the same (kind, path) cell.
const existing = this.#tabs.find((x) => isEditorTabFor(x.url, editorTarget))
if (existing && existing.id !== t.id) {
this.#activeId = existing.id
this.#flush()
@@ -219,6 +227,21 @@ export class SessionPreviewTabs {
}
}
const url = targetUrl(target)
// Keep at most one pipeline tab (all share runtime.pipelineEditorState): if a
// *different* tab already hosts a pipeline, retarget and focus it rather than
// turning the active tab into a second pipeline editor racing the shared
// state. Same invariant as open(); a no-op when the active tab is that tab.
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
const existing = this.#tabs.find((x) => parsePipelineRoute(x.url) !== null)
if (existing && existing.id !== t.id) {
existing.url = url
existing.loc = url
this.#activeId = existing.id
this.#flush()
return
}
}
t.url = url
t.loc = url
this.#flush()
@@ -298,6 +321,9 @@ export class SessionPreviewTabs {
}
#flush(): void {
// Prune cells promptly (cheap, synchronous) even though the durable persist
// stays debounced — a closed tab's editor cell should be reclaimable now.
this.#adapter.onTabsChanged?.()
clearTimeout(this.#flushHandle)
this.#flushHandle = setTimeout(() => {
this.#flushHandle = undefined
@@ -335,25 +361,23 @@ export function selectPreviewTabsToClose(
}
// Human-readable summary of a session's open preview tabs, for the
// `get_preview_status` AI tool. Pure over the owner's model + the session target
// so the owner needs no target-read dependency. The "no session" case is the
// caller's (the tool handler has the session context).
export function describePreview(
tabs: SessionPreviewTab[],
activeId: string,
target: SessionTarget | undefined
): string {
// `get_preview_status` AI tool. Pure over the owner's model. The "no session"
// case is the caller's (the tool handler has the session context).
export function describePreview(tabs: SessionPreviewTab[], activeId: string): string {
if (tabs.length === 0) return 'No preview tabs are open in the side panel.'
const lines = tabs.map((t) => {
const where = t.loc || t.url
const page = matchPreviewPage(where)
const pipelineFolder = parsePipelineRoute(where)
const route = parsePreviewItemRoute(where)
const label = page
? `page "${page.label}"`
: route
? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"`
: stripBase(where)
const live = resolvePreviewTab(t.url, target).kind === 'editor' ? ', live editor' : ''
: pipelineFolder
? `pipeline "${pipelineFolder}"`
: route
? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"`
: stripBase(where)
const live = resolvePreviewTab(t.url).kind === 'editor' ? ', live editor' : ''
const active = t.id === activeId ? ', active' : ''
return `- ${label}${live}${active}`
})
@@ -9,17 +9,16 @@ import {
type PreviewTabsSnapshot
} from './sessionPreviewTabs.svelte'
import type { PreviewTarget } from './previewRouter'
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
import type { SessionPreviewTab } from './sessionState.svelte'
import { base } from '$lib/base'
// In-memory adapter spy: records persisted snapshots + target writes, no IDB.
// In-memory adapter spy: records persisted snapshots, no IDB.
function makeAdapter() {
const persisted: PreviewTabsSnapshot[] = []
const targets: SessionTarget[] = []
const adapter: PreviewTabsAdapter = {
persist: (snap) => persisted.push(snap),
setTarget: (t) => targets.push(t)
persist: (snap) => persisted.push(snap)
}
return { adapter, persisted, targets }
return { adapter, persisted }
}
function owner(initial: Partial<PreviewTabsSnapshot> = {}, adapter?: PreviewTabsAdapter) {
@@ -48,6 +47,12 @@ const dndAppTarget: PreviewTarget = {
item: { kind: 'app', path: 'u/me/legacy', summary: '' }
}
const pageTarget: PreviewTarget = { type: 'page', href: '/runs', label: 'Runs' }
const pipelineTarget: PreviewTarget = { type: 'page', href: `${base}/pipeline/crm`, label: 'crm' }
const pipelineTarget2: PreviewTarget = {
type: 'page',
href: `${base}/pipeline/sales`,
label: 'sales'
}
beforeEach(() => {
vi.useFakeTimers()
@@ -76,15 +81,6 @@ describe('hydratePreviewTabs', () => {
expect(snap.activeId).toBe('a')
})
it('seeds a single tab on the editor target when there are no saved tabs', () => {
const snap = hydratePreviewTabs({ target: { kind: 'script', path: 'u/me/foo' } })
expect(snap.tabs).toEqual([
{ id: 'session', url: '/scripts/edit/u/me/foo', loc: '/scripts/edit/u/me/foo' }
])
expect(snap.activeId).toBe('session')
expect(snap.collapsed).toBe(false)
})
it('is empty and collapsed for a session with nothing to preview', () => {
const snap = hydratePreviewTabs({})
expect(snap.tabs).toEqual([])
@@ -93,10 +89,7 @@ describe('hydratePreviewTabs', () => {
})
it('honours an explicit previewCollapsed override', () => {
expect(
hydratePreviewTabs({ previewCollapsed: true, target: { kind: 'script', path: 'p' } })
.collapsed
).toBe(true)
expect(hydratePreviewTabs({ previewCollapsed: true }).collapsed).toBe(true)
expect(hydratePreviewTabs({ previewCollapsed: false }).collapsed).toBe(false)
})
@@ -115,13 +108,12 @@ describe('hydratePreviewTabs', () => {
expect(snap.activeId).toBe('a')
})
it('falls back to the target seed when every saved tab is malformed', () => {
it('is empty when every saved tab is malformed', () => {
const snap = hydratePreviewTabs({
previewTabs: [{ id: '', url: '', loc: '' }],
target: { kind: 'script', path: 'u/me/foo' }
previewTabs: [{ id: '', url: '', loc: '' }]
})
expect(snap.tabs).toHaveLength(1)
expect(snap.activeId).toBe('session')
expect(snap.tabs).toEqual([])
expect(snap.activeId).toBe('')
})
})
@@ -142,22 +134,24 @@ describe('previewTargetForSessionTarget', () => {
item: { kind: 'flow', path: 'p', summary: '' }
})
})
it('returns undefined for pipeline (no full-page route)', () => {
expect(previewTargetForSessionTarget('pipeline', 'p')).toBeUndefined()
it('maps pipeline to its folder route page target', () => {
expect(previewTargetForSessionTarget('pipeline', 'my_folder')).toEqual({
type: 'page',
href: `${base}/pipeline/my_folder`,
label: 'my_folder'
})
})
})
describe('SessionPreviewTabs.open', () => {
it('opens an editor item, points the target at it, activates it, and reveals the panel', () => {
const { adapter, targets } = makeAdapter()
const o = owner({ collapsed: true }, adapter)
it('opens an editor item, activates it, and reveals the panel', () => {
const o = owner({ collapsed: true })
const res = o.open(scriptTarget)
expect(res.status).toBe('opened')
expect(o.tabs).toHaveLength(1)
expect(o.tabs[0].url).toBe('/scripts/edit/u/me/foo')
expect(o.activeId).toBe(o.tabs[0].id)
expect(o.collapsed).toBe(false)
expect(targets).toEqual([{ kind: 'script', path: 'u/me/foo' }])
})
it('focuses the existing tab instead of duplicating when the item is already shown', () => {
@@ -171,27 +165,23 @@ describe('SessionPreviewTabs.open', () => {
expect(o.activeId).toBe(firstId)
})
it('opens a second tab and repoints the live editor for a different item', () => {
const { adapter, targets } = makeAdapter()
const o = owner({}, adapter)
it('opens a second tab for a different editor item', () => {
const o = owner()
o.open(scriptTarget)
const res = o.open(flowTarget)
expect(res.status).toBe('opened')
expect(o.tabs).toHaveLength(2)
expect(targets.at(-1)).toEqual({ kind: 'flow', path: 'u/me/bar' })
expect(o.tabs.at(-1)!.url).toBe('/flows/edit/u/me/bar')
})
it('opens a raw app via its apps_raw route', () => {
const { adapter, targets } = makeAdapter()
const o = owner({}, adapter)
const o = owner()
o.open(rawAppTarget)
expect(o.tabs[0].url).toBe('/apps_raw/edit/u/me/app')
expect(targets).toEqual([{ kind: 'raw_app', path: 'u/me/app' }])
})
it('focuses the tab already showing a page instead of duplicating, and never sets a target', () => {
const { adapter, targets } = makeAdapter()
const o = owner({}, adapter)
it('focuses the tab already showing a page instead of duplicating', () => {
const o = owner()
o.open(pageTarget)
const firstId = o.activeId
o.open(scriptTarget)
@@ -199,7 +189,6 @@ describe('SessionPreviewTabs.open', () => {
expect(res.status).toBe('focused')
expect(o.tabs).toHaveLength(2)
expect(o.activeId).toBe(firstId)
expect(targets).toEqual([{ kind: 'script', path: 'u/me/foo' }])
})
it('opens a fresh page tab when the original navigated away', () => {
@@ -221,19 +210,16 @@ describe('SessionPreviewTabs.open', () => {
expect(o.tabs).toHaveLength(1)
})
it('does not set a target for a legacy drag-and-drop app', () => {
const { adapter, targets } = makeAdapter()
const o = owner({}, adapter)
it('opens a legacy drag-and-drop app as an iframe route', () => {
const o = owner()
o.open(dndAppTarget)
expect(targets).toEqual([])
expect(o.tabs[0].url).toBe('/apps/edit/u/me/legacy')
})
})
describe('SessionPreviewTabs.navigate', () => {
it('retargets the active tab and sets the target for an editor item', () => {
const { adapter, targets } = makeAdapter()
const o = owner({}, adapter)
it('retargets the active tab to an editor item', () => {
const o = owner()
o.open(pageTarget)
const tabId = o.activeId
o.navigate(flowTarget)
@@ -241,25 +227,19 @@ describe('SessionPreviewTabs.navigate', () => {
expect(o.activeId).toBe(tabId)
expect(o.tabs[0].url).toBe('/flows/edit/u/me/bar')
expect(o.tabs[0].loc).toBe('/flows/edit/u/me/bar')
expect(targets).toEqual([{ kind: 'flow', path: 'u/me/bar' }])
})
it('no-ops with no active tab', () => {
const { adapter, targets } = makeAdapter()
const o = owner({}, adapter)
const o = owner()
o.navigate(flowTarget)
expect(o.tabs).toHaveLength(0)
expect(targets).toEqual([])
})
it('retargets to a page without touching the target', () => {
const { adapter, targets } = makeAdapter()
const o = owner({}, adapter)
it('retargets to a page', () => {
const o = owner()
o.open(scriptTarget)
targets.length = 0
o.navigate(pageTarget)
expect(o.tabs[0].url).toBe('/runs')
expect(targets).toEqual([])
})
it('focuses the tab already hosting the item instead of duplicating the editor', () => {
@@ -275,6 +255,31 @@ describe('SessionPreviewTabs.navigate', () => {
// The page tab must keep its own url — only focus moved.
expect(o.tabs.find((t) => t.id === pageTabId)?.url).toBe('/runs')
})
it('retargets the one pipeline tab instead of turning the active tab into a second', () => {
const o = owner()
o.open(pipelineTarget)
const pipelineTabId = o.activeId
o.open(scriptTarget) // a second, non-pipeline tab is now active
const scriptTabId = o.activeId
o.navigate(pipelineTarget2)
// No second pipeline editor: the existing one is retargeted and focused.
expect(o.tabs).toHaveLength(2)
expect(o.activeId).toBe(pipelineTabId)
expect(o.tabs.find((t) => t.id === pipelineTabId)?.url).toBe(`${base}/pipeline/sales`)
// The script tab is untouched.
expect(o.tabs.find((t) => t.id === scriptTabId)?.url).toBe('/scripts/edit/u/me/foo')
})
it('retargets the active pipeline tab in place to a new folder', () => {
const o = owner()
o.open(pipelineTarget)
const tabId = o.activeId
o.navigate(pipelineTarget2)
expect(o.tabs).toHaveLength(1)
expect(o.activeId).toBe(tabId)
expect(o.tabs[0].url).toBe(`${base}/pipeline/sales`)
})
})
describe('SessionPreviewTabs.select / close / setCollapsed', () => {
@@ -442,23 +447,23 @@ describe('SessionPreviewTabs persistence', () => {
describe('describePreview', () => {
it('reports no tabs when there are none', () => {
expect(describePreview([], '', undefined)).toContain('No preview tabs')
expect(describePreview([], '')).toContain('No preview tabs')
})
it('lists tabs, marks the active one, and flags the live editor', () => {
const tabs: SessionPreviewTab[] = [
{ id: 'a', url: '/scripts/edit/u/me/foo', loc: '/scripts/edit/u/me/foo' }
]
const out = describePreview(tabs, 'a', { kind: 'script', path: 'u/me/foo' })
const out = describePreview(tabs, 'a')
expect(out).toContain('1 preview tab')
expect(out).toContain('script "u/me/foo"')
expect(out).toContain('live editor')
expect(out).toContain('active')
})
it('labels a known page and omits the live-editor flag when the target differs', () => {
it('labels a known page and omits the live-editor flag for a non-item page', () => {
const tabs: SessionPreviewTab[] = [{ id: 'a', url: '/runs', loc: '/runs' }]
const out = describePreview(tabs, 'a', { kind: 'script', path: 'u/me/foo' })
const out = describePreview(tabs, 'a')
expect(out).toContain('page "Runs"')
expect(out).not.toContain('live editor')
})
@@ -1,4 +1,4 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { SvelteMap } from 'svelte/reactivity'
import { get } from 'svelte/store'
import { base } from '$lib/base'
import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
@@ -39,7 +39,6 @@ import {
setSessionChatId,
setSessionPreviewCollapsed,
setSessionTabs,
setSessionTarget,
type Session
} from './sessionState.svelte'
import {
@@ -49,7 +48,7 @@ import {
previewTargetForSessionTarget,
selectPreviewTabsToClose
} from './sessionPreviewTabs.svelte'
import { matchPreviewPage, previewLocationLabel } from './previewRouter'
import { matchPreviewPage, parsePreviewItemRoute, previewLocationLabel } from './previewRouter'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { armRestartOnFirstInteraction } from '$lib/userDraftToast'
@@ -93,6 +92,55 @@ export interface LoadSlot {
export type SessionTargetKind = 'flow' | 'script' | 'raw_app'
// The live runtime value a raw-app editor cell binds. Legacy drag-and-drop apps
// are intentionally NOT hosted in the session preview (only code-based raw apps).
export interface RawAppRuntimeValue {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
summary: string
path: string
custom_path?: string
draft_path?: string
}
// The deployed baseline a raw-app cell diffs against (topbar Diff drawer).
export interface RawAppSavedValue {
value: {
files: Record<string, { code: string }>
runnables: Record<string, HiddenRunnable>
}
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
/** No deployed counterpart (draft-only); disables the topbar Diff. */
no_deployed?: boolean
custom_path?: string
}
// One editor cell per (kind, path) the session loads: the load slot plus the
// content/baseline stores for that item. Keying by path lets several items of the
// same kind stay loaded — and mounted as separate live editors — at once. A tab's
// editor resolves its own cell via the accessors below.
export interface FlowCell {
slot: LoadSlot
store: StateStore<Flow>
stateStore: { val: Record<string, any> }
saved: { val: SavedFlow | undefined }
}
export interface ScriptCell {
slot: LoadSlot
store: { val: NewScript | undefined }
saved: { val: SavedScript | undefined }
}
export interface RawAppCell {
slot: LoadSlot
store: { val: RawAppRuntimeValue | undefined }
saved: { val: RawAppSavedValue | undefined }
}
export interface SessionRuntime {
readonly sessionId: string
readonly manager: AIChatManager
@@ -103,54 +151,18 @@ export interface SessionRuntime {
// Pipeline target state — persists across editor hide/show (the pane unmounts
// on hide, so this can't be component-local) and across session switches.
readonly pipelineEditorState: PipelineEditorState
// 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: SavedFlow | undefined }
// Per-(kind, path) editor cells (content/baseline stores + load slot), created
// on demand. Each editable preview tab resolves its own cell, so several items
// stay live at once.
flowCell(path: string): FlowCell
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: SavedScript | undefined }
scriptCell(path: string): ScriptCell
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
// app target state here.
// Raw App (HTML-based) target state
readonly rawApp: {
val:
| {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
summary: string
path: string
custom_path?: string
draft_path?: string
}
| undefined
}
readonly savedRawApp: {
val:
| {
value: {
files: Record<string, { code: string }>
runnables: Record<string, HiddenRunnable>
}
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
/** No deployed counterpart (draft-only); disables the topbar Diff. */
no_deployed?: boolean
custom_path?: string
}
| undefined
}
rawAppCell(path: string): RawAppCell
// Non-creating peek at an editor cell's settled path (undefined when no cell
// exists yet for this (kind, path)), so callers can check load state without
// the cell accessors' create-on-miss side effect.
loadedEditorPath(kind: SessionTargetKind, path: string): string | undefined
loadRawApp(
workspace: string,
path: string,
@@ -185,6 +197,32 @@ function emptyFlow(): Flow {
}
}
function emptyLoadSlot(): LoadSlot {
return { loadedPath: undefined, loadedWorkspace: undefined, loading: false, notFound: false }
}
// Cell factories — a cell starts in the empty-editor state (empty flow / no
// script / no app) until its first load populates it.
function makeFlowCell(): FlowCell {
const slot: LoadSlot = $state(emptyLoadSlot())
const store: StateStore<Flow> = $state({ val: emptyFlow() })
const stateStore: { val: Record<string, any> } = $state({ val: {} })
const saved: { val: SavedFlow | undefined } = $state({ val: undefined })
return { slot, store, stateStore, saved }
}
function makeScriptCell(): ScriptCell {
const slot: LoadSlot = $state(emptyLoadSlot())
const store: { val: NewScript | undefined } = $state({ val: undefined })
const saved: { val: SavedScript | undefined } = $state({ val: undefined })
return { slot, store, saved }
}
function makeRawAppCell(): RawAppCell {
const slot: LoadSlot = $state(emptyLoadSlot())
const store: { val: RawAppRuntimeValue | undefined } = $state({ val: undefined })
const saved: { val: RawAppSavedValue | undefined } = $state({ val: undefined })
return { slot, store, saved }
}
const GENERATED_SUMMARY_TIMEOUT_MS = 15000
const GENERATED_SUMMARY_MAX_TRANSCRIPT_CHARS = 4000
const GENERATED_SUMMARY_MAX_LENGTH = 60
@@ -309,48 +347,65 @@ function createRuntime(session: Session): SessionRuntime {
}
manager.afterFirstTurnSaved = () => generateAndApplySessionSummary(session.id, manager)
const flowStore: StateStore<Flow> = $state({ val: emptyFlow() })
const flowStateStore: { val: Record<string, any> } = $state({ val: {} })
const savedFlow: { val: SavedFlow | undefined } = $state({
val: undefined
})
const flowSlot: LoadSlot = $state({
loadedPath: undefined,
loadedWorkspace: undefined,
loading: false,
notFound: false
})
const scriptStore: { val: NewScript | undefined } = $state({ val: undefined })
const savedScript: { val: SavedScript | undefined } = $state({ val: undefined })
const scriptSlot: LoadSlot = $state({
loadedPath: undefined,
loadedWorkspace: undefined,
loading: false,
notFound: false
})
const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined })
const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined })
const rawAppSlot: LoadSlot = $state({
loadedPath: undefined,
loadedWorkspace: undefined,
loading: false,
notFound: false
})
// One cell per (kind, path). Created on demand by the load methods; each holds
// cached content (KBMB), not a mounted editor. Bounded to the items open
// preview tabs reference: pruneEditorCells (below) drops the rest when the tab
// set changes, so re-pointing one tab through many items can't leak cells.
const flowCells = new Map<string, FlowCell>()
const scriptCells = new Map<string, ScriptCell>()
const rawAppCells = new Map<string, RawAppCell>()
function flowCell(path: string): FlowCell {
let c = flowCells.get(path)
if (!c) flowCells.set(path, (c = makeFlowCell()))
return c
}
function scriptCell(path: string): ScriptCell {
let c = scriptCells.get(path)
if (!c) scriptCells.set(path, (c = makeScriptCell()))
return c
}
function rawAppCell(path: string): RawAppCell {
let c = rawAppCells.get(path)
if (!c) rawAppCells.set(path, (c = makeRawAppCell()))
return c
}
function loadedEditorPath(kind: SessionTargetKind, path: string): string | undefined {
const cell =
kind === 'flow'
? flowCells.get(path)
: kind === 'script'
? scriptCells.get(path)
: rawAppCells.get(path)
return cell?.slot.loadedPath
}
// Drop every editor cell no open preview tab still points at. Called on each
// tab-set change: a closed or navigated-away item's cell (and its cached
// content) is reclaimed. Deduping keeps at most one editor tab per item, so an
// item absent from the open tabs has no live editor to strand.
function pruneEditorCells(): void {
const keep = { flow: new Set<string>(), script: new Set<string>(), raw_app: new Set<string>() }
for (const t of previewTabs.tabs) {
const route = parsePreviewItemRoute(t.url)
if (!route) continue
const kind = route.raw_app ? 'raw_app' : route.kind
if (kind === 'flow' || kind === 'script' || kind === 'raw_app') keep[kind].add(route.itemPath)
}
for (const p of [...flowCells.keys()]) if (!keep.flow.has(p)) flowCells.delete(p)
for (const p of [...scriptCells.keys()]) if (!keep.script.has(p)) scriptCells.delete(p)
for (const p of [...rawAppCells.keys()]) if (!keep.raw_app.has(p)) rawAppCells.delete(p)
}
// Hydrate the preview-tab owner from the session record (the durable backing);
// from here on the owner is the single live copy and writes back through the
// adapter. setSessionTabs / setSessionPreviewCollapsed / setSessionTarget stay
// the low-level record writers (a transient session's writes land in the
// localStorage draft slot until it materialises).
// adapter. setSessionTabs / setSessionPreviewCollapsed stay the low-level record
// writers (a transient session's writes land in the localStorage draft slot
// until it materialises).
const previewTabs = new SessionPreviewTabs(hydratePreviewTabs(session), {
persist: (snap) => {
setSessionTabs(session.id, snap.tabs, snap.activeId)
setSessionPreviewCollapsed(session.id, snap.collapsed)
},
setTarget: (target) => setSessionTarget(session.id, target)
onTabsChanged: pruneEditorCells
})
// Let the jobs tray open a run in this session's preview panel (as an iframe
@@ -376,23 +431,20 @@ function createRuntime(session: Session): SessionRuntime {
sessionId: session.id,
manager,
previewTabs,
slot(kind: SessionTargetKind): LoadSlot {
return kind === 'flow' ? flowSlot : kind === 'script' ? scriptSlot : rawAppSlot
},
pipelineEditorState,
flowStore,
flowStateStore,
savedFlow,
flowCell,
loadedEditorPath,
async loadFlow(workspace: string, path: string, force = false) {
if (flowSlot.loadedPath === path && flowSlot.loadedWorkspace === workspace && !force) return
const { slot, store, stateStore, saved } = flowCell(path)
if (slot.loadedPath === path && slot.loadedWorkspace === workspace && !force) return
// See loadScript: forced reload remounts via the render gate. A workspace
// retarget (same path, new fork) drops the stale content the same way so
// the editor gate shows loading and outbound sync can't write the old
// workspace's content into the new one before the fetch lands.
if (force || flowSlot.loadedWorkspace !== workspace) flowSlot.loadedPath = undefined
flowSlot.loading = true
flowSlot.notFound = false
if (force || slot.loadedWorkspace !== workspace) slot.loadedPath = undefined
slot.loading = true
slot.notFound = false
try {
// Draft first. UserDraft is the shared authoritative content
// source — the chat (write_flow / patch_flow_json /
@@ -416,21 +468,20 @@ function createRuntime(session: Session): SessionRuntime {
// yet on the backend — draft-only flows are a valid state.
try {
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
savedFlow.val = result as SavedFlow
saved.val = result as SavedFlow
} catch {
savedFlow.val = undefined
saved.val = undefined
}
await initFlow(aiDraft, flowStore, flowStateStore)
if (deployedVersionId != null && flowStore.val)
flowStore.val.version_id = deployedVersionId
flowSlot.loadedPath = path
flowSlot.loadedWorkspace = workspace
await initFlow(aiDraft, store, stateStore)
if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId
slot.loadedPath = path
slot.loadedWorkspace = workspace
return
}
// No local draft yet — seed from `result.draft ?? result`.
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
savedFlow.val = result as SavedFlow
saved.val = result as SavedFlow
const flow: Flow = ((result as SavedFlow).draft ?? (result as Flow)) as Flow
// Seed the per-tab last_sync from the server draft's timestamp so the
// seeding save below attaches a matching last_sync and the server can
@@ -443,31 +494,30 @@ function createRuntime(session: Session): SessionRuntime {
(result as SavedFlow).draft_saved_at
)
UserDraft.save('flow', path, flow, { workspace })
await initFlow(flow, flowStore, flowStateStore)
if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId
flowSlot.loadedPath = path
flowSlot.loadedWorkspace = workspace
await initFlow(flow, store, stateStore)
if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId
slot.loadedPath = path
slot.loadedWorkspace = workspace
} catch (err) {
console.error('Failed to load flow', err)
flowSlot.notFound = true
slot.notFound = true
} finally {
flowSlot.loading = false
slot.loading = false
}
},
scriptStore,
savedScript,
scriptCell,
async loadScript(workspace: string, path: string, force = false) {
if (scriptSlot.loadedPath === path && scriptSlot.loadedWorkspace === workspace && !force)
return
const { slot, store, saved } = scriptCell(path)
if (slot.loadedPath === path && slot.loadedWorkspace === workspace && !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.loadedWorkspace !== workspace) scriptSlot.loadedPath = undefined
scriptSlot.loading = true
scriptSlot.notFound = false
if (force || slot.loadedWorkspace !== workspace) slot.loadedPath = undefined
slot.loading = true
slot.notFound = false
try {
// Draft first. UserDraft is the shared authoritative content
// source — the chat (write_script / edit_script) and the
@@ -482,16 +532,16 @@ function createRuntime(session: Session): SessionRuntime {
// savedScript undefined and skip parent_hash.
try {
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
savedScript.val = result as SavedScript
saved.val = result as SavedScript
} catch {
savedScript.val = undefined
saved.val = undefined
}
// Clone before layering the AI draft on top, else we'd mutate
// `savedScript.val` in place and lose the pristine diff baseline.
const baseline: NewScript = savedScript.val
// `saved.val` in place and lose the pristine diff baseline.
const baseline: NewScript = saved.val
? (structuredClone(
$state.snapshot(
(savedScript.val.draft as NewScript | undefined) ?? (savedScript.val as NewScript)
(saved.val.draft as NewScript | undefined) ?? (saved.val as NewScript)
)
) as NewScript)
: {
@@ -510,21 +560,21 @@ function createRuntime(session: Session): SessionRuntime {
schema: emptySchema(),
language: (aiDraft.language ?? 'bun') as any
}
if (savedScript.val?.hash) {
baseline.parent_hash = savedScript.val.hash
if (saved.val?.hash) {
baseline.parent_hash = saved.val.hash
}
baseline.content = aiDraft.content
if (aiDraft.language) baseline.language = aiDraft.language
if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary
scriptStore.val = baseline
scriptSlot.loadedPath = path
scriptSlot.loadedWorkspace = workspace
store.val = baseline
slot.loadedPath = path
slot.loadedWorkspace = workspace
return
}
// No local draft yet — seed from `result.draft ?? result`.
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
savedScript.val = result as SavedScript
saved.val = result as SavedScript
// Clone before mutating, else `baseline` aliases `result` and
// `baseline.parent_hash` corrupts the diff baseline.
const baseline = structuredClone(
@@ -542,27 +592,26 @@ function createRuntime(session: Session): SessionRuntime {
(result as SavedScript).draft_saved_at
)
UserDraft.save<NewScript>('script', path, baseline, { workspace })
scriptStore.val = baseline
scriptSlot.loadedPath = path
scriptSlot.loadedWorkspace = workspace
store.val = baseline
slot.loadedPath = path
slot.loadedWorkspace = workspace
} catch (err) {
console.error('Failed to load script', err)
scriptSlot.notFound = true
slot.notFound = true
} finally {
scriptSlot.loading = false
slot.loading = false
}
},
rawApp,
savedRawApp,
rawAppCell,
async loadRawApp(workspace: string, path: string, force = false, deployedOnly = false) {
if (rawAppSlot.loadedPath === path && rawAppSlot.loadedWorkspace === workspace && !force)
return
const { slot, store, saved } = rawAppCell(path)
if (slot.loadedPath === path && slot.loadedWorkspace === workspace && !force) return
// See loadScript: forced reload remounts via the render gate.
if (force || rawAppSlot.loadedWorkspace !== workspace) rawAppSlot.loadedPath = undefined
rawAppSlot.loading = true
rawAppSlot.notFound = false
if (force || slot.loadedWorkspace !== workspace) slot.loadedPath = undefined
slot.loading = true
slot.notFound = false
try {
// Draft first. UserDraft is the shared authoritative content
// source — the chat (init_app / write_app_file / ...) and the
@@ -586,7 +635,7 @@ function createRuntime(session: Session): SessionRuntime {
})
// Top-level fields are the deployed payload — the diff
// baseline, since the session has its own `aiDraft`.
savedRawApp.val = {
saved.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
@@ -595,9 +644,9 @@ function createRuntime(session: Session): SessionRuntime {
no_deployed: result.no_deployed
}
} catch {
savedRawApp.val = undefined
saved.val = undefined
}
rawApp.val = applyDraftToRuntimeRawApp(
store.val = applyDraftToRuntimeRawApp(
{
files: {},
runnables: {},
@@ -608,8 +657,8 @@ function createRuntime(session: Session): SessionRuntime {
},
aiDraft
)
rawAppSlot.loadedPath = path
rawAppSlot.loadedWorkspace = workspace
slot.loadedPath = path
slot.loadedWorkspace = workspace
return
}
@@ -622,7 +671,7 @@ function createRuntime(session: Session): SessionRuntime {
rawApp: true
})
// Deployed baseline for the diff drawer (top-level fields).
savedRawApp.val = {
saved.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
@@ -669,14 +718,14 @@ function createRuntime(session: Session): SessionRuntime {
(result as any).draft_saved_at as string | undefined
)
UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace })
rawApp.val = runtimeValue
rawAppSlot.loadedPath = path
rawAppSlot.loadedWorkspace = workspace
store.val = runtimeValue
slot.loadedPath = path
slot.loadedWorkspace = workspace
} catch (err) {
console.error('Failed to load raw app', err)
rawAppSlot.notFound = true
slot.notFound = true
} finally {
rawAppSlot.loading = false
slot.loading = false
}
},
@@ -794,33 +843,14 @@ export type SessionChatStatus =
| 'draft'
| 'error'
// MRU set of session ids whose FlowEditorView is currently mounted. Capped at
// MAX_WARM_EDITORS — sessions outside the set show chat-only. Module-scoped so
// both the page (which mutates) and the sidebar (which reads for the dev clue)
// see the same state.
const MAX_WARM_EDITORS = 3
export const editorWarmIds = new SvelteSet<string>()
// Full session teardown: dispose the runtime, drop the LRU entry, and remove
// from sessionState in one call. Callers (sidebar / header dropdowns) just
// invoke this; navigation away from a deleted active session is the caller's
// responsibility.
// Full session teardown: dispose the runtime and remove from sessionState in one
// call. Callers (sidebar / header dropdowns) just invoke this; navigation away
// from a deleted active session is the caller's responsibility.
export function removeSession(sessionId: string): void {
disposeRuntime(sessionId)
editorWarmIds.delete(sessionId)
deleteSessionState(sessionId)
}
export function promoteEditorWarm(sessionId: string): void {
editorWarmIds.delete(sessionId)
editorWarmIds.add(sessionId)
while (editorWarmIds.size > MAX_WARM_EDITORS) {
const oldest = editorWarmIds.values().next().value
if (oldest === undefined) break
editorWarmIds.delete(oldest)
}
}
// Register the global open_preview tool handler once at module load. It
// dispatches to the *calling* session (sessionId threaded from the tool ctx),
// falling back to the UI-active session only when none was passed — so a
@@ -841,7 +871,6 @@ setOpenPreviewHandler(({ sessionId: callerSessionId, kind, path }) => {
return `Error: ${kind} targets cannot be shown in the preview panel.`
}
const result = getOrCreateRuntime(session).previewTabs.open(target)
promoteEditorWarm(sessionId)
return result.status === 'focused'
? `A preview tab is already showing ${kind} "${path}" — focused it.`
: `Opened ${kind} preview for ${path} in a new tab in the side panel.`
@@ -869,12 +898,10 @@ setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label, newTab })
owner.select(existing.id)
owner.navigate({ type: 'page', href, label })
owner.setCollapsed(false)
promoteEditorWarm(sessionId)
return `Updated the ${label} preview tab with the new filters.`
}
}
const result = owner.open({ type: 'page', href, label })
promoteEditorWarm(sessionId)
return result.status === 'focused'
? `A preview tab is already showing ${label} — focused it and applied the filters.`
: `Opened ${label} in a new preview tab in the side panel.`
@@ -889,7 +916,7 @@ setGetPreviewStatusHandler((callerSessionId) => {
const session = sessionState.sessions.find((s) => s.id === sessionId)
if (!session) return 'No active session; the preview panel is unavailable.'
const owner = getOrCreateRuntime(session).previewTabs
return describePreview(owner.tabs, owner.activeId, session.target)
return describePreview(owner.tabs, owner.activeId)
})
// close_page dispatches here to close preview tabs in the calling session's
@@ -923,7 +950,9 @@ 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
if (runtime.slot(kind).loadedPath !== path) return
// Peek without creating a cell: a deploy for an item with no open editor tab
// must not allocate an empty cell that lingers until the next prune.
if (runtime.loadedEditorPath(kind, path) !== path) return
runtime.syncPreviewWithDeployed(session.workspace_id, kind, path)
})
@@ -37,21 +37,12 @@ import { sendUserToast } from '$lib/toast'
import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte'
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
// Kinds the in-session editor pane can host. Legacy drag-and-drop apps are
// intentionally not previewable — only code-based 'raw_app' apps are. A
// 'pipeline' target's `path` is the folder name (not a workspace item path):
// it hosts the data-pipeline graph editor for that folder, which uses its own
// fetch/draft model rather than the single-item load slots the other kinds share.
// A destination the session preview can open as an editor: a workspace item
// (`path`) for flow/script/raw_app, or — for 'pipeline' — a folder name (not an
// item path), which resolves to the data-pipeline graph editor for that folder.
// Legacy drag-and-drop apps aren't previewable; only code-based 'raw_app' apps.
export type SessionTarget = { kind: 'flow' | 'script' | 'raw_app' | 'pipeline'; path: string }
// Useful for filtering dropdowns / pickers to "items the side panel can open".
export const EDITOR_TARGET_KINDS: ReadonlySet<SessionTarget['kind']> = new Set([
'flow',
'script',
'raw_app',
'pipeline'
])
// Whether the session points at a workspace that is itself a fork (i.e.
// has a parent). Used by the sidebar to pick between a root (Building)
// icon and a fork icon.
@@ -97,7 +88,6 @@ export type Session = {
// workspace_id, not this field. Root sessions store the same id in both fields.
workspace_root_id?: string
chatId?: string
target?: SessionTarget
summary?: string
summarySource?: SessionSummarySource
createdAt: number
@@ -151,12 +141,12 @@ interface SessionSchema extends DBSchema {
}
// Normalise legacy localStorage records in place: drop empty-string
// workspace_id (older drafts used '' as a missing marker), migrate the
// deprecated 'rawapp' target.kind, and coerce unknown summarySource values.
// Operates on raw parsed JSON, so the record is loosely typed.
// workspace_id (older drafts used '' as a missing marker), drop the retired
// `target` field (the preview is tab-driven now), and coerce unknown
// summarySource values. Operates on raw parsed JSON, so the record is loosely typed.
function normalizeLegacySession(s: Record<string, any>): void {
if (s.workspace_id === '') delete s.workspace_id
if (s.target?.kind === 'rawapp') s.target.kind = 'raw_app'
delete s.target
if (
s.summarySource !== undefined &&
s.summarySource !== 'placeholder' &&
@@ -804,20 +794,6 @@ export function getEffectiveWorkspaceId(session: Session): string | undefined {
return session.workspace_id ?? session.pending_workspace_id
}
// Canonical mutation for session.target. Persists, optionally seeds the
// session summary, and centralises the path so callers don't reach into
// session.target directly.
export function setSessionTarget(id: string, target: SessionTarget, summary?: string): void {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.target = target
if (!s.summary && summary) {
s.summary = summary
s.summarySource = 'generated'
}
void putSession(s)
}
// Persist the session's preview tabs. Fire-and-forget write-behind (transient
// sessions land in the localStorage draft slot).
export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabId: string): void {
@@ -123,7 +123,6 @@ describe('sessionState IndexedDB persistence', () => {
session({
id: 't1b',
transient: true,
target: { kind: 'script', path: 'u/me/foo' },
previewTabs: [{ id: 'session', url: '/x', loc: '/x' }],
activePreviewTabId: 'session',
previewCollapsed: false
@@ -132,7 +131,6 @@ describe('sessionState IndexedDB persistence', () => {
await rehydrate(user)
await flush()
const restored = sessionState.sessions.find((s) => s.id === 't1b')
expect(restored?.target).toEqual({ kind: 'script', path: 'u/me/foo' })
expect(restored?.previewTabs).toEqual([{ id: 'session', url: '/x', loc: '/x' }])
expect(restored?.activePreviewTabId).toBe('session')
expect(restored?.previewCollapsed).toBe(false)
@@ -7,7 +7,6 @@ import {
sessionInCurrentFamily,
sessionState,
setSessionPendingWorkspace,
setSessionTarget,
type SessionTarget
} from './sessionState.svelte'
import { sessionTargetHref } from './sessionMode.svelte'
@@ -66,8 +65,8 @@ export async function exitSessionMode(): Promise<void> {
await goto(target)
}
// Open a fresh AI session pre-targeted at an editor (flow/script/raw_app), then
// route into session mode. The session preview loads that editor via its target,
// Open a fresh AI session showing an editor (flow/script/raw_app) in its preview,
// then route into session mode. The preview loads the item from its live draft,
// so the caller MUST persist any unsaved edits first (e.g. save a draft) for the
// preview to reflect the live state. `workspaceId` scopes the session to the
// editor's workspace (instead of createSession's root default) so it opens the
@@ -76,16 +75,14 @@ export async function openEditorInSession(
target: SessionTarget,
workspaceId?: string
): Promise<void> {
// createSession() reuses an existing transient draft, which may still be
// pointed at a *different* item — and its preview tabs (persisted with the
// draft and/or held by a live runtime) keep showing that old target unless
// the tab model is reset along with the target field.
// createSession() reuses an existing transient draft, whose preview tabs
// (persisted with the draft and/or held by a live runtime) may still show a
// different item — so seed the preview with a single tab on `target`, resetting
// whatever it was showing.
const session = createSession()
const retargeted = session.target?.kind !== target.kind || session.target?.path !== target.path
if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId)
setSessionTarget(session.id, target)
const url = sessionTargetHref(target)
if (url && retargeted) {
if (url) {
// Dynamic import: a static one would drag the runtime's heavy graph
// (chat manager → monaco) into this thin navigation seam, breaking its
// node-run unit tests.
@@ -37,7 +37,13 @@ export interface UserDraftSyncOptions<Draft> {
* `loadedX !== path` guards.
*/
ready: () => boolean
codec: DraftSyncCodec<Draft>
/**
* Reactive codec. Retargeting a mounted editor (breadcrumb / in-editor link)
* changes `path` without remounting, and the codec closes over one
* `(kind, path)` cell's store — so it must be re-read per path, not captured
* once, or the sync would read/write the previously-targeted cell.
*/
codec: () => DraftSyncCodec<Draft>
}
/**
@@ -61,18 +67,19 @@ export interface UserDraftSyncOptions<Draft> {
* 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 }] : []
return p && ws ? [{ itemKind: opts.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.
// (chat write / another session's edit) or the target retargets (new codec).
// The store read happens inside applyDraftToStore under untrack so the
// editor's own mutations don't refire.
$effect(() => {
const codec = opts.codec()
const incoming = handles[0]?.draft
if (incoming == null) return
const sig = codec.sig(incoming)
@@ -101,6 +108,7 @@ export function useUserDraftSync<Draft>(opts: UserDraftSyncOptions<Draft>): void
let pendingFlush: (() => void) | undefined
$effect(() => {
if (!opts.ready()) return
const codec = opts.codec()
const draft = codec.storeToDraft(undefined)
if (draft == null) return
const sig = codec.sig(draft)
@@ -38,8 +38,7 @@
import {
getOrCreateRuntime,
getRuntime,
listRuntimes,
promoteEditorWarm
listRuntimes
} from '$lib/components/sessions/sessionRuntime.svelte'
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
@@ -52,6 +51,7 @@
previewLocationLabel,
type PreviewTarget
} from '$lib/components/sessions/previewRouter'
import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload'
import { leafKeyFor, type WorkspaceItem } from '$lib/components/workspacePicker'
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
@@ -132,14 +132,6 @@
.filter((s): s is NonNullable<typeof s> => s != null)
)
// Promote the active session in the LRU. Mutations untracked so the effect
// only re-runs when activeSession changes, not on its own writes.
$effect(() => {
const id = activeSession?.id
if (!id) return
untrack(() => promoteEditorWarm(id))
})
// Mark the active session "seen" up to its current message count: arrive →
// clear unread; AI streams a new message while we're here → clear again. The
// effect depends only on the length, not the array contents, so token-by-token
@@ -317,61 +309,49 @@
}
}
// Reload mounted preview tabs affected by a mutating chat tool (write_/patch_/
// delete_/deploy_/…; read/test/navigate tools don't match). Scoped to the changed
// item so editing one item never blank-reboots an unrelated item's preview iframe
// (a full-page /apps_raw/edit reload is jarring).
// Reload mounted preview tabs affected by a mutating chat tool. Item and pipeline
// tabs are live editors that self-sync from the store the chat mutates, so nothing
// reloads them. Only list-page tabs (schedules, resources, …) are iframes, and each
// reloads only when a tool actually changed *its* page (toolReloadEffect) — so a
// schedule write leaves the Resources tab alone, and a purely local tool (saving
// user instructions) reloads nothing.
const tabHosts: Record<string, PreviewTabHost | undefined> = {}
const MUTATING_TOOL_RE = /^(write_|patch_|delete_|deploy_|discard_|set_|create_|update_|remove_)/
let reloadHandle: ReturnType<typeof setTimeout> | undefined
// Drained each flush: item paths touched since the last flush, and a flag for an
// unresolved mutation that forces a full reload (safe fallback).
let pendingReloadPaths = new Set<string>()
let pendingReloadAll = false
// Reload the batched-mutation tabs across all warm sessions' mounted tabs (a
// hidden preview would otherwise show pre-mutation content on return). `null`
// reloads all; otherwise an item-route iframe reloads only when its item was
// touched. Non-item pages always reload; a live-editor slot no-ops in reload().
function reloadTabs(paths: Set<string> | null) {
let reloadHandle: ReturnType<typeof setTimeout> | undefined
// Base-stripped list-page paths (e.g. `/schedules`) a chat round touched since
// the last flush — see toolReloadEffect for how tools map to pages.
let pendingPages = new Set<string>()
// Reload the mounted list-page tabs a chat round changed, across all warm
// sessions (a hidden preview would otherwise show pre-mutation content on
// return). tabsToReload picks only the tabs whose page is in `pages`.
function reloadTabs(pages: Set<string>) {
for (const s of warmSessions) {
const tabs = getRuntime(s.id)?.previewTabs?.tabs ?? []
for (const tab of tabs) {
const owner = getRuntime(s.id)?.previewTabs
if (!owner) continue
for (const tab of tabsToReload(owner.tabs, pages)) {
const key = tabKey(s.id, tab.id)
if (!mountedTabKeys.has(key)) continue
if (paths) {
const route = parsePreviewItemRoute(tab.url)
if (route && !paths.has(route.itemPath)) continue
}
tabHosts[key]?.reload()
if (mountedTabKeys.has(key)) tabHosts[key]?.reload()
}
}
}
function flushReload() {
const paths = pendingReloadAll ? null : pendingReloadPaths
pendingReloadPaths = new Set()
pendingReloadAll = false
reloadTabs(paths)
const pages = pendingPages
pendingPages = new Set()
reloadTabs(pages)
}
$effect(() => {
// Debounced so a burst of writes (the AI editing several files) reloads once.
setToolCompletionListener((name, args) => {
if (!MUTATING_TOOL_RE.test(name)) return
// A workspace item path scopes the reload to that item. The raw-app file
// tools (write_app_file, …) pass a leading-'/' frontend file path and edit
// the active session's target app, so scope to the target. Anything else is
// unresolved → reload everything (safe fallback).
const p = typeof args?.path === 'string' ? args.path : undefined
if (p && !p.startsWith('/')) pendingReloadPaths.add(p)
else if (p && activeSession?.target?.path) pendingReloadPaths.add(activeSession.target.path)
else pendingReloadAll = true
const { pages } = toolReloadEffect(name, args)
if (pages.length === 0) return
for (const p of pages) pendingPages.add(p)
clearTimeout(reloadHandle)
reloadHandle = setTimeout(flushReload, 500)
})
return () => {
clearTimeout(reloadHandle)
pendingReloadPaths = new Set()
pendingReloadAll = false
pendingPages = new Set()
setToolCompletionListener(undefined)
}
})
@@ -565,7 +545,7 @@
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} hideEditor />
<SessionWrapper sessionId={s.id} />
</div>
{/each}
</div>