feat: hand a proposed run to the page already showing it

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 05:21:59 -07:00
co-authored by Claude Opus 5
parent 9f9e10f0c8
commit aaa436d126
3 changed files with 73 additions and 28 deletions
@@ -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<boolean>(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<RunFormAnswer | undefined>((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
}
/**
@@ -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
@@ -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 })