diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte
index 45163cc416..fe1ae268d7 100644
--- a/frontend/src/lib/components/sessions/FlowEditorView.svelte
+++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte
@@ -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 @@
}
-{#if runtime.savedFlow.val}
+{#if cell.saved.val}
{/if}
runtime.flowStore.val?.path ?? path}
+ isActiveTab={active}
+ effectivePath={() => cell.store.val?.path ?? path}
>
{#snippet editor()}
-
+
(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)
})
diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte
index 81ad9e525e..2a25cff3a5 100644
--- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte
+++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte
@@ -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}
{:else if mounted}
diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte
index c3ad7ead4e..cb6507519c 100644
--- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte
+++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte
@@ -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(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 @@
}
-{#if runtime.savedRawApp.val}
+{#if cell.saved.val}
{/if}
runtime.rawApp.val?.path ?? path}
+ isActiveTab={active}
+ effectivePath={() => cell.store.val?.path ?? path}
>
{#snippet editor()}
- {#if runtime.rawApp.val}
+ {#if cell.store.val}
-
+
-{#if runtime.savedScript.val}
+{#if cell.saved.val}
{/if}
runtime.scriptStore.val?.path ?? path}
+ isActiveTab={active}
+ effectivePath={() => cell.store.val?.path ?? path}
>
{#snippet editor()}
- {#if runtime.scriptStore.val}
+ {#if cell.store.val}
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 {
if (kind === 'flow') return runtime.loadFlow(workspaceId, path)
@@ -56,10 +67,15 @@
}
function buildCodec(): DraftSyncCodec {
- 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
diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte
index ae97510629..e3d1c5d21e 100644
--- a/frontend/src/lib/components/sessions/SessionPicker.svelte
+++ b/frontend/src/lib/components/sessions/SessionPicker.svelte
@@ -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)
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte
index 8161349656..f5fa09c6d0 100644
--- a/frontend/src/lib/components/sessions/SessionWrapper.svelte
+++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte
@@ -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}