fix(sessions): apply a run proposal once, not on every remount

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-01 09:36:37 -07:00
co-authored by Claude Opus 5
parent 465c15aa6b
commit 904abda69b
5 changed files with 73 additions and 29 deletions
@@ -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<string, any>; 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}
<InputSelectedBadge inputSelected="agent" onReject={() => onClearSeededRun?.()} />
{/if}
@@ -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<string, any>; 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}
<InputSelectedBadge inputSelected="agent" onReject={() => onClearSeededRun?.()} />
{/if}
<RunForm
@@ -178,14 +178,10 @@
const shownKey = $derived(poolState?.shownKey)
/** Put a message in the composer of the conversation on screen, for the reader to edit and
* send, and set the flow's other inputs to go with it. Returns false when that composer
* already holds a draft — it is never overwritten, and the caller leaves the message where
* the reader can still see it.
*
* `inputs` are the run's non-message arguments: a chat flow's schema can declare more than
* `user_message`, and a message sent without them would run on saved values or schema
* defaults — a different run from the one being offered. Applied only alongside a message
* the composer took, so a declined offer leaves the reader's settings alone. */
* send. Returns false when that composer already holds a draft — it is never overwritten,
* and the caller drops the message rather than holding it. The run's other arguments go
* through {@link applyInputs}, which is deliberately not conditional on this: they belong
* to the proposal whether or not its message could land. */
export function offerMessage(text: string): boolean {
const panel = shownKey ? pool?.get(shownKey) : undefined
return panel?.host.offerMessage(text) ?? false
@@ -69,22 +69,40 @@
// Arguments a chat tool filled this item's run form with when it opened the page. Nothing
// is waiting on them: the reader runs the page as they would with no chat open, and the
// note above the form only says where the values came from.
// Taken from the runtime once and held here: the request belongs to the turn that made it,
// so leaving it in the map would re-apply a stale proposal — and reannounce it — to every
// later open of this item's deployed page.
// Carries the item it was filed under: this tab re-points to another item in place rather
// than remounting, so a seed held without its key would be pushed into the next item's
// form — replacing its own defaults — under a note crediting the agent for them.
//
// Taken from the runtime once and held here, because the request belongs to the turn that
// made it: left in the map it would re-seed every later open of this item's page. The item
// it was filed under rides along, since this tab re-points to another item in place rather
// than remounting — a seed held without its key would land in the next item's form,
// replacing its own defaults, under a note crediting the agent for them.
//
// `pending` is what the page has still to apply, and the detail page clears it by reporting
// that it has. The two are separate because the detail page remounts under this one (a
// deploy, a refresh, a version pin) and comes back with its own latch reset: without that,
// each remount would push the proposal in again, over whatever the reader has since typed,
// and bring back a note they had dismissed. The agent proposed once.
let held = $state<
{ kind: SessionTargetKind; path: string; args: Record<string, any>; seq: number } | undefined
| {
kind: SessionTargetKind
path: string
args: Record<string, any>
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}
/>
@@ -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)