From 88457fbe160322c057d7b14e30a3ef8a7075647f Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 7 Oct 2026 02:41:30 -0700 Subject: [PATCH] fix(sessions): keep the page's promises and its edits when it holds a call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dynamic-select helper runs its job when the field mounts, not when Run is pressed, so a form relocated to the page ran one under plan mode — which the card withholds for exactly that reason. The page owes the same promise while it is standing in for a card. Its form is a copy, so arguments typed there were dropped when the call went back to the card: it then offered, and ran, what the page had opened with. The call now carries them back. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/RunForm.svelte | 17 ++++++++++++----- .../copilot/chat/AIChatManager.svelte.ts | 11 +++++++++-- .../lib/components/details/FlowDetail.svelte | 11 +++++++++++ .../lib/components/details/ScriptDetail.svelte | 11 +++++++++++ .../src/lib/components/details/pendingRun.ts | 18 +++++++++++------- .../sessions/sessionRuntime.svelte.ts | 4 ++++ 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index a83a9be252..929f39de4f 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -146,6 +146,10 @@ * if the press then ends before `runAction`, so a guard that counts presses can stop * counting this one. */ claimRun?: { claim: () => boolean; release: () => void } + /** Withhold the dynamic-select helper, which runs the `dynselect-` entrypoint on mount + * rather than on Run. For a form standing in for a chat call while plan mode is on: + * nothing about pressing Run gates it, and plan mode promised no runs. */ + noHelperScript?: boolean buttonText?: string schedulable?: boolean detailed?: boolean @@ -174,6 +178,7 @@ runnable, runAction, claimRun = undefined, + noHelperScript = false, buttonText = 'Run', schedulable = true, detailed = true, @@ -364,11 +369,13 @@ {#key reloadArgs}
| undefined => this.#runForms.get(toolId)?.draft?.args + /** Take over what the form is waiting on, for a surface handing the call back with what + * the reader typed into it. A snapshot for the same reason the draft is one: these are + * `$state` proxies off another component, and the draft must not alias them. */ + setRunFormArgs = (toolId: string, args: Record) => { + const entry = this.#runForms.get(toolId) + if (entry) entry.draft.args = ($state.snapshot(args) ?? {}) as Record + } + handleRunFormCancel = (toolId: string) => { // The card's own copy is settled here rather than only in the tool's fn, which a form // restored from history no longer has: Cancel is that card's one way out, and while it diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index a288a41c75..c56d7c1294 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -399,6 +399,16 @@ runForm.setArgs(pendingRun.args ?? {}) }) + // The form here is a copy, so what the reader typed into it would be lost when the call + // goes back to the card — the card would then offer, and run, the arguments this page + // opened with. Written on the way out rather than per keystroke: one hand-back, whether + // the tab closed, was re-pointed, or the reader picked a version. + $effect(() => { + const call = pendingRun + if (!call) return + return () => call.handBack(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) { @@ -936,6 +946,7 @@ runnable={flow} {runAction} claimRun={pendingRun} + noHelperScript={pendingRun?.planModeActive} bind:args schedulable={!pendingRun} bind:this={runForm} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index cf28c5de62..0c136067a1 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -448,6 +448,16 @@ runForm.setArgs(pendingRun.args ?? {}) }) + // The form here is a copy, so what the reader typed into it would be lost when the call + // goes back to the card — the card would then offer, and run, the arguments this page + // opened with. Written on the way out rather than per keystroke: one hand-back, whether + // the tab closed, was re-pointed, or the reader picked a version. + $effect(() => { + const call = pendingRun + if (!call) return + return () => call.handBack(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) @@ -1144,6 +1154,7 @@ runnable={script} {runAction} claimRun={pendingRun} + noHelperScript={pendingRun?.planModeActive} bind:args schedulable={!pendingRun} bind:this={runForm} diff --git a/frontend/src/lib/components/details/pendingRun.ts b/frontend/src/lib/components/details/pendingRun.ts index 844030c499..c277732ca5 100644 --- a/frontend/src/lib/components/details/pendingRun.ts +++ b/frontend/src/lib/components/details/pendingRun.ts @@ -13,10 +13,16 @@ export type PendingRun = { * mount: a tab already showing this item is reused for the next request, so a latch that * only remembered "seeded" would leave the previous call's arguments on screen. */ toolCallId: string - /** What the model proposed, already narrowed to the deployed schema by the tool. The - * draft the card edits, not a copy of it, so the two surfaces fill one set of arguments. - * Gone once the call stops waiting, which a page mid-press outlives. */ + /** What the form is waiting on: the model's proposal, or whatever the card was last left + * holding. Gone once the call stops waiting, which a page mid-press outlives. */ args: Record | undefined + /** Whether the chat is in plan mode, which promised no runs. The page is standing in for + * a chat card, so it owes that promise too — and the dynamic-select helper runs its job on + * mount, where nothing about confirming the run can gate it. */ + planModeActive: boolean + /** Give the call back holding what the reader typed here, so the card they return to + * offers those arguments rather than the ones this page opened with. */ + handBack: (args: Record) => void /** Take the call, before the form mints a `password` field into an ephemeral workspace * variable. False means this press runs nothing: plan mode is on, or a press is already * in flight. Both have to be answered here rather than in {@link submit}, which the form @@ -31,8 +37,6 @@ export type PendingRun = { submit: (args: Record) => boolean /** Refuse the call, as the card's Cancel does. Not an undo of the prefill: there is * nothing to restore it to, and leaving the call parked with an emptied form would strand - * the turn. `reason` is what the model is told instead of "cancelled" — for the page that - * settles the call by running the item some other way, where reporting a refusal would - * have the model say nothing ran. */ - decline: (reason?: string) => void + * the turn. */ + decline: () => void } diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 2f66d98fd9..542be7b511 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -601,6 +601,10 @@ function createRuntime(session: Session): SessionRuntime { return { toolCallId, args: manager.pendingRunFormArgs(toolCallId), + // Read here rather than on the page, so the page's own `$derived` rebuilds when the + // posture changes while its form sits open. + planModeActive: manager.planModeActive, + handBack: (args) => manager.setRunFormArgs(toolCallId, args), // Both guards belong ahead of the form's own `processSecretArgs`, which writes // ephemeral variables to the workspace: plan mode can be switched on while the form // sits here, and a second press would mint a second set of them.