Summary
When a user sends a message in one conversation and switches to another conversation before the response arrives, the streaming response continues to mutate the destination conversation's local state. The destination chat ends up displaying assistant deltas (and sometimes the source user message) from a different conversation. The user sees a turn that was never actually sent in the destination conversation.
The server-side persistence remains correct — the source conversation's file gets the full assistant turn and the auto-generated title, exactly as expected. The bleed is purely client-side UI state corruption.
Steps To Reproduce
- Open the app with the Conversations tab visible (sidebar + chat panel).
- Start a new chat (or open an existing one — call it A).
- Type a prompt in A and send it.
- Before A's response finishes streaming, click a different conversation (B) in the list.
- Watch B's chat panel.
Expected Behavior
B's chat panel shows only B's persisted history. The in-flight streaming from A continues in the background against A's own state — navigating back to A shows the response still arriving. A's full turn — user message + assistant response + generated title — lands on disk in A's conversation file via runtime.chat, which completes on the server regardless of client navigation.
Actual Behavior
B's chat panel renders B's history followed by A's streaming assistant deltas, attached to whatever B's last message happened to be. The assistant turn appears "ghosted" into B. Returning to A and back to B does not clear it until a full page reload, because the in-flight fetch keeps running and flushToMessage keeps rewriting messages[messages.length - 1] regardless of which conversation useChat's state now points to.
Root Cause
useChat (web/src/hooks/useChat.ts) is mounted by ChatProvider near the App root and persists across conversation switches. sendMessage starts a fetch via streamChat / streamChatMultipart and registers an onEvent callback whose setMessages calls always target the current state — there's no per-stream conversation guard.
When loadConversation(B) runs:
- It calls
setMessages(B's messages), replacing state.
- It does not isolate the in-flight stream from the new state.
The fetch's reader keeps delivering events. text.delta → flushToMessage → setMessages(prev => updated[last] = {role:"assistant", content: cumulativeText, ...}). Since prev is now B's array, B's last message gets overwritten.
Impact
Confusing and data-looks-corrupted UX. The disk state is fine, so a refresh recovers — but the user perceives the destination conversation as having grown an extra turn it never had. Easy to reproduce, hard to recover from without a refresh.
Proposed Fix
Rework the web chat client to handle per-chat state the way other AI chat apps (Claude.ai, ChatGPT) do. Each conversation owns its own state slice (messages, streaming flag, blocks, tool calls). Streams continue in the background after a conversation switch and write only into the originating conversation's slice. The chat panel renders from the active conversation's slice. Sidebar shows a streaming indicator on conversations with an in-flight turn. Server-side runtime.chat stays unchanged — it already completes regardless of client navigation.
Out of Scope
Server-side abort propagation. The server intentionally finishes the turn even when the client disconnects — losing the assistant message and the title for a switched-away chat would be a worse UX than letting it complete. If we ever want true cancellation, that's a separate design discussion (thread an AbortSignal from the Hono request through runtime.chat → engine.run).
Summary
When a user sends a message in one conversation and switches to another conversation before the response arrives, the streaming response continues to mutate the destination conversation's local state. The destination chat ends up displaying assistant deltas (and sometimes the source user message) from a different conversation. The user sees a turn that was never actually sent in the destination conversation.
The server-side persistence remains correct — the source conversation's file gets the full assistant turn and the auto-generated title, exactly as expected. The bleed is purely client-side UI state corruption.
Steps To Reproduce
Expected Behavior
B's chat panel shows only B's persisted history. The in-flight streaming from A continues in the background against A's own state — navigating back to A shows the response still arriving. A's full turn — user message + assistant response + generated title — lands on disk in A's conversation file via
runtime.chat, which completes on the server regardless of client navigation.Actual Behavior
B's chat panel renders B's history followed by A's streaming assistant deltas, attached to whatever B's last message happened to be. The assistant turn appears "ghosted" into B. Returning to A and back to B does not clear it until a full page reload, because the in-flight fetch keeps running and
flushToMessagekeeps rewritingmessages[messages.length - 1]regardless of which conversationuseChat's state now points to.Root Cause
useChat(web/src/hooks/useChat.ts) is mounted byChatProvidernear the App root and persists across conversation switches.sendMessagestarts a fetch viastreamChat/streamChatMultipartand registers anonEventcallback whosesetMessagescalls always target the current state — there's no per-stream conversation guard.When
loadConversation(B)runs:setMessages(B's messages), replacing state.The fetch's reader keeps delivering events.
text.delta→flushToMessage→setMessages(prev => updated[last] = {role:"assistant", content: cumulativeText, ...}). Sinceprevis now B's array, B's last message gets overwritten.Impact
Confusing and data-looks-corrupted UX. The disk state is fine, so a refresh recovers — but the user perceives the destination conversation as having grown an extra turn it never had. Easy to reproduce, hard to recover from without a refresh.
Proposed Fix
Rework the web chat client to handle per-chat state the way other AI chat apps (Claude.ai, ChatGPT) do. Each conversation owns its own state slice (messages, streaming flag, blocks, tool calls). Streams continue in the background after a conversation switch and write only into the originating conversation's slice. The chat panel renders from the active conversation's slice. Sidebar shows a streaming indicator on conversations with an in-flight turn. Server-side
runtime.chatstays unchanged — it already completes regardless of client navigation.Out of Scope
Server-side abort propagation. The server intentionally finishes the turn even when the client disconnects — losing the assistant message and the title for a switched-away chat would be a worse UX than letting it complete. If we ever want true cancellation, that's a separate design discussion (thread an
AbortSignalfrom the Hono request throughruntime.chat→engine.run).