fix(chat): stop a send whose panel went away while it uploaded

A send waits on its attachments before it has a run to be stopped, and
the panel can be replaced while it waits — the chat is keyed on its flow
and workspace, and changing either builds a new one. The upload resolved
regardless and the send carried on, starting a run against a manager
nobody was reading, with the conversation and arguments of the chat that
had gone.

The panel tells the host it is gone, and a send that finds it gone stops
where Stop would have stopped it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 13:20:39 +02:00
co-authored by Claude Opus 5
parent 1c650ba81f
commit 6df056cb60
2 changed files with 22 additions and 1 deletions
@@ -184,6 +184,10 @@
})
setChatViewHost(chatHost)
// The panel is replaced rather than re-pointed when its flow or workspace changes, so a
// send still waiting on its uploads has to be told this one is gone before it carries on.
$effect(() => () => chatHost.dispose())
// What the Configure-inputs modal asks for: every flow input the composer does not
// edit itself. Below the host, because whether the paperclip is offered is its answer.
const modalSchema = $derived.by(() => {
@@ -329,6 +329,21 @@ export class FlowChatViewHost implements ChatViewHost {
*/
#abortedSends = new Set<string>()
/** The panel this belongs to is gone. */
#gone = false
/**
* The panel went away, so nothing this host started may still act.
*
* A send waits on its uploads before it has a run, and the panel can be replaced while it
* waits — the chat is keyed on its flow and workspace, and changing either builds a new
* one. The upload resolves regardless, and without this the send would carry on into a
* manager nobody is reading, starting a run against the flow that replaced this one.
*/
dispose() {
this.#gone = true
}
/**
* Give a spent draft back after a send that did not run. The composer took it before
* calling, so something has to.
@@ -455,7 +470,9 @@ export class FlowChatViewHost implements ChatViewHost {
}
// Stop pressed while the upload ran has no job to cancel yet, so it is honoured
// here — the run has not started, and starting it now would execute a message the
// reader already took back.
// reader already took back. The panel going away is the same: nothing is left to
// run the turn for, and the composer it would be handed back to is gone too.
if (this.#gone) return false
if (this.#abortedSends.delete(conversationId)) {
this.#restoreToComposer({ ...options, conversationId })
return false