From 904abda69b2d97468e60ab3f3dac6a4a89c77280 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 09:36:37 -0700 Subject: [PATCH] fix(sessions): apply a run proposal once, not on every remount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail page remounts under its viewer whenever the item is deployed, refreshed or pinned to a version, and came back with its own latch reset — so the agent's proposal was pushed in again, over whatever the reader had typed since, bringing back a note they had dismissed. The proposal was made once. The viewer now holds it as pending until the page reports applying it, and says separately whether to credit the agent: the note stands until dismissed, the values land once. Also from the CI round's nits: - a pinned viewer the handover re-points is brought to the front, since the call is about to say the page is open - offerMessage's doc described inputs it no longer carries; applyInputs owns them, deliberately unconditional on the message landing Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/details/FlowDetail.svelte | 13 +++++- .../components/details/ScriptDetail.svelte | 22 +++++++--- .../flows/conversations/FlowChat.svelte | 12 ++--- .../components/sessions/ItemViewerView.svelte | 44 ++++++++++++++----- .../sessions/sessionRuntime.svelte.ts | 11 ++++- 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index c43522e1b4..04452ec800 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -104,6 +104,8 @@ active = true, embedded = false, seededRun, + seededByAgent = false, + onSeedApplied, onClearSeededRun, onLoadState }: { @@ -125,7 +127,12 @@ * note above them. `seq` identifies the request, so a second one re-seeds a page that * is already open. */ seededRun?: { args: Record; seq: number } - /** The reader dismissed that note. The values stay in the fields. */ + /** Whether to say the agent filled them. Outlives `seededRun`, since the note stands + * until the reader dismisses it. */ + seededByAgent?: boolean + /** Applied, so the host stops offering them. */ + onSeedApplied?: () => void + /** The reader dismissed the note. The values stay in the fields. */ onClearSeededRun?: () => void /** How the load ended, for a host that renders its own state around this page. */ onLoadState?: (state: 'loaded' | 'not_found') => void @@ -402,9 +409,11 @@ // would put words in the box long after they were proposed. The card still shows // what was asked for. if (typeof message === 'string' && message) flowChat.offerMessage(message) + onSeedApplied?.() } else if (runForm) { seededSeq = seededRun.seq runForm.setArgs(seededRun.args) + onSeedApplied?.() } }) @@ -920,7 +929,7 @@ /> {/if} - {#if seededRun} + {#if seededByAgent} onClearSeededRun?.()} /> {/if} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 4fdcf5d143..41555557ca 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -127,6 +127,8 @@ active = true, embedded = false, seededRun, + seededByAgent = false, + onSeedApplied, onClearSeededRun, onLoadState }: { @@ -143,12 +145,19 @@ /** Rendered inside a page that is not the script's own (an AI session preview tab), whose * URL this must leave alone and which a cross-workspace link must not navigate away. */ embedded?: boolean - /** Arguments a chat tool filled this form with when it opened the page. Nothing waits - * on them: the page runs as it always does, and these only seed the fields and put a - * note above them. `seq` identifies the request, so a second one re-seeds a page that - * is already open. */ + /** Arguments a chat tool proposed when it opened the page, for as long as they have + * still to be applied. Nothing waits on them: the page runs as it always does, and + * these only fill the fields. `seq` identifies the request, so a second one re-seeds a + * page already open. Gone once applied — this page remounts under its host on a + * deploy, a refresh or a version pin, and applying again would overwrite what the + * reader has typed since. */ seededRun?: { args: Record; seq: number } - /** The reader dismissed that note. The values stay in the fields. */ + /** Whether to say the agent filled them. Outlives `seededRun`, since the note stands + * until the reader dismisses it. */ + seededByAgent?: boolean + /** Applied, so the host stops offering them. */ + onSeedApplied?: () => void + /** The reader dismissed the note. The values stay in the fields. */ onClearSeededRun?: () => void /** How the load ended, for a host that renders its own state around this page. * Providing it also suppresses the "could not load" toast: a 404 here is the normal @@ -432,6 +441,7 @@ if (seededSeq === seededRun.seq) return seededSeq = seededRun.seq runForm.setArgs(seededRun.args) + onSeedApplied?.() }) // Read once on purpose: these args seed the form, so tracking the fragment would @@ -1105,7 +1115,7 @@ /> {/if} - {#if seededRun} + {#if seededByAgent} onClearSeededRun?.()} /> {/if} ; seq: number } | undefined + | { + kind: SessionTargetKind + path: string + args: Record + seq: number + pending: boolean + } + | undefined >(undefined) - const seededRun = $derived(held?.kind === kind && held.path === path ? held : undefined) + const forThisItem = $derived(held?.kind === kind && held.path === path ? held : undefined) + const seededRun = $derived( + forThisItem?.pending ? { args: forThisItem.args, seq: forThisItem.seq } : undefined + ) + /** The note stands until the reader dismisses it, which outlives the one application. */ + const seededByAgent = $derived(!!forThisItem) $effect(() => { - const pending = runtime.seededRunArgsFor(kind, path) - if (!pending) return + const incoming = runtime.seededRunArgsFor(kind, path) + if (!incoming) return const [k, p] = [kind, path] untrack(() => { - held = { kind: k, path: p, ...pending } + held = { kind: k, path: p, ...incoming, pending: true } runtime.clearSeededRunArgs(k, p) }) }) @@ -228,6 +246,8 @@ {active} embedded {seededRun} + {seededByAgent} + onSeedApplied={() => held && (held = { ...held, pending: false })} onClearSeededRun={() => (held = undefined)} onLoadState={setLoadState} /> @@ -240,6 +260,8 @@ {active} embedded {seededRun} + {seededByAgent} + onSeedApplied={() => held && (held = { ...held, pending: false })} onClearSeededRun={() => (held = undefined)} onLoadState={setLoadState} /> diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index ac25e031ea..825b509467 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -637,8 +637,15 @@ function createRuntime(session: Session): SessionRuntime { t.url.includes('version=') ) }) - if (pinned) previewTabs.navigate(viewTarget, pinned.id) - else previewTabs.open(viewTarget) + if (pinned) { + previewTabs.navigate(viewTarget, pinned.id) + // `navigate` re-points without bringing the tab forward, and the call is about to + // tell the reader the page is open — which it would not be, if they had switched + // away from it before asking. + previewTabs.select(pinned.id) + } else { + previewTabs.open(viewTarget) + } return true } manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId)