mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
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:
co-authored by
Claude Opus 4.8
parent
fb12b23e01
commit
32c398f27d
@@ -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 (KB–MB), 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>
|
||||
|
||||
Reference in New Issue
Block a user