From aaa436d12614eb6e4016997d83f29673d7f1b6d6 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 05:21:59 -0700 Subject: [PATCH] feat: hand a proposed run to the page already showing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader who declines a proposal and asks for different arguments still has that item's page open, and the next proposal belongs on it — but it was parked on the card until Preview was pressed again. `requestRunArgs` now routes the call to that page itself, through `openRunOnDeployedPage` with `onlyIfAlreadyOpen`: a tab already on the item is re-pointed, and anything else — a tab showing a run included — answers no and leaves the form on the card. The card keeps a working form while a page carries the call, rather than reporting that its inputs are elsewhere. Only the chat's own form in a tab is exclusive with it, since that one is the same draft and two mounts each reorder the schema in place; a page builds its form from a copy of the proposal and shares nothing. So the card folds its row instead of emptying it, and expanding it is how a run goes from the transcript. `isRunFormTabOpen` answers that narrower question, and the collapse moves into the row's default so pressing Preview and being routed here behave alike. Co-Authored-By: Claude Opus 5 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 36 ++++++++++++++++--- .../copilot/chat/RunScriptCard.svelte | 35 +++++++++--------- .../sessions/sessionRuntime.svelte.ts | 30 ++++++++++++---- 3 files changed, 73 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 8acb77930f..d1a3376321 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -595,6 +595,10 @@ export class AIChatManager implements ChatViewHost { path: string summary: string carries: boolean + /** Re-point a tab already on that page, and answer `false` rather than opening one. + * For handing a call to a page the reader already has in front of them: a tab showing + * something else, a run included, has no form to confirm this call on. */ + onlyIfAlreadyOpen?: boolean }) => boolean closeRunForm?: (toolCallId: string) => void /** Hands that tab from the form to the run it just started, in place: the tab keeps its @@ -605,11 +609,16 @@ export class AIChatManager implements ChatViewHost { workspace: string label: string }) => void - /** Whether the panel holds this call's pending form. Answered off the session's tab list, - * so it stays true while the user is on another tab, and per call rather than "the open - * one". Read from a `$derived` — the reader subscribes to the tab list through the call. - * The card hides its form on it, which is what keeps exactly one mounted per call. */ + /** Whether the panel holds this call's pending form, on either surface. Answered off the + * session's tab list, so it stays true while the user is on another tab, and per call + * rather than "the open one". Read from a `$derived` — the reader subscribes to the tab + * list through the call. The card folds its row on it, and a started job takes over + * whichever surface was waiting. */ isRunFormInPreview?: (toolCallId: string) => boolean + /** Whether the panel holds this call on the chat's own form, as opposed to on the item's + * page. The card's form is exclusive with this one alone: it is the same draft, and two + * mounts of it each reorder the schema SchemaForm edits in place. */ + isRunFormTabOpen?: (toolCallId: string) => boolean openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void closeArtifact?: (artifactId: string) => void #loading = $state(false) @@ -2064,9 +2073,26 @@ export class AIChatManager implements ChatViewHost { // `displayMessages` are redacted, so a draft built from those would open the form on // the marker rather than on the bytes the model proposed. const entry = this.#runFormEntry(toolId, form) - return new Promise((resolve) => { + const answer = new Promise((resolve) => { this.#runForms.set(toolId, { ...entry, resolve }) }) + // A tab already on this item's deployed page is where the reader would be sent anyway, + // so the call goes there without waiting to be asked: declining a proposal and having + // the agent propose another leaves that page open, and the second proposal belongs on + // it. Only a tab already there — opening one for every proposed run would take the + // panel away from whatever the reader is doing. After the resolve above is installed, + // since the page reads the call back through `isRunFormPending`. + if (form.kind === 'run' && form.path && !form.conversational) { + this.openRunOnDeployedPage?.({ + toolCallId: toolId, + kind: form.runnableKind === 'flow' ? 'flow' : 'script', + path: form.path, + summary: form.summary || form.path, + carries: true, + onlyIfAlreadyOpen: true + }) + } + return answer } /** diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index 2e95a80c58..b58ea838bf 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -223,14 +223,31 @@ const autoTab = $derived(hasOutcome ? 'outcome' : ran ? 'logs' : 'input') const activeTab = $derived(steered && tabs.some((t) => t.value === steered) ? steered : autoTab) + // The panel mounts the chat's own form on this call, so the card must not mount a second + // one: two views binding the one draft would each reorder the schema SchemaForm edits in + // place. Only the form is exclusive. A run shows in both, and collapsing the row or + // closing the tab is the user's own way out of seeing it twice. + const formInPreview = $derived( + pending && (aiChatManager.isRunFormTabOpen?.(message.tool_call_id) ?? false) + ) + // A deployed page holding the call builds its own form from a copy of the proposal, so it + // shares no draft and this card keeps a working one. The row folds instead, and expanding + // it is how the reader runs from the transcript without going to the page. + const carriedByPage = $derived( + pending && !formInPreview && (aiChatManager.isRunFormInPreview?.(message.tool_call_id) ?? false) + ) + // Keyed by call id: a bare flag would carry one card's collapse onto the next message // reusing this instance. Open by default, since the run is what was asked for — // except for an inspection, which is usually a step in the reasoning rather than // the answer, and which pays a fetch for being opened. A run cancelled before it started - // is closed too: the row already says Cancelled, and there is no run to show. + // is closed too: the row already says Cancelled, and there is no run to show. So is a call + // whose page is on screen, which is the copy the reader is looking at. let toggled = $state<{ id: string; open: boolean } | undefined>(undefined) const expanded = $derived( - toggled?.id === message.tool_call_id ? toggled.open : !inspected && (ran || !canceled) + toggled?.id === message.tool_call_id + ? toggled.open + : !inspected && (ran || !canceled) && !carriedByPage ) $effect(() => { @@ -285,14 +302,6 @@ } }) - // The panel mounts the chat's own form on this call, so the card must not mount a second - // one: two views binding the one draft would each reorder the schema SchemaForm edits in - // place. Only the form is exclusive. A run shows in both, and collapsing the row or - // closing the tab is the user's own way out of seeing it twice. - const formInPreview = $derived( - pending && (aiChatManager.isRunFormInPreview?.(message.tool_call_id) ?? false) - ) - // The three moments a run moves the card — a tab arrives, the selection follows it, the body // changes under it — are staggered rather than landing in one frame, which is what makes the // card followable. A tab already on screen when the card mounts never arrived, so a settled @@ -454,12 +463,6 @@ function openPreview() { const label = runnableName - // The panel now shows what this card was showing, so the card folds away rather than - // holding a second copy open in the transcript. Through `toggled`, so it reads as an - // ordinary collapse the reader can undo. Only when the page is taking the run: a page - // opened to look at leaves the form here, and folding it away would hide the only - // place this call can still be confirmed. - if (carries) toggled = { id: message.tool_call_id, open: false } if (previewTarget === 'form') { aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label }) return diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index a1f5880ef2..848dc2dd66 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -636,7 +636,14 @@ function createRuntime(session: Session): SessionRuntime { } // Show a deployed run on the item's own page. With `carries`, that page answers the call: // the job its Run starts is the one the tool follows. - manager.openRunOnDeployedPage = ({ toolCallId, kind, path, summary, carries }) => { + manager.openRunOnDeployedPage = ({ + toolCallId, + kind, + path, + summary, + carries, + onlyIfAlreadyOpen + }) => { // Through the shared adapter: 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) @@ -661,6 +668,9 @@ function createRuntime(session: Session): SessionRuntime { previewTabs.select(open.id) previewTabs.setCollapsed(false) } else { + // Only the item's own page carries a form for this call, so a caller routing a call + // that was not asked to open anything gets a no rather than a new tab. + if (onlyIfAlreadyOpen) return false previewTabs.open(viewTarget) } return true @@ -677,17 +687,23 @@ function createRuntime(session: Session): SessionRuntime { if (carrying) previewTabs.retargetById(carrying.id, runHref) previewTabs.retargetRunForm(toolCallId, runHref) } - // Read off the tab list rather than the slot's lifecycle: a tab the user has switched - // away from is unmounted but still open, and the card must keep its form hidden until it - // is closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes - // to `tabs` through it, and the runtime is not inside an effect root to push from. - // A page carrying the call counts: its form is this call's, so the card must not mount a - // second one. Both read the same fact, so a tab closed or re-pointed hands the form back. + // Both read off the tab list rather than the slot's lifecycle: a tab the user has switched + // away from is unmounted but still open. Resolvers, like activePreviewResolver: the + // reader's own $derived subscribes to `tabs` through them, and the runtime is not inside + // an effect root to push from. So a tab closed or re-pointed hands the card back whatever + // it had given up. + // Either surface — the chat's own form in a tab, or the item's page carrying the call. manager.isRunFormInPreview = (toolCallId) => previewTabs.tabs.some( (t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId || pendingRunIdOf(t.url) === toolCallId ) + // The chat's own form alone. The card's form is exclusive with this one and no other: + // both bind the one draft, and SchemaForm reorders in place the schema it is handed, so a + // second mount reorders it again. A page builds its own form from a copy of the proposal + // and shares nothing, so the card keeps its form while a page carries the call. + manager.isRunFormTabOpen = (toolCallId) => + previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId) manager.openArtifact = (id, name, version) => { previewTabs.open({ type: 'artifact', id, name, version })