fix(sessions): tell a mounting composer apart from a busy one

The retry that waits for a chat flow's composer could not tell "not there
yet" from "the reader is writing": both read as a declined offer, so the
proposal stayed pending and would have landed in the box whenever the composer
next changed — words appearing long after they were proposed. `composerReady`
answers the first question on its own; past it the proposal is settled either
way, and a reader's draft is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-01 09:10:00 -07:00
co-authored by Claude Opus 5
parent daa2a3433d
commit 3ee5ca72f8
3 changed files with 29 additions and 9 deletions
@@ -381,16 +381,21 @@
seededSeq = seededRun.seq
return
}
// The rest of the proposal goes with it: a chat flow's schema can declare more than
// `user_message`, and sending the message alone would run on saved values or schema
// defaults — a different run from the one being offered.
// The composer mounts a flush or two after the panel does, so until it is there this
// effect waits — it reads the host's registration through `composerReady`, and runs
// again when that lands. Not latching is what keeps the proposal alive across those
// flushes.
if (!flowChat?.composerReady()) return
// Past that point the answer is final either way. The rest of the proposal goes with
// the message: a chat flow's schema can declare more than `user_message`, and sending
// the message alone would run on saved values or schema defaults — a different run
// from the one being offered. A composer already holding a draft declines, and the
// proposal is dropped rather than held: injecting it whenever the reader happens to
// clear their draft would put words in the box long after they were proposed. The
// card still shows what was asked for.
const { user_message: _m, ...inputs } = seededRun.args ?? {}
// Only on success. The composer mounts a flush or two after the panel, and a
// declined offer here means it is not there yet — this effect tracks the host's
// input through `offerMessage` and runs again when it registers. A composer that
// already holds a draft also declines, and re-offering on its next change is right:
// the reader may clear it, and then the message lands.
if (flowChat?.offerMessage(message, inputs)) seededSeq = seededRun.seq
seededSeq = seededRun.seq
flowChat.offerMessage(message, inputs)
} else if (runForm) {
seededSeq = seededRun.seq
runForm.setArgs(seededRun.args)
@@ -193,6 +193,14 @@
return true
}
/** Whether the conversation on screen has a composer to offer a message to. A caller that
* gets `false` from `offerMessage` reads this to tell "mounting, try again" from "the
* reader is writing, leave them alone". */
export function composerReady(): boolean {
const panel = shownKey ? pool?.get(shownKey) : undefined
return panel?.host.composerReady ?? false
}
// Derive additional inputs schema (excluding user_message) for chat mode
const additionalInputsSchema = $derived.by(() => {
const props = inputSchema?.properties ?? {}
@@ -582,6 +582,13 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac
this.#takeReturned()
})
/** Whether a composer is mounted and listening for this conversation. Apart from
* `offerMessage`'s own answer because the two mean different things to a caller: not ready
* is worth waiting for, a composer that declined is not. */
get composerReady(): boolean {
return !!this.#aiChatInput && !this.#disposed
}
/** Put a message in the composer showing this conversation, for the reader to edit and
* send — a chat flow's page has no run form to fill, so this is where a chat tool's
* proposed `user_message` lands. Declines when the composer already holds a draft, so it