Skip to content

Mid-turn conversation switch bleeds streaming response into destination chat #254

@Ovaculos

Description

@Ovaculos

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

  1. Open the app with the Conversations tab visible (sidebar + chat panel).
  2. Start a new chat (or open an existing one — call it A).
  3. Type a prompt in A and send it.
  4. Before A's response finishes streaming, click a different conversation (B) in the list.
  5. 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.deltaflushToMessagesetMessages(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.chatengine.run).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions