feat(sessions): hand a deployed run to the item's own page

A deployed `run_script` / `run_flow` opened a form in the chat and started the
job itself. It now opens the item's deployed page in the preview panel, fills
the run form that is already there with the arguments it proposed, and the call
ends. The run is the reader's: they start it from the page they would have run
it from anyway.

**Why the tool stops owning the run.** What the tool could carry was arguments
and nothing else, so the page had to hide the controls it could not forward —
scheduling, tag override, invisible-to-owner — and "Schedule to run later" ran
immediately. It also ran HEAD whatever version the page was pinned to. Handing
the page over costs the model nothing it had: it never ran anything, it waited
for a reader to press Run. Now it says so and moves on; `get_run` is there when
it needs the outcome.

A TEST run is unchanged. It executes the draft being written, which the deployed
page does not show, so it keeps the chat's own form. So does the bypass posture,
which answers for a reader who has already consented and would leave nobody to
press Run. Plan mode is unaffected: its gate already sits before the form, so it
refuses before anything opens.

**On the page.** The arguments arrive narrowed against the deployed schema by
the tool that fetched it, and a note above the form says where they came from;
dismissing it drops the note and keeps the values. A chat flow has no run form —
its page is a conversation — so the proposed `user_message` goes into the
composer instead, for the reader to edit and send. The composer declines if they
were already typing.

**On the card.** The call settles as `Opened`, with the arguments it filled
under Inputs and no outcome pane: nothing ran, and a Result tab could only say
the run came back empty. The eye reopens the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-01 08:46:03 -07:00
co-authored by Claude Opus 5
parent b10f83c763
commit 8f33e7877e
11 changed files with 305 additions and 59 deletions
@@ -570,6 +570,17 @@ export class AIChatManager implements ChatViewHost {
* chat card holds. Unset outside a session: a chat-bound form has nowhere else to go,
* so the card hides the control rather than offering a tab that cannot run. */
openRunForm?: (a: { toolCallId: string; label: string }) => void
/** A DEPLOYED run shows the item's own page instead, with the model's arguments in the
* run form already there. The tool call ends at the open — the run belongs to the reader,
* who starts it from that page with its scheduling, tag and version controls in reach.
* Returns whether the panel took it; false (a legacy app has no in-panel page) leaves the
* tool to fall back on its own form. */
openDeployedRunPage?: (a: {
kind: 'script' | 'flow'
path: string
summary: string
args: Record<string, any>
}) => boolean
closeRunForm?: (toolCallId: string) => void
/** Hands that tab from the form to the run it just started, in place: the tab keeps its
* position in the strip and stays active if it was. */
@@ -4149,6 +4160,7 @@ export class AIChatManager implements ChatViewHost {
requestUserQuestion: this.requestUserQuestion,
requestRunArgs: this.requestRunArgs,
markRunFormStarted: this.markRunFormStarted,
openDeployedRunPage: this.openDeployedRunPage,
onItemModified: (kind, path) => this.recordModifiedItem(kind, path),
onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to),
onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path),
@@ -92,12 +92,24 @@
// "the run finished": a detached job keeps the card in its running state until
// the background poller lands an outcome on it or the tray sees the job end. An
// inspected run has no poller behind it — the status it was read at is the answer.
// The call ended by opening the item's deployed page: nothing ran, nothing is waiting, and
// the run is the reader's to start from there. An outcome of its own, so the card neither
// shimmers as if a job were in flight nor claims one finished.
const openedDeployedPage = $derived(
!pending &&
!message.isLoading &&
!ran &&
runForm?.kind === 'run' &&
!runForm?.submitted &&
!runForm?.canceled
)
const settled = $derived(
inspected
? ['success', 'failure', 'canceled'].includes(inspectedStatus ?? '')
: !pending &&
!message.isLoading &&
(message.result !== undefined ||
(openedDeployedPage ||
message.result !== undefined ||
failed ||
canceled ||
(chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status)))
@@ -170,7 +182,15 @@
// Inspecting is done the moment the tool returned, whatever the run it looked at is
// still doing — the tense belongs to the call, not to its subject.
const verb = $derived(
inspected ? 'Inspected' : running ? verbs.present : settled && ran ? verbs.past : verbs.future
inspected
? 'Inspected'
: openedDeployedPage
? 'Opened'
: running
? verbs.present
: settled && ran
? verbs.past
: verbs.future
)
// Being cancelled is an outcome like any other, and it is the one the card has to say out
@@ -185,7 +205,10 @@
)
// Streaming opens the tab early: the result is already arriving, and one that appeared
// only at the end would hide the thing the user is waiting to read.
const hasOutcome = $derived(settled || streaming)
// A call that ended by opening the page has no outcome to show — no job ran, so an outcome
// tab could only say the run returned nothing, which reads as a run that came back empty.
// The arguments it filled are the whole story, and `Inputs` carries those.
const hasOutcome = $derived(!openedDeployedPage && (settled || streaming))
const tabs = $derived([
{ value: 'input', label: 'Inputs' },
...(ran ? [{ value: 'logs', label: 'Logs' }] : []),
@@ -376,6 +399,11 @@
// waiting on one, the run once a job exists. Neither, and there is nothing to open, so
// the button is not drawn at all — a form has nowhere to go outside a session, and a
// call cancelled before Run never became a run.
// A deployed run never waits on a form: the tool opened the item's own page and ended, so
// this reopens that page — the reader may have closed the tab, or moved on and come back.
const asDeployedPage = $derived(
openedDeployedPage && !!path && !!aiChatManager.openDeployedRunPage
)
const previewTarget = $derived(
pending
? aiChatManager.openRunForm
@@ -383,14 +411,18 @@
: undefined
: job
? ('run' as const)
: undefined
: asDeployedPage
? ('deployed' as const)
: undefined
)
const previewTitle = $derived(
previewTarget === 'form'
? `Open this form in the preview panel: ${path}`
: aiChatManager.openRunInPreview
? `Open this run in the preview panel: ${path || runnableName}`
: `Open this run in a new tab: ${path || runnableName}`
: previewTarget === 'deployed'
? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and its run form: ${path}`
: aiChatManager.openRunInPreview
? `Open this run in the preview panel: ${path || runnableName}`
: `Open this run in a new tab: ${path || runnableName}`
)
function openPreview() {
@@ -399,6 +431,15 @@
aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label })
return
}
if (previewTarget === 'deployed') {
aiChatManager.openDeployedRunPage?.({
kind: runnableKind === 'flow' ? 'flow' : 'script',
path,
summary: label,
args: runForm?.args ?? {}
})
return
}
if (!job) return
// Outside a session there is no panel, so the run opens where the jobs tray sends it.
if (aiChatManager.openRunInPreview) {
@@ -432,6 +473,7 @@
title={previewTitle}
onOpen={openPreview}
kindIcon={false}
mode={previewTarget === 'deployed' ? 'view' : 'edit'}
/>
{/snippet}
@@ -6041,6 +6041,13 @@ async function testRunScriptByPath(
/** The "do not call again" half is load-bearing: without it the model re-proposes the
* call, which re-opens the form the user just dismissed, and Stop becomes their only
* way out. */
/** Nothing ran, and saying so is the whole point: the model is about to report back, and a
* "started" it inferred would be wrong. The "do not call again" half is load-bearing for the
* same reason as the cancelled one — a re-proposal would reopen the page the reader is
* already looking at. */
const deployedPageOpened = (path: string, noun: string, toolName: string) =>
`Opened the deployed page for "${path}" in the preview panel, with these arguments filled into its run form. The ${noun} has NOT run: the user starts it from that page, where they can also edit the arguments, schedule it, override the tag or pick a version. Do not call ${toolName} again for this. Tell the user it is ready to run; if you need the result afterwards, ask them or call get_run once they say it has run.`
const runFormCancelled = (toolName: string, noun: string) =>
`The user cancelled the run form. The ${noun} did NOT run. Do not call ${toolName} again unless the user asks for it.`
@@ -6179,6 +6186,38 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st
// size guard that covers `parameters`.
const persisted = { ...form, args: redactFileArgs(proposed, schema as any) }
// A DEPLOYED run is handed to the item's own page and this call ends there. The reader
// runs it from the page they would have run it from anyway — with its scheduling, tag
// override and pinned version in reach, none of which this tool could carry — so there is
// no form to park on and no job for the tool to own.
//
// A TEST run has no such page: it executes the draft being written, which the deployed
// page does not show, so it keeps the form below. So does the bypass posture, which
// answers for a reader who already consented and would leave nobody to press Run.
if (spec.kind === 'run' && !autoAccepted && toolCallbacks.openDeployedRunPage) {
const handedOver = toolCallbacks.openDeployedRunPage({
kind: spec.contextName === 'flow' ? 'flow' : 'script',
path: spec.path,
summary: spec.summary ?? spec.path,
args: proposed
})
if (handedOver) {
toolCallbacks.setToolStatus(toolId, {
content: `Opened the deployed page for "${spec.path}"`,
// Carried without `isLoading`, so `isActiveRunForm` reads it as settled: the card
// shows what was proposed and offers to reopen the page, rather than mounting a
// second form for a call that is already over.
runForm: persisted,
// The card settles on what the page opened with, as it would on what a form
// opened with: it is the record of what this call proposed.
parameters: persisted.args,
isLoading: false,
isStreamingArguments: false
})
return deployedPageOpened(spec.path, noun, spec.toolName)
}
}
toolCallbacks.setToolStatus(toolId, {
content: autoAccepted
? spec.startMessage
@@ -1418,6 +1418,16 @@ export interface ToolCallbacks {
) => Promise<Record<string, any> | undefined>
/** The submitted form's job is queued. Wired alongside requestRunArgs. */
markRunFormStarted?: (toolId: string) => void
/** Show a DEPLOYED run on the item's own page, with these arguments in the run form that
* is already there, and end the call: the reader runs it from that page rather than
* confirming a form the tool would then run itself. Returns whether the host took it.
* Wired only where that page can be shown — a session's preview panel. */
openDeployedRunPage?: (a: {
kind: 'script' | 'flow'
path: string
summary: string
args: Record<string, any>
}) => boolean
/** Records a workspace item the tool call created/edited/deleted, by its
* canonical (itemKind, storagePath). Session chats wire this to accumulate the
* chat's modified-items mask; the global side-panel chat omits it (no-op). */
@@ -101,6 +101,8 @@
onNavigate = goto,
active = true,
embedded = false,
seededRun,
onClearSeededRun,
onLoadState
}: {
/** The `[...path]` route segment: the flow's path. */
@@ -116,6 +118,13 @@
/** Rendered inside a page that is not the flow's own (an AI session preview tab), whose
* URL this must leave alone and which a cross-workspace link must not navigate away. */
embedded?: boolean
/** Arguments a chat tool filled this form with when it opened the page. Nothing waits
* on them: the page runs as it always does, and these only seed the fields and put a
* note above them. `seq` identifies the request, so a second one re-seeds a page that
* is already open. */
seededRun?: { args: Record<string, any>; seq: number }
/** The reader dismissed that note. The values stay in the fields. */
onClearSeededRun?: () => void
/** How the load ended, for a host that renders its own state around this page. */
onLoadState?: (state: 'loaded' | 'not_found') => void
} = $props()
@@ -352,6 +361,30 @@
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let runForm: RunForm | undefined = $state()
// Seeded once the surface exists, and again whenever another request arrives for a page
// already open — latched on the request rather than on "seeded once", which would leave
// the previous turn's values on screen. The arguments were narrowed against this flow's
// deployed schema before they got here, by the tool that opened the page.
//
// A chat flow has no run form to fill: its page is a conversation, so the proposed
// `user_message` goes into the composer for the reader to edit and send. The composer
// declines if they were already typing, which leaves their draft alone.
let flowChat: FlowChat | undefined = $state()
let seededSeq: number | undefined = undefined
$effect(() => {
if (!seededRun) return
const surface = chatInputEnabled ? flowChat : runForm
if (!surface) return
if (seededSeq === seededRun.seq) return
seededSeq = seededRun.seq
if (chatInputEnabled) {
const message = seededRun.args?.user_message
if (typeof message === 'string' && message) flowChat?.offerMessage(message)
} else {
runForm?.setArgs(seededRun.args)
}
})
// The dev workspace's editor is not one the session panel can host, so from a preview tab
// it opens in a new browser tab, as the session editors' own entry does.
function editInFork(e: Event | undefined, itemPath: string) {
@@ -812,6 +845,7 @@
{#if chatInputEnabled}
<!-- Chat Layout with Sidebar -->
<FlowChat
bind:this={flowChat}
onRunFlow={runFlowForChat}
{deploymentInProgress}
path={flow?.path ?? ''}
@@ -863,6 +897,10 @@
/>
{/if}
{#if seededRun}
<InputSelectedBadge inputSelected="agent" onReject={() => onClearSeededRun?.()} />
{/if}
<RunForm
bind:scheduledForStr
bind:invisible_to_owner
@@ -126,6 +126,8 @@
onNavigate = goto,
active = true,
embedded = false,
seededRun,
onClearSeededRun,
onLoadState
}: {
/** The `[...hash]` route segment: either a script hash or a script path. */
@@ -141,6 +143,13 @@
/** Rendered inside a page that is not the script's own (an AI session preview tab), whose
* URL this must leave alone and which a cross-workspace link must not navigate away. */
embedded?: boolean
/** Arguments a chat tool filled this form with when it opened the page. Nothing waits
* on them: the page runs as it always does, and these only seed the fields and put a
* note above them. `seq` identifies the request, so a second one re-seeds a page that
* is already open. */
seededRun?: { args: Record<string, any>; seq: number }
/** The reader dismissed that note. The values stay in the fields. */
onClearSeededRun?: () => void
/** How the load ended, for a host that renders its own state around this page.
* Providing it also suppresses the "could not load" toast: a 404 here is the normal
* state of a not-yet-deployed path, which the host explains in place instead. */
@@ -413,6 +422,18 @@
let args: Record<string, any> | undefined = $state(undefined)
// Seeded once the form exists, and again whenever another request arrives for a page
// already open — latched on the request rather than on "seeded once", which would leave
// the previous turn's values on screen. The arguments were narrowed against this script's
// deployed schema before they got here, by the tool that opened the page.
let seededSeq: number | undefined = undefined
$effect(() => {
if (!seededRun || !runForm) return
if (seededSeq === seededRun.seq) return
seededSeq = seededRun.seq
runForm.setArgs(seededRun.args)
})
// Read once on purpose: these args seed the form, so tracking the fragment would
// overwrite what the user has typed whenever it changes.
let hash = untrack(() => locationHash)
@@ -1084,6 +1105,9 @@
/>
{/if}
{#if seededRun}
<InputSelectedBadge inputSelected="agent" onReject={() => onClearSeededRun?.()} />
{/if}
<RunForm
bind:scheduledForStr
bind:invisible_to_owner
@@ -177,6 +177,14 @@
// The panel on screen. Every other panel the pool holds stays mounted behind it.
const shownKey = $derived(poolState?.shownKey)
/** Put a message in the composer of the conversation on screen, for the reader to edit and
* send. Returns false when that composer already holds a draft — it is never overwritten,
* and the caller leaves the message where the reader can still see it. */
export function offerMessage(text: string): boolean {
const panel = shownKey ? pool?.get(shownKey) : undefined
return panel?.host.offerMessage(text) ?? false
}
// Derive additional inputs schema (excluding user_message) for chat mode
const additionalInputsSchema = $derived.by(() => {
const props = inputSchema?.properties ?? {}
@@ -579,6 +579,15 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac
this.#takeReturned()
})
/** Put a message in the composer showing this conversation, for the reader to edit and
* send — a chat flow's page has no run form to fill, so this is where a chat tool's
* proposed `user_message` lands. Declines when the composer already holds a draft, so it
* never overwrites what the reader was typing. */
offerMessage(text: string): boolean {
if (!this.#aiChatInput || this.#disposed) return false
return this.#aiChatInput.restoreInstructions(text)
}
/** Whether a composer showing this conversation holds something not sent — text, an
* attachment, a file still being read. Its panel stays mounted for as long as one does:
* nothing else holds that draft. */
@@ -6,7 +6,11 @@
import type { Snippet } from 'svelte'
interface Props {
inputSelected: 'history' | 'captures' | 'saved' | 'ai' | undefined
/** `agent` is the odd one: the others name where the arguments in the form came from
* and put them back on reject, while this one reports that a chat tool filled the form
* when it opened the page — rejecting only drops the note, since there is nothing to
* put back. */
inputSelected: 'history' | 'captures' | 'saved' | 'ai' | 'agent' | undefined
labelColor?: string
className?: string
acceptButton?: Snippet
@@ -26,13 +30,17 @@
)}
>
<p class={twMerge(classes['info'].descriptionClass, 'text-xs px-2', labelColor)}>
Using {inputSelected === 'history'
? 'historic'
: inputSelected === 'captures'
? 'captures'
: inputSelected === 'ai'
? 'AI generated'
: 'saved'} input arguments
{#if inputSelected === 'agent'}
The agent filled these arguments
{:else}
Using {inputSelected === 'history'
? 'historic'
: inputSelected === 'captures'
? 'captures'
: inputSelected === 'ai'
? 'AI generated'
: 'saved'} input arguments
{/if}
</p>
{#if acceptButton}
{@render acceptButton()}
@@ -66,6 +66,11 @@
reloadKey++
})
// Arguments a chat tool filled this item's run form with when it opened the page. Nothing
// is waiting on them: the reader runs the page as they would with no chat open, and the
// note above the form only says where the values came from.
const seededRun = $derived(runtime.seededRunArgsFor(kind, path))
/** Refetch the deployed item. The host calls this for the reload signals that reach a
* tab from outside it; nothing about becoming visible triggers it, so a form the reader
* filled in survives switching tabs away and back. */
@@ -179,50 +184,54 @@
<!-- Remount to refetch: both detail components load on mount, so a deploy lands here
rather than as a refresh path of their own. -->
{#key loadKey}
{@const mountedKey = loadKey}
{@const setLoadState = (state: 'loaded' | 'not_found') => onLoadState(mountedKey, state)}
{#if notDeployed}
<!-- This side shows the DEPLOYED item, and there isn't one. Without this the detail
{#key loadKey}
{@const mountedKey = loadKey}
{@const setLoadState = (state: 'loaded' | 'not_found') => onLoadState(mountedKey, state)}
{#if notDeployed}
<!-- This side shows the DEPLOYED item, and there isn't one. Without this the detail
page renders nothing and the panel is simply blank, which reads as a failure
rather than as the ordinary state of a draft nobody has deployed yet. -->
<div class="flex-1 flex flex-col items-center justify-center gap-3 p-8 text-center">
<div class="text-sm text-primary">Not deployed yet</div>
<div class="text-xs text-tertiary max-w-sm">
Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed
version to view. Deploy it from the editor to see it here.
</div>
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}>
Back to editor
</Button>
<div class="flex-1 flex flex-col items-center justify-center gap-3 p-8 text-center">
<div class="text-sm text-primary">Not deployed yet</div>
<div class="text-xs text-tertiary max-w-sm">
Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed
version to view. Deploy it from the editor to see it here.
</div>
{:else if kind === 'script'}
<ScriptDetail
hash={path}
workspace={workspaceId}
{searchParams}
{onNavigate}
{active}
embedded
onLoadState={setLoadState}
/>
{:else if kind === 'flow'}
<FlowDetail
{path}
workspace={workspaceId}
{searchParams}
{onNavigate}
{active}
embedded
onLoadState={setLoadState}
/>
{:else}
<InWorkspaceAppViewer
workspace={workspaceId}
{path}
onEdit={() => onNavigate(`/apps_raw/edit/${path}`)}
onLoadState={setLoadState}
syncHashToUrl={false}
/>
{/if}
{/key}
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}>
Back to editor
</Button>
</div>
{:else if kind === 'script'}
<ScriptDetail
hash={path}
workspace={workspaceId}
{searchParams}
{onNavigate}
{active}
embedded
{seededRun}
onClearSeededRun={() => runtime.clearSeededRunArgs(kind, path)}
onLoadState={setLoadState}
/>
{:else if kind === 'flow'}
<FlowDetail
{path}
workspace={workspaceId}
{searchParams}
{onNavigate}
{active}
embedded
{seededRun}
onClearSeededRun={() => runtime.clearSeededRunArgs(kind, path)}
onLoadState={setLoadState}
/>
{:else}
<InWorkspaceAppViewer
workspace={workspaceId}
{path}
onEdit={() => onNavigate(`/apps_raw/edit/${path}`)}
onLoadState={setLoadState}
syncHashToUrl={false}
/>
{/if}
{/key}
@@ -223,6 +223,13 @@ export interface SessionRuntime {
* viewer refetches when this changes — it reads the deployed version from the
* API, so nothing else tells it the editor beside it just published. */
deployedRevision(kind: SessionTargetKind, path: string): number
/** What a chat tool filled this item's deployed run form with, if one opened the page. */
seededRunArgsFor(
kind: SessionTargetKind,
path: string
): { args: Record<string, any>; seq: number } | undefined
/** Drop that note; the arguments themselves stay in the form. */
clearSeededRunArgs(kind: SessionTargetKind, path: string): void
/** Record that a preview tab landed on `url`. Fires for a newly opened tab and
* for an existing one switched to the other side of its item. */
logTabUsage(url: string): void
@@ -564,6 +571,30 @@ function createRuntime(session: Session): SessionRuntime {
onTabOpened: logTabUsage
})
// Arguments a chat tool filled a deployed page's run form with, keyed by the item it
// showed. Not persisted: they belong to the turn that proposed them, and a restored tab
// comes back as a plain deployed page.
const seededRunArgs = new SvelteMap<string, { args: Record<string, any>; seq: number }>()
const seededKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}`
let seededRunSeq = 0
/** What a tool filled this item's run form with, for the viewer that renders it. `seq`
* identifies the request: a page already open is re-seeded rather than reopened, so a
* latch on "seeded once" would leave the previous turn's arguments on screen. */
function seededRunArgsFor(
kind: SessionTargetKind,
path: string
): { args: Record<string, any>; seq: number } | undefined {
return seededRunArgs.get(seededKey(kind, path))
}
/** The reader dismissed the note above the form. The arguments stay in the fields — there
* is nothing to restore them to — so this only stops the page claiming they came from the
* chat. */
function clearSeededRunArgs(kind: SessionTargetKind, path: string): void {
seededRunArgs.delete(seededKey(kind, path))
}
// Let the jobs tray open a run in this session's preview panel (as an iframe
// tab over the run page). The global side-panel chat leaves this unset and
// falls back to a new browser tab.
@@ -581,6 +612,20 @@ function createRuntime(session: Session): SessionRuntime {
manager.openRunForm = ({ toolCallId, label }) => {
previewTabs.open({ type: 'runform', toolCallId, label })
}
// A DEPLOYED run opens the item's own page instead, with the model's arguments filled
// into the run form that is already there. Nothing waits on it: the tool call ends at the
// open, and the run is the reader's, started from that page as it would be with no chat
// open. `seq` rather than a flag, so a second request for a page already open re-seeds it.
manager.openDeployedRunPage = ({ kind, path, summary, args }) => {
// Through the shared adapter rather than building the item here: a session target
// spells a code-based app `raw_app` and a workspace item spells it `app` with a
// flag, and one place should know that.
const target = previewTargetForSessionTarget(kind, path)
if (target?.type !== 'item') return false
seededRunArgs.set(seededKey(kind, path), { args, seq: ++seededRunSeq })
previewTabs.open({ ...target, item: { ...target.item, summary }, mode: 'view' })
return true
}
manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId)
manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => {
previewTabs.retargetRunForm(toolCallId, `${base}/run/${jobId}?workspace=${workspace}`)
@@ -626,6 +671,8 @@ function createRuntime(session: Session): SessionRuntime {
flowCell,
loadedEditorPath,
deployedRevision,
seededRunArgsFor,
clearSeededRunArgs,
logTabUsage,
async loadFlow(workspace: string, path: string, force = false) {