diff --git a/web/src/stores/handlers/sessionHandlers.ts b/web/src/stores/handlers/sessionHandlers.ts index c0ed119..3dafdb8 100644 --- a/web/src/stores/handlers/sessionHandlers.ts +++ b/web/src/stores/handlers/sessionHandlers.ts @@ -1,6 +1,6 @@ import type { WSMessage } from '../../api/websocket'; import type { PanelTab } from '../../types/chat'; -import { applyStreamEvent, rebuildPanelTabsFromBuffer, deriveStatus } from '../helpers/bufferReplay'; +import { applyStreamEvent, rebuildPanelTabsFromBuffer, deriveStatus, extractTodosFromBuffer } from '../helpers/bufferReplay'; import type { Get, Set } from './types'; // ------------------------------------------------------------------ // @@ -89,7 +89,13 @@ export function handleSessionStatus( } } - set({ + // Restore todos panel from the freshest TodoWrite in the buffer. Without + // this, a client that reconnects mid-turn (page refresh, WS drop, tab + // backgrounded) sees a stale snapshot from persisted history because the + // buffered TodoWrite tool_use events fed only streamingBlocks. + const restoredTodos = extractTodosFromBuffer(bufferedEvents); + + const update: Record = { isStreaming: true, streamingBlocks: blocks, agentStatus: deriveStatus(blocks), @@ -97,7 +103,11 @@ export function handleSessionStatus( activePanelId: restored.activePanelId, panelVisible: restored.panels.length > 0, pendingInteraction: restoredInteraction, - }); + }; + if (restoredTodos !== null) { + update.currentTodos = restoredTodos; + } + set(update); } else { set({ isStreaming: true, streamingBlocks: blocks, agentStatus: deriveStatus(blocks) }); } diff --git a/web/src/stores/helpers/bufferReplay.ts b/web/src/stores/helpers/bufferReplay.ts index 1b4f098..a46e246 100644 --- a/web/src/stores/helpers/bufferReplay.ts +++ b/web/src/stores/helpers/bufferReplay.ts @@ -184,3 +184,27 @@ export function extractTodosFromMessages(messages: ChatMessage[]): TodoItem[] { } return []; } + +/** + * Extract the latest TodoWrite todos from a list of buffered WS events. + * Used during live reconnect (session_status with buffered_events): the + * persisted message history may not yet include the in-flight turn, so the + * todos panel needs to read the freshest state from the buffer. + * + * Returns null when the buffer contains no top-level TodoWrite, so the caller + * can preserve whatever currentTodos was already set from persisted history. + * Unlike extractTodosFromMessages, this does NOT skip the all-completed case; + * the panel's own auto-hide handles that animation once the user sees it. + */ +export function extractTodosFromBuffer(events: WSMessage[]): TodoItem[] | null { + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.type !== 'tool_use') continue; + if (event.tool !== 'TodoWrite') continue; + // Sub-agent (Task) child TodoWrite calls belong to the panel, not the main todos. + if ('parent_tool_use_id' in event && event.parent_tool_use_id) continue; + const todos = (event.input as { todos?: TodoItem[] } | undefined)?.todos; + if (Array.isArray(todos)) return todos as TodoItem[]; + } + return null; +}