Description
When the user navigates away from a chat conversation while the assistant is still generating a response, the entire response is silently lost. On returning to that session, the last user message has no assistant reply, making it look like the model never responded.
Root cause analysis
The bug has two compounding causes that together guarantee data loss:
1. Navigation triggers stream cancellation
The abort chain is:
ChatPage unmounts
→ useChat cleanup fires AbortSignal
→ ElectronChatTransport abort listener (line ~158 in ElectronChatTransport.ts)
→ window.levante.stopStreaming()
→ ipcMain handler "levante/chat/stop-stream"
→ activeStreams.get(streamId).cancel() ← sets isCancelled = true
→ streaming loop in chatHandlers.ts (~line 73) breaks immediately
The isCancelled flag is checked at the top of the for await loop in handleChatStream (src/main/ipc/chatHandlers.ts:73). As soon as it is set, the main process stops calling the LLM and the connection to the provider is dropped.
2. Assistant messages are only persisted on stream completion
persistMessage (called from onFinish in ChatPage.tsx:335 and ChatPage.tsx:427) is never reached when the stream is cancelled. The sequence:
User message → persisted immediately (before AI call) ✅
Assistant message → persisted in onFinish callback ❌ (never fires if stream cancelled)
So after cancellation:
- DB contains the user message ✅
- DB contains no assistant message ❌
useChat in-memory state is cleared on unmount ❌
- On return,
loadHistoricalMessages loads from DB → only the user message appears
3. Reconnection hook is a no-op
ElectronChatTransport.reconnectToStream() (src/renderer/transports/ElectronChatTransport.ts:245) is the exact AI SDK v5 hook designed for this scenario, but it currently returns null with the comment:
// Electron IPC doesn't support reconnection pattern yet
// This would require stream persistence in the main process
return null;
This is the extension point to implement the fix.
Steps to reproduce
- Start a new conversation and send a message that produces a long response (e.g. "write a 500-word essay on…")
- While the assistant is streaming, click a different session in the sidebar
- Navigate back to the original session
- The assistant response is gone — only the user message remains
Proposed solutions
There are two viable approaches, from least to most invasive:
Option A — Background streaming with main-process buffering (recommended)
Idea: decouple stream lifetime from renderer lifecycle. The main process keeps streaming even after the renderer disconnects, buffers all chunks in memory, and writes the final message to the DB on completion. When the user navigates back, the message is already there.
Key changes:
-
Do not cancel on navigation — Remove or condition the abort→cancel chain. Instead of cancelling the IPC stream when ChatPage unmounts, let the main process stream run to completion independently.
-
Buffer chunks in main process — Extend the activeStreams Map entry to also hold an accumulated text buffer:
const activeStreams = new Map<string, {
cancel: () => void;
sessionId: string;
buffer: string;
}>();
-
Persist on completion in the main process — When the stream finishes (or errors), the main process writes the buffered assistant message directly to the DB via ChatService, regardless of whether the renderer is still listening.
-
Implement reconnectToStream() — When the user navigates back to a session that has an in-progress stream, ElectronChatTransport.reconnectToStream() can reattach to the existing IPC channel (by streamId, stored alongside chatId in the active streams Map) and replay missed chunks, or simply skip to live.
Pros: complete responses always reach the DB; seamless reconnection possible.
Cons: requires passing sessionId + ChatService reference into chatHandlers.ts; more state in the main process.
Option B — Periodic partial-message persistence (simpler, lower fidelity)
Idea: keep the existing cancellation behaviour but periodically flush accumulated delta text to the DB as a "draft" assistant message (e.g. every N chunks or every 2 s). On cancellation the last flush survives.
Key changes:
- Add a
status column (or reuse existing) on messages table: 'complete' | 'streaming'.
- In
ElectronChatTransport, after every batch of chunks, call a new IPC endpoint levante/chat/save-partial with the accumulated text and messageId.
- On cancel/unmount, the most recent partial is already in the DB.
- On session load, messages with
status = 'streaming' show a "(response interrupted)" indicator.
Pros: minimal architectural change; no state kept in main process beyond stream lifetime.
Cons: partial responses may be confusing; does not enable reconnection; still loses the tail of the response.
Affected files
| File |
Role |
src/main/ipc/chatHandlers.ts |
Stream lifecycle, isCancelled flag |
src/renderer/transports/ElectronChatTransport.ts |
Abort listener, reconnectToStream() stub |
src/renderer/pages/ChatPage.tsx |
onFinish persistence callback |
src/renderer/stores/chatStore.ts |
persistMessage action |
src/main/services/aiService.ts |
Streaming generator |
database/migrations/ |
Schema changes if Option B |
Environment
- Electron + React (renderer) + IPC bridge
- AI SDK v5
useChat with custom ChatTransport
- SQLite for message persistence
Description
When the user navigates away from a chat conversation while the assistant is still generating a response, the entire response is silently lost. On returning to that session, the last user message has no assistant reply, making it look like the model never responded.
Root cause analysis
The bug has two compounding causes that together guarantee data loss:
1. Navigation triggers stream cancellation
The abort chain is:
The
isCancelledflag is checked at the top of thefor awaitloop inhandleChatStream(src/main/ipc/chatHandlers.ts:73). As soon as it is set, the main process stops calling the LLM and the connection to the provider is dropped.2. Assistant messages are only persisted on stream completion
persistMessage(called fromonFinishinChatPage.tsx:335andChatPage.tsx:427) is never reached when the stream is cancelled. The sequence:So after cancellation:
useChatin-memory state is cleared on unmount ❌loadHistoricalMessagesloads from DB → only the user message appears3. Reconnection hook is a no-op
ElectronChatTransport.reconnectToStream()(src/renderer/transports/ElectronChatTransport.ts:245) is the exact AI SDK v5 hook designed for this scenario, but it currently returnsnullwith the comment:This is the extension point to implement the fix.
Steps to reproduce
Proposed solutions
There are two viable approaches, from least to most invasive:
Option A — Background streaming with main-process buffering (recommended)
Idea: decouple stream lifetime from renderer lifecycle. The main process keeps streaming even after the renderer disconnects, buffers all chunks in memory, and writes the final message to the DB on completion. When the user navigates back, the message is already there.
Key changes:
Do not cancel on navigation — Remove or condition the abort→cancel chain. Instead of cancelling the IPC stream when
ChatPageunmounts, let the main process stream run to completion independently.Buffer chunks in main process — Extend the
activeStreamsMap entry to also hold an accumulated text buffer:Persist on completion in the main process — When the stream finishes (or errors), the main process writes the buffered assistant message directly to the DB via
ChatService, regardless of whether the renderer is still listening.Implement
reconnectToStream()— When the user navigates back to a session that has an in-progress stream,ElectronChatTransport.reconnectToStream()can reattach to the existing IPC channel (bystreamId, stored alongsidechatIdin the active streams Map) and replay missed chunks, or simply skip to live.Pros: complete responses always reach the DB; seamless reconnection possible.
Cons: requires passing
sessionId+ChatServicereference intochatHandlers.ts; more state in the main process.Option B — Periodic partial-message persistence (simpler, lower fidelity)
Idea: keep the existing cancellation behaviour but periodically flush accumulated delta text to the DB as a "draft" assistant message (e.g. every N chunks or every 2 s). On cancellation the last flush survives.
Key changes:
statuscolumn (or reuse existing) onmessagestable:'complete' | 'streaming'.ElectronChatTransport, after every batch of chunks, call a new IPC endpointlevante/chat/save-partialwith the accumulated text andmessageId.status = 'streaming'show a "(response interrupted)" indicator.Pros: minimal architectural change; no state kept in main process beyond stream lifetime.
Cons: partial responses may be confusing; does not enable reconnection; still loses the tail of the response.
Affected files
src/main/ipc/chatHandlers.tsisCancelledflagsrc/renderer/transports/ElectronChatTransport.tsreconnectToStream()stubsrc/renderer/pages/ChatPage.tsxonFinishpersistence callbacksrc/renderer/stores/chatStore.tspersistMessageactionsrc/main/services/aiService.tsdatabase/migrations/Environment
useChatwith customChatTransport