From 8f33e7877e508ceb8abe67b80e31a45eaff809a3 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 08:44:27 -0700 Subject: [PATCH] feat(sessions): hand a deployed run to the item's own page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../copilot/chat/AIChatManager.svelte.ts | 12 +++ .../copilot/chat/RunScriptCard.svelte | 56 +++++++++-- .../components/copilot/chat/global/core.ts | 39 ++++++++ .../src/lib/components/copilot/chat/shared.ts | 10 ++ .../lib/components/details/FlowDetail.svelte | 38 ++++++++ .../components/details/ScriptDetail.svelte | 24 +++++ .../flows/conversations/FlowChat.svelte | 8 ++ .../conversations/flowChatViewHost.svelte.ts | 9 ++ .../schema/InputSelectedBadge.svelte | 24 +++-- .../components/sessions/ItemViewerView.svelte | 97 ++++++++++--------- .../sessions/sessionRuntime.svelte.ts | 47 +++++++++ 11 files changed, 305 insertions(+), 59 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 7bf9685e87..e79edd0c3e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -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 + }) => 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), diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index ca5e717891..114a49fe6b 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -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} diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 2a39ca1902..00378dbb26 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -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 Promise | 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 + }) => 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). */ diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 7001075e93..ab475450c7 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -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; 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} {/if} + {#if seededRun} + onClearSeededRun?.()} /> + {/if} + ; 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 | 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} + onClearSeededRun?.()} /> + {/if} { const props = inputSchema?.properties ?? {} diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index d8dee9dcaa..9b28019796 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -579,6 +579,15 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender

- 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}

{#if acceptButton} {@render acceptButton()} diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index b805f1b91a..58deabe52b 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -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 @@ - {#key loadKey} - {@const mountedKey = loadKey} - {@const setLoadState = (state: 'loaded' | 'not_found') => onLoadState(mountedKey, state)} - {#if notDeployed} - -
-
Not deployed yet
-
- Nothing is deployed at {path}, so there is no deployed - version to view. Deploy it from the editor to see it here. -
- +
+
Not deployed yet
+
+ Nothing is deployed at {path}, so there is no deployed + version to view. Deploy it from the editor to see it here.
- {:else if kind === 'script'} - - {:else if kind === 'flow'} - - {:else} - onNavigate(`/apps_raw/edit/${path}`)} - onLoadState={setLoadState} - syncHashToUrl={false} - /> - {/if} - {/key} + +
+ {:else if kind === 'script'} + runtime.clearSeededRunArgs(kind, path)} + onLoadState={setLoadState} + /> + {:else if kind === 'flow'} + runtime.clearSeededRunArgs(kind, path)} + onLoadState={setLoadState} + /> + {:else} + onNavigate(`/apps_raw/edit/${path}`)} + onLoadState={setLoadState} + syncHashToUrl={false} + /> + {/if} +{/key} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 77d0a5c200..a0f792c24b 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -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; 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; 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; 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) {