fix(sessions): keep the page's promises and its edits when it holds a call

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-07 02:41:30 -07:00
co-authored by Claude Opus 5
parent d5b96105c6
commit 88457fbe16
6 changed files with 58 additions and 14 deletions
+12 -5
View File
@@ -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}
<div bind:clientHeight={schemaHeight}>
<SchemaForm
helperScript={{
source: 'deployed',
path: runnable.path!,
runnable_kind: runnable.hash ? 'script' : 'flow'
}}
helperScript={noHelperScript
? undefined
: {
source: 'deployed',
path: runnable.path!,
runnable_kind: runnable.hash ? 'script' : 'flow'
}}
prettifyHeader
{noVariablePicker}
{autofocus}
@@ -2110,11 +2110,18 @@ export class AIChatManager implements ChatViewHost {
return true
}
/** The arguments a parked form is waiting on, for a surface showing that form outside the
* card. The draft itself, not a copy: the card and the page edit one set of arguments. */
/** What a parked form is waiting on, for a surface showing that form outside the card. */
pendingRunFormArgs = (toolId: string): Record<string, any> | 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<string, any>) => {
const entry = this.#runForms.get(toolId)
if (entry) entry.draft.args = ($state.snapshot(args) ?? {}) as Record<string, any>
}
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
@@ -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}
@@ -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}
@@ -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<string, any> | 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<string, any>) => 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<string, any>) => 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
}
@@ -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.