fix(sessions): take no writes on a relocated form under plan mode

A password field mints an ephemeral workspace variable as it is typed, so
withholding the dynamic-select helper was half the promise: the field itself has
to be unwritable. One flag now covers both, as the card's own form already did.

The hand-back is keyed on the call rather than on the object naming it, which is
rebuilt whenever the draft or the posture changes — its own write to that draft
is what would have re-run it. It also hands back only what its own form held: the
next call for this item re-points the same tab without remounting, and its
seeding can land first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-07 03:07:55 -07:00
co-authored by Claude Opus 5
parent 88457fbe16
commit 2fe3579f75
3 changed files with 39 additions and 14 deletions
+9 -6
View File
@@ -146,10 +146,12 @@
* 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
/** Take no writes from the reader: no edits, and no dynamic-select helper. Both write
* before Run is ever pressed — a `password` field mints an ephemeral variable as it is
* typed, and the helper runs the `dynselect-` entrypoint when the field mounts — so
* nothing about gating Run can stop them. For a form standing in for a chat card while
* plan mode is on, which promised neither. */
argsReadonly?: boolean
buttonText?: string
schedulable?: boolean
detailed?: boolean
@@ -178,7 +180,7 @@
runnable,
runAction,
claimRun = undefined,
noHelperScript = false,
argsReadonly = false,
buttonText = 'Run',
schedulable = true,
detailed = true,
@@ -369,13 +371,14 @@
{#key reloadArgs}
<div bind:clientHeight={schemaHeight}>
<SchemaForm
helperScript={noHelperScript
helperScript={argsReadonly
? undefined
: {
source: 'deployed',
path: runnable.path!,
runnable_kind: runnable.hash ? 'script' : 'flow'
}}
disabled={argsReadonly}
prettifyHeader
{noVariablePicker}
{autofocus}
@@ -403,10 +403,21 @@
// 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.
//
// Keyed on the call rather than the object naming it, which is rebuilt whenever the draft
// or the posture changes: this must not re-run on either, since its own write to that
// draft would be what re-ran it.
$effect(() => {
const call = pendingRun
if (!call) return
return () => call.handBack(args ?? {})
const id = pendingRun?.toolCallId
if (!id) return
const call = untrack(() => pendingRun)
return () =>
untrack(() => {
// Only what this call's own form held. The next call for this item re-points the
// same tab without remounting, and its seeding may land first — handing this one
// the arguments of a proposal that was never on screen for it.
if (seededCallId === id) call?.handBack(args ?? {})
})
})
// The dev workspace's editor is not one the session panel can host, so from a preview tab
@@ -946,7 +957,7 @@
runnable={flow}
{runAction}
claimRun={pendingRun}
noHelperScript={pendingRun?.planModeActive}
argsReadonly={pendingRun?.planModeActive}
bind:args
schedulable={!pendingRun}
bind:this={runForm}
@@ -452,10 +452,21 @@
// 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.
//
// Keyed on the call rather than the object naming it, which is rebuilt whenever the draft
// or the posture changes: this must not re-run on either, since its own write to that
// draft would be what re-ran it.
$effect(() => {
const call = pendingRun
if (!call) return
return () => call.handBack(args ?? {})
const id = pendingRun?.toolCallId
if (!id) return
const call = untrack(() => pendingRun)
return () =>
untrack(() => {
// Only what this call's own form held. The next call for this item re-points the
// same tab without remounting, and its seeding may land first — handing this one
// the arguments of a proposal that was never on screen for it.
if (seededCallId === id) call?.handBack(args ?? {})
})
})
// Read once on purpose: these args seed the form, so tracking the fragment would
@@ -1154,7 +1165,7 @@
runnable={script}
{runAction}
claimRun={pendingRun}
noHelperScript={pendingRun?.planModeActive}
argsReadonly={pendingRun?.planModeActive}
bind:args
schedulable={!pendingRun}
bind:this={runForm}