From 06006af4a218b54bebdb0096c2fbe7fed92b85f6 Mon Sep 17 00:00:00 2001 From: null-runner Date: Mon, 20 Jul 2026 12:25:45 +0200 Subject: [PATCH 1/2] [verified] fix(desktop): keep completed task history accessible --- .../composer/hooks/use-status-presence.ts | 6 +- .../app/chat/composer/status-stack/index.tsx | 22 +++ .../status-stack/task-history-list.tsx | 46 +++++ .../status-stack/task-history.test.tsx | 174 +++++++++++++++++ .../app/chat/session-tile-actions.test.tsx | 167 +++++++++++++++++ .../src/app/chat/session-tile-actions.ts | 25 ++- .../hooks/use-session-tile-delegate.test.tsx | 177 ++++++++++++++++++ .../hooks/use-session-tile-delegate.ts | 11 +- apps/desktop/src/app/contrib/wiring.tsx | 4 +- .../hooks/use-message-stream/gateway-event.ts | 13 +- .../session/hooks/use-message-stream/index.ts | 31 +-- .../use-message-stream/todo-cleanup.test.tsx | 104 +++++++++- .../hooks/use-prompt-actions/index.test.tsx | 71 +++++++ .../session/hooks/use-prompt-actions/index.ts | 24 ++- .../hooks/use-session-actions.test.tsx | 166 +++++++++++++++- .../hooks/use-session-actions/index.ts | 22 ++- .../src/components/chat/status-section.tsx | 11 +- apps/desktop/src/i18n/en.ts | 3 + apps/desktop/src/i18n/ja.ts | 3 + apps/desktop/src/i18n/types.ts | 3 + apps/desktop/src/i18n/zh-hant.ts | 3 + apps/desktop/src/i18n/zh.ts | 3 + apps/desktop/src/lib/todos.test.ts | 28 ++- apps/desktop/src/lib/todos.ts | 89 +++++++++ apps/desktop/src/store/session-states.ts | 4 + apps/desktop/src/store/todos.test.ts | 130 +++++++++++++ apps/desktop/src/store/todos.ts | 152 ++++++++++++++- 27 files changed, 1437 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/status-stack/task-history-list.tsx create mode 100644 apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx create mode 100644 apps/desktop/src/app/chat/session-tile-actions.test.tsx create mode 100644 apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts index c6b9af53b737..6d1633361d11 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts @@ -2,14 +2,17 @@ import { useSyncExternalStore } from 'react' import { $statusItemsBySession } from '@/store/composer-status' import { $previewStatusBySession } from '@/store/preview-status' +import { $todoHistoryBySession } from '@/store/todos' const subscribe = (onChange: () => void) => { const offItems = $statusItemsBySession.listen(onChange) const offPreviews = $previewStatusBySession.listen(onChange) + const offTaskHistory = $todoHistoryBySession.listen(onChange) return () => { offItems() offPreviews() + offTaskHistory() } } @@ -30,7 +33,8 @@ export function useSessionStatusPresence(sessionId: string | null): boolean { return ( ($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 || - ($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0 + ($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0 || + ($todoHistoryBySession.get()[sessionId]?.length ?? 0) > 0 ) }) } diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index e8107764d881..7026707f9633 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { $statusItemsBySession, @@ -22,10 +23,12 @@ import { } from '@/store/composer-status' import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $threadScrolledUp } from '@/store/thread-scroll' +import { $todoHistoryBySession } from '@/store/todos' import { openSessionInNewWindow } from '@/store/windows' import { PreviewStatusRow } from './preview-row' import { StatusItemRow } from './status-row' +import { TaskHistoryList } from './task-history-list' // Slow safety-net poll for silent exits (processes without notify_on_complete // emit no event when they die). Only armed while a running row is on screen. @@ -70,6 +73,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const itemsBySession = useStore($statusItemsBySession) const previewsBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) + const taskHistory = useSessionSlice($todoHistoryBySession, sessionId) const groups = useMemo( () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), @@ -123,6 +127,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const sections: { key: string; node: ReactNode }[] = [] + const historySection = + taskHistory.length > 0 ? ( + } + label={t.statusStack.taskHistory} + > + + + ) : null + + if (historySection && !groups.some(group => group.type === 'todo')) { + sections.push({ key: 'task-history', node: historySection }) + } + for (const group of groups) { sections.push({ key: group.type, @@ -160,6 +178,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro ) }) + if (group.type === 'todo' && historySection) { + sections.push({ key: 'task-history', node: historySection }) + } + // Preview links belong to the background group (a localhost dev server and // its preview are the same thing), but they must stay VISIBLE even when that // group is collapsed — the whole point is a one-tap open. Render them as an diff --git a/apps/desktop/src/app/chat/composer/status-stack/task-history-list.tsx b/apps/desktop/src/app/chat/composer/status-stack/task-history-list.tsx new file mode 100644 index 000000000000..c72c642e83b8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/task-history-list.tsx @@ -0,0 +1,46 @@ +import { Fragment } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import type { TodoHistorySnapshot } from '@/lib/todos' +import type { ComposerStatusItem } from '@/store/composer-status' + +import { StatusItemRow } from './status-row' + +interface TaskHistoryListProps { + snapshots: readonly TodoHistorySnapshot[] +} + +const historyItem = (snapshotId: string, todo: TodoHistorySnapshot['todos'][number]): ComposerStatusItem => ({ + id: `${snapshotId}:${todo.id}`, + state: todo.status === 'in_progress' ? 'running' : 'done', + title: todo.content, + todoStatus: todo.status, + type: 'todo' +}) + +/** Compact transcript-derived plans. The store owns ordering, retention, and + * deduplication; this component only renders the selected session's slice. */ +export function TaskHistoryList({ snapshots }: TaskHistoryListProps) { + const { t } = useI18n() + + return ( +
+ {snapshots.map(snapshot => ( + +
+ + + {snapshot.state === 'completed' + ? t.statusStack.taskHistoryCompleted + : t.statusStack.taskHistoryUnfinished} + +
+ {snapshot.todos.map(todo => ( + + ))} +
+ ))} +
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx new file mode 100644 index 000000000000..0685cf130fed --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx @@ -0,0 +1,174 @@ +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { atom } from 'nanostores' +import type { ReactNode } from 'react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import type { TodoHistorySnapshot, TodoItem } from '@/lib/todos' +import { $messages } from '@/store/session' +import { + $todoHistoryBySession, + clearAllSessionTodoState, + rebuildSessionTodoHistory, + setSessionTodos +} from '@/store/todos' + +import { ComposerStatusStack } from '.' + +class ResizeObserverStub { + disconnect() {} + observe() {} +} + +const todo = (id: string, content: string, status: TodoItem['status'] = 'completed'): TodoItem => ({ + content, + id, + status +}) + +const snapshot = (id: string, content: string): TodoHistorySnapshot => ({ + id, + state: 'completed', + todos: [todo(id, content)] +}) + +function renderStack(sessionId: string, wrapper?: (children: ReactNode) => ReactNode) { + const stack = + + return render( + + + {wrapper ? wrapper(stack) : stack} + + + ) +} + +describe('composer task history', () => { + beforeAll(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + }) + + afterEach(() => { + cleanup() + clearAllSessionTodoState() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('keeps task history available after the finished live list dismisses at four seconds', () => { + vi.useFakeTimers() + $todoHistoryBySession.set({ sid: [snapshot('old', 'Historical task')] }) + setSessionTodos('sid', [todo('live', 'Live task')]) + + const view = renderStack('sid') + + expect(screen.getByText('Live task')).toBeTruthy() + const historyButton = screen.getByRole('button', { name: 'Task history' }) + expect(historyButton.getAttribute('aria-expanded')).toBe('false') + const controlledId = historyButton.getAttribute('aria-controls') ?? '' + expect(controlledId).toBeTruthy() + + act(() => vi.advanceTimersByTime(4_000)) + + expect(screen.queryByText('Live task')).toBeNull() + expect(screen.getByRole('button', { name: 'Task history' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Task history' })) + expect(screen.getByText('Historical task')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Task history' }).getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector(`[id="${controlledId}"]`)).toBeTruthy() + }) + + it('renders the live list first and keeps a large history in a separate collapsed section', () => { + const history = Array.from({ length: 10 }, (_, index) => snapshot(`history-${index}`, `History ${index}`)) + $todoHistoryBySession.set({ sid: history }) + setSessionTodos('sid', [todo('live', 'Live now', 'in_progress')]) + + const view = renderStack('sid') + const live = screen.getByText('Live now') + const historyButton = screen.getByRole('button', { name: 'Task history' }) + + expect(live).toBeTruthy() + expect(historyButton.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('History 0')).toBeNull() + expect(live.compareDocumentPosition(historyButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(view.container.querySelectorAll('button[aria-expanded]')).toHaveLength(2) + }) + + it('shows only the newest copy of the same id and content when status changed', () => { + rebuildSessionTodoHistory('sid', [ + { + id: 'older', + role: 'assistant', + parts: [ + { + type: 'tool-call', + toolName: 'todo', + toolCallId: 'todo-old', + args: { todos: [todo('same', 'One plan', 'in_progress')] } + } + ] + }, + { + id: 'newer', + role: 'assistant', + parts: [ + { + type: 'tool-call', + toolName: 'todo', + toolCallId: 'todo-new', + result: { todos: [todo('same', 'One plan', 'completed')] } + } + ] + } + ]) + + renderStack('sid') + fireEvent.click(screen.getByRole('button', { name: 'Task history' })) + + expect(screen.getAllByText('One plan')).toHaveLength(1) + }) + + it('isolates task history between two session views', () => { + $todoHistoryBySession.set({ + 'runtime-a': [snapshot('a', 'Only A')], + 'runtime-b': [snapshot('b', 'Only B')] + }) + + const view = render( + + +
+ +
+
+ +
+
+
+ ) + + const sessionA = within(view.getByRole('region', { name: 'Session A' })) + const sessionB = within(view.getByRole('region', { name: 'Session B' })) + fireEvent.click(sessionA.getByRole('button', { name: 'Task history' })) + + expect(sessionA.getByText('Only A')).toBeTruthy() + expect(sessionA.queryByText('Only B')).toBeNull() + expect(sessionB.queryByText('Only A')).toBeNull() + expect(sessionB.queryByText('Only B')).toBeNull() + + fireEvent.click(sessionB.getByRole('button', { name: 'Task history' })) + expect(sessionB.getByText('Only B')).toBeTruthy() + }) + + it('does not subscribe the composer status surface to transcript messages', () => { + const listen = vi.spyOn($messages, 'listen') + const unrelatedMessages = atom([{ id: 'message', role: 'user' }]) + + renderStack('sid', children =>
{children}
) + + expect(listen).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile-actions.test.tsx b/apps/desktop/src/app/chat/session-tile-actions.test.tsx new file mode 100644 index 000000000000..84e09ce21acc --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-actions.test.tsx @@ -0,0 +1,167 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { atom } from 'nanostores' +import { useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { textPart } from '@/lib/chat-messages' +import { createClientSessionState } from '@/lib/chat-runtime' +import { createComposerAttachmentScope } from '@/store/composer' +import { + clearAllSessionStates, + publishSessionState, + type SessionTileDelegate, + setSessionTileDelegate +} from '@/store/session-states' +import { $todoHistoryBySession, clearAllSessionTodoState, rebuildSessionTodoHistory } from '@/store/todos' + +import type { ComposerScope } from './composer/scope' +import { useSessionTileActions } from './session-tile-actions' + +const requestGateway = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + desktop: { + editFailed: 'edit failed', + regenerateFailed: 'reload failed', + stopFailed: 'stop failed' + } + } + }) +})) + +const RUNTIME_ID = 'runtime-tile' +const STORED_ID = 'stored-tile' + +const todoAssistant = (id: string, content: string) => ({ + id, + parts: [ + { + args: { todos: [{ content, id, status: 'completed' as const }] }, + toolCallId: `call-${id}`, + toolName: 'todo', + type: 'tool-call' as const + } + ], + role: 'assistant' as const +}) + +const originalMessages = [ + { id: 'u1', parts: [textPart('first prompt')], role: 'user' as const }, + todoAssistant('a1', 'first task'), + { id: 'u2', parts: [textPart('second prompt')], role: 'user' as const }, + todoAssistant('a2', 'tail task') +] + +interface Handle { + editMessage: ReturnType['editMessage'] + reloadFromMessage: ReturnType['reloadFromMessage'] + restoreToMessage: ReturnType['restoreToMessage'] +} + +function Harness({ onReady }: { onReady: (handle: Handle) => void }) { + const scope: ComposerScope = { + $awaitingInput: atom(false), + attachments: createComposerAttachmentScope(), + popoutAllowed: false, + readMessages: () => originalMessages, + target: `tile:${STORED_ID}` + } + + const actions = useSessionTileActions({ runtimeId: RUNTIME_ID, scope, storedSessionId: STORED_ID }) + + useEffect(() => { + onReady(actions) + }, [actions, onReady]) + + return null +} + +function installDelegate(stateRef: { current: ClientSessionState }) { + const delegate: SessionTileDelegate = { + archiveSession: async () => undefined, + branchSession: async () => undefined, + deleteSession: async () => undefined, + executeSlash: async () => undefined, + interruptSession: async () => undefined, + resumeTile: async () => RUNTIME_ID, + submitToSession: async () => undefined, + updateSession: (_runtimeId, updater) => { + stateRef.current = updater(stateRef.current) + publishSessionState(RUNTIME_ID, stateRef.current) + + return stateRef.current + } + } + + setSessionTileDelegate(delegate) +} + +describe('useSessionTileActions task-history rollback', () => { + let stateRef: { current: ClientSessionState } + let handle: Handle | null + + beforeEach(async () => { + handle = null + requestGateway.mockReset() + requestGateway.mockRejectedValue(new Error('gateway rejected rewind')) + stateRef = { + current: { + ...createClientSessionState(STORED_ID), + messages: originalMessages + } + } + publishSessionState(RUNTIME_ID, stateRef.current) + rebuildSessionTodoHistory(RUNTIME_ID, originalMessages) + installDelegate(stateRef) + render( (handle = value)} />) + await waitFor(() => expect(handle).not.toBeNull()) + }) + + afterEach(() => { + cleanup() + clearAllSessionStates() + clearAllSessionTodoState() + vi.restoreAllMocks() + }) + + const expectOriginalHistory = () => { + expect(stateRef.current.messages).toEqual(originalMessages) + expect($todoHistoryBySession.get()[RUNTIME_ID]?.map(snapshot => snapshot.id)).toEqual(['a2', 'a1']) + } + + it('restores both transcript and task history when tile reload fails', async () => { + await act(async () => handle!.reloadFromMessage('a1')) + + expectOriginalHistory() + }) + + it('restores both transcript and task history when tile restore fails', async () => { + await expect(act(async () => handle!.restoreToMessage('u1'))).rejects.toThrow('gateway rejected rewind') + + expectOriginalHistory() + }) + + it('restores both transcript and task history when tile edit fails', async () => { + await act(async () => + handle!.editMessage({ + attachments: [], + content: [{ text: 'edited first prompt', type: 'text' }], + createdAt: new Date(0), + metadata: { custom: {} }, + parentId: null, + role: 'user', + runConfig: undefined, + sourceId: 'u1' + }) + ) + + expectOriginalHistory() + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 4eca47f5c094..01938eb4faf0 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -27,7 +27,7 @@ import { clearAllPrompts } from '@/store/prompts' import { $connection } from '@/store/session' import { $sessionStates, sessionTileDelegate } from '@/store/session-states' import { clearSessionSubagents } from '@/store/subagents' -import { clearSessionTodos } from '@/store/todos' +import { clearSessionTodos, rebuildSessionTodoHistory } from '@/store/todos' import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions' import { @@ -269,7 +269,11 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses return } - update(current => applyReloadOptimistic(current, plan)) + const optimistic = update(current => applyReloadOptimistic(current, plan)) + + if (optimistic) { + rebuildSessionTodoHistory(runtimeIdRef.current, optimistic.messages) + } try { await requestGateway( @@ -278,7 +282,8 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses PROMPT_SUBMIT_REQUEST_TIMEOUT_MS ) } catch (err) { - update(current => ({ ...current, busy: false, awaitingResponse: false })) + update(current => ({ ...current, busy: false, awaitingResponse: false, messages: state.messages })) + rebuildSessionTodoHistory(runtimeIdRef.current, state.messages) notifyError(err, copy.regenerateFailed) } }, @@ -297,12 +302,17 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses const wasBusy = readState()?.busy ?? false - update(state => applyRewindOptimistic(state, plan.sourceIndex)) + const optimistic = update(state => applyRewindOptimistic(state, plan.sourceIndex)) + + if (optimistic) { + rebuildSessionTodoHistory(sessionId, optimistic.messages) + } try { await submitRewind(plan.text, plan.truncateOrdinal, wasBusy) } catch (err) { update(state => ({ ...state, busy: false, awaitingResponse: false, messages })) + rebuildSessionTodoHistory(sessionId, messages) throw err } }, @@ -326,12 +336,17 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses const wasBusy = readState()?.busy ?? false - update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage)) + const optimistic = update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage)) + + if (optimistic) { + rebuildSessionTodoHistory(sessionId, optimistic.messages) + } try { await submitRewind(plan.text, plan.truncateOrdinal, wasBusy) } catch (err) { update(state => ({ ...state, busy: false, awaitingResponse: false, messages })) + rebuildSessionTodoHistory(sessionId, messages) notifyError(err, copy.editFailed) } }, diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx new file mode 100644 index 000000000000..981d56856b25 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx @@ -0,0 +1,177 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getSessionMessages } from '@/hermes' +import { createClientSessionState } from '@/lib/chat-runtime' +import { sessionTileDelegate } from '@/store/session-states' +import { $todoHistoryBySession, clearAllSessionTodoState, rebuildSessionTodoHistory } from '@/store/todos' + +import type { ClientSessionState } from '../../types' + +import { useSessionTileDelegate } from './use-session-tile-delegate' + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getSessionMessages: vi.fn() +})) + +describe('useSessionTileDelegate task-history hydration', () => { + afterEach(() => { + cleanup() + clearAllSessionTodoState() + vi.restoreAllMocks() + }) + + it('hydrates a cold tile under its runtime id without touching another tile', async () => { + const runtimeByStored: MutableRefObject> = { current: new Map() } + const states: MutableRefObject> = { current: new Map() } + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + return { session_id: 'runtime-tile-a', messages: [], info: {} } as never + } + + return {} as never + }) + + const updateSessionState = (runtimeId: string, updater: (state: ClientSessionState) => ClientSessionState) => { + const next = updater(states.current.get(runtimeId) ?? createClientSessionState('stored-tile-a')) + states.current.set(runtimeId, next) + + return next + } + + vi.mocked(getSessionMessages).mockResolvedValue({ + session_id: 'stored-tile-a', + messages: [ + { + content: '', + role: 'assistant', + timestamp: 2, + tool_calls: [ + { + id: 'todo-tile', + function: { + name: 'todo', + arguments: JSON.stringify({ + todos: [{ content: 'Tile A task', id: 'same', status: 'completed' }] + }) + } + } + ] + } + ] + } as never) + $todoHistoryBySession.set({ + 'runtime-tile-b': [ + { id: 'todo-tile', state: 'completed', todos: [{ content: 'Tile B task', id: 'same', status: 'completed' }] } + ] + }) + + function Harness() { + useSessionTileDelegate({ + archiveSession: async () => undefined, + branchStoredSession: async () => undefined, + executeSlashCommand: async () => undefined, + removeSession: async () => undefined, + requestGateway, + runtimeIdByStoredSessionIdRef: runtimeByStored, + sessionStateByRuntimeIdRef: states, + updateSessionState: updateSessionState as never + }) + + return null + } + + render() + await waitFor(() => expect(sessionTileDelegate()).not.toBeNull()) + await act(async () => sessionTileDelegate()!.resumeTile('stored-tile-a')) + + expect($todoHistoryBySession.get()['runtime-tile-a']?.[0]?.todos[0]?.content).toBe('Tile A task') + expect($todoHistoryBySession.get()['runtime-tile-b']?.[0]?.todos[0]?.content).toBe('Tile B task') + }) + + it('rebuilds from newer runtime messages and removes the temporary stored-id history key', async () => { + const runtimeByStored: MutableRefObject> = { current: new Map() } + + const runtimeMessages = [ + { + id: 'runtime-newer', + parts: [ + { + args: { todos: [{ content: 'Runtime task', id: 'runtime', status: 'completed' }] }, + toolCallId: 'runtime-todo', + toolName: 'todo', + type: 'tool-call' as const + } + ], + role: 'assistant' as const, + timestamp: 3 + } + ] + + const states: MutableRefObject> = { + current: new Map([['runtime-tile-a', { ...createClientSessionState(), messages: runtimeMessages }]]) + } + + const requestGateway = vi.fn(async () => ({ session_id: 'runtime-tile-a', messages: [], info: {} }) as never) + + const updateSessionState = ( + runtimeId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => { + const current = states.current.get(runtimeId) ?? createClientSessionState(storedSessionId) + const next = updater({ ...current, storedSessionId: storedSessionId ?? current.storedSessionId }) + states.current.set(runtimeId, next) + + return next + } + + vi.mocked(getSessionMessages).mockResolvedValue({ + session_id: 'stored-tile-a', + messages: [ + { + content: '', + role: 'assistant', + timestamp: 2, + tool_calls: [ + { + id: 'prefetch-todo', + function: { + name: 'todo', + arguments: JSON.stringify({ + todos: [{ content: 'Prefetch task', id: 'prefetch', status: 'completed' }] + }) + } + } + ] + } + ] + } as never) + rebuildSessionTodoHistory('stored-tile-a', runtimeMessages) + + function Harness() { + useSessionTileDelegate({ + archiveSession: async () => undefined, + branchStoredSession: async () => undefined, + executeSlashCommand: async () => undefined, + removeSession: async () => undefined, + requestGateway, + runtimeIdByStoredSessionIdRef: runtimeByStored, + sessionStateByRuntimeIdRef: states, + updateSessionState: updateSessionState as never + }) + + return null + } + + render() + await waitFor(() => expect(sessionTileDelegate()).not.toBeNull()) + await act(async () => sessionTileDelegate()!.resumeTile('stored-tile-a')) + + expect($todoHistoryBySession.get()['runtime-tile-a']?.[0]?.id).toBe('runtime-newer') + expect($todoHistoryBySession.get()['stored-tile-a']).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index d7a7e0467272..473cc13a1538 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { getSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import { toChatMessages } from '@/lib/chat-messages' import { publishSessionState, setSessionTileDelegate } from '@/store/session-states' +import { rebuildResumedSessionTodoHistory } from '@/store/todos' import type { SessionResumeResponse } from '@/types/hermes' import type { usePromptActions } from '../../session/hooks/use-prompt-actions' @@ -62,6 +63,7 @@ export function useSessionTileDelegate({ if (existing && cached?.storedSessionId === storedSessionId) { publishSessionState(existing, cached) + rebuildResumedSessionTodoHistory(existing, storedSessionId, cached.messages) return existing } @@ -77,17 +79,20 @@ export function useSessionTileDelegate({ throw new Error('resume returned no session id') } - updateSessionState( + const hydratedMessages = toChatMessages(prefetch?.messages ?? resumed?.messages ?? []) + + const effectiveState = updateSessionState( runtimeId, state => ({ ...state, busy: Boolean(resumed?.info?.running), - messages: - state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? []) + messages: state.messages.length > 0 ? state.messages : hydratedMessages }), storedSessionId ) + rebuildResumedSessionTodoHistory(runtimeId, storedSessionId, effectiveState.messages) + return runtimeId }, submitToSession: async (runtimeId, text) => { diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index a548e191bbb3..c777d7727822 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -56,7 +56,7 @@ import { setMessages } from '@/store/session' import { focusOpenSession } from '@/store/session-states' -import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' +import { clearSessionTodos, rebuildSessionTodoHistory, setSessionTodos, todosForHydration } from '@/store/todos' import { isSecondaryWindow } from '@/store/windows' import { useSkinCommand } from '@/themes/use-skin-command' @@ -276,6 +276,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), storedSessionId ) + rebuildSessionTodoHistory(runtimeSessionId, messages) const restored = todosForHydration(latestSessionTodos(messages)) @@ -332,6 +333,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), storedSessionId ) + rebuildSessionTodoHistory(runtimeSessionId, messages) } catch { // Non-fatal: next poll or manual refresh can hydrate. } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 25669b5a48ba..f0fdc5f28ccc 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -43,7 +43,7 @@ import { setYoloActive } from '@/store/session' import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' -import { clearActiveSessionTodos } from '@/store/todos' +import { clearActiveSessionTodos, finalizeSessionTodoSnapshot, releaseSessionTodoTurn } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events' @@ -461,9 +461,13 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // prompt, and vice versa. clearAllPrompts(sessionId) clearClarifyRequest(undefined, sessionId) - // Turn ended without a final `todo` update — drop a still-unfinished - // list so "Tasks N/M" doesn't stay pinned above the composer with the - // last item stuck pending/in_progress. Finished lists keep their linger. + // Turn ended without a final `todo` update — finalize the last live + // state first, then drop a still-unfinished live list. Finished lists + // keep their 4s linger while history remains reachable. + finalizeSessionTodoSnapshot( + sessionId, + sessionStateByRuntimeIdRef.current.get(sessionId)?.streamId ?? `turn-${Date.now()}` + ) clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) @@ -757,6 +761,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (sessionId) { clearAllPrompts(sessionId) clearClarifyRequest(undefined, sessionId) + releaseSessionTodoTurn(sessionId, sessionStateByRuntimeIdRef.current.get(sessionId)?.streamId) clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) compactedTurnRef.current.delete(sessionId) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 12aa158a860d..c711b0e08fed 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -339,14 +339,22 @@ export function useMessageStream({ return } - // The composer status stack owns todo display now (no inline panel) — - // mirror every todo state the tool reports into its session store. - if (payload?.name === 'todo') { - const todos = parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args) + const todos = + payload?.name === 'todo' + ? (parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args)) + : null - if (todos) { - setSessionTodos(sessionId, todos) - } + mutateStream( + sessionId, + parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)), + () => upsertToolPart([], payload, phase), + { pending: m => phase !== 'complete' || (m.pending ?? false) } + ) + + // Keep visual linger separate from the authoritative turn owner. Reading + // after mutateStream ensures a todo-only turn has acquired its stream id. + if (todos) { + setSessionTodos(sessionId, todos, sessionStateByRuntimeIdRef.current.get(sessionId)?.streamId) } if (!nativeSubagentSessionsRef.current.has(sessionId)) { @@ -359,15 +367,8 @@ export function useMessageStream({ ) } } - - mutateStream( - sessionId, - parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)), - () => upsertToolPart([], payload, phase), - { pending: m => phase !== 'complete' || (m.pending ?? false) } - ) }, - [flushQueuedDeltas, mutateStream, sessionInterrupted] + [flushQueuedDeltas, mutateStream, sessionInterrupted, sessionStateByRuntimeIdRef] ) const completeAssistantMessage = useCallback( diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx index 6b676976e5d1..b6f0fa9ef231 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx @@ -6,7 +6,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { createClientSessionState } from '@/lib/chat-runtime' import type { TodoItem } from '@/lib/todos' -import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos' +import { + $todoHistoryBySession, + $todosBySession, + clearSessionTodoHistory, + clearSessionTodos, + setSessionTodos +} from '@/store/todos' import type { RpcEvent } from '@/types/hermes' import { useMessageStream } from './index' @@ -15,10 +21,11 @@ const SID = 'session-1' const todo = (id: string, status: TodoItem['status']): TodoItem => ({ content: `task ${id}`, id, status }) let handleEvent: ((event: RpcEvent) => void) | null = null +let sessionStates = new Map() function Harness() { const activeSessionIdRef = useRef(SID) - const sessionStateByRuntimeIdRef = useRef(new Map()) + const sessionStateByRuntimeIdRef = useRef(sessionStates) const queryClientRef = useRef(new QueryClient()) const stream = useMessageStream({ @@ -54,12 +61,16 @@ const complete = () => act(() => handleEvent!({ payload: { text: 'done' }, sessi describe('useMessageStream turn-end todo cleanup', () => { beforeEach(() => { handleEvent = null + sessionStates = new Map() + clearSessionTodoHistory(SID) clearSessionTodos(SID) }) afterEach(() => { cleanup() + clearSessionTodoHistory(SID) clearSessionTodos(SID) + vi.useRealTimers() vi.restoreAllMocks() }) @@ -72,6 +83,46 @@ describe('useMessageStream turn-end todo cleanup', () => { expect($todosBySession.get()[SID]).toBeUndefined() }) + it('does not touch task history when a text delta streams through a 1000-message session', async () => { + await mountStream() + sessionStates.set(SID, { + ...createClientSessionState(), + messages: Array.from({ length: 1_000 }, (_, index) => ({ + id: `message-${index}`, + parts: [{ text: 'unchanged', type: 'text' as const }], + role: index % 2 === 0 ? ('user' as const) : ('assistant' as const) + })) + }) + const history = [{ id: 'old', state: 'completed' as const, todos: [todo('old', 'completed')] }] + $todoHistoryBySession.set({ [SID]: history }) + const before = $todoHistoryBySession.get() + + act(() => handleEvent!({ payload: { text: 'x' }, session_id: SID, type: 'message.delta' })) + + expect($todoHistoryBySession.get()).toBe(before) + expect($todoHistoryBySession.get()[SID]).toBe(history) + }) + + it('updates live todos on the todo event and finalizes one snapshot at message.complete', async () => { + await mountStream() + const before = $todoHistoryBySession.get() + + act(() => + handleEvent!({ + payload: { name: 'todo', todos: [todo('a', 'in_progress')] }, + session_id: SID, + type: 'tool.progress' + }) + ) + + expect($todosBySession.get()[SID]).toEqual([todo('a', 'in_progress')]) + expect($todoHistoryBySession.get()).toBe(before) + + complete() + + expect($todoHistoryBySession.get()[SID]).toMatchObject([{ state: 'unfinished', todos: [todo('a', 'in_progress')] }]) + }) + it('keeps a finished list on completion so its linger shows the final checkmarks', async () => { await mountStream() setSessionTodos(SID, [todo('a', 'completed')]) @@ -82,6 +133,37 @@ describe('useMessageStream turn-end todo cleanup', () => { expect($todosBySession.get()[SID]).toHaveLength(1) }) + it('does not let a todo-free turn replace the prior turn snapshot during its visual linger', async () => { + await mountStream() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + $todoHistoryBySession.set({ + [SID]: [{ id: 'older', state: 'completed', timestamp: 1, todos: [todo('older', 'completed')] }] + }) + + act(() => + handleEvent!({ + payload: { name: 'todo', todos: [todo('a', 'completed')] }, + session_id: SID, + type: 'tool.complete' + }) + ) + complete() + const afterTurnA = $todoHistoryBySession.get()[SID] + + expect(afterTurnA.map(snapshot => snapshot.id)).toEqual(['assistant-stream-1767225600000', 'older']) + expect(afterTurnA[0]?.timestamp).toBe(1_767_225_600) + expect($todosBySession.get()[SID]).toEqual([todo('a', 'completed')]) + + vi.setSystemTime(new Date('2026-01-01T00:00:02Z')) + act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) + complete() + + expect($todoHistoryBySession.get()[SID]).toEqual(afterTurnA) + expect($todosBySession.get()[SID]).toEqual([todo('a', 'completed')]) + vi.useRealTimers() + }) + it('drops a still-active task list when the turn errors out', async () => { await mountStream() setSessionTodos(SID, [todo('a', 'in_progress')]) @@ -90,4 +172,22 @@ describe('useMessageStream turn-end todo cleanup', () => { expect($todosBySession.get()[SID]).toBeUndefined() }) + + it('retires a finished todo turn on error without cancelling its visual linger', async () => { + await mountStream() + act(() => + handleEvent!({ + payload: { name: 'todo', todos: [todo('a', 'completed')] }, + session_id: SID, + type: 'tool.complete' + }) + ) + + act(() => handleEvent!({ payload: { message: 'boom' }, session_id: SID, type: 'error' })) + expect($todosBySession.get()[SID]).toEqual([todo('a', 'completed')]) + + complete() + + expect($todoHistoryBySession.get()[SID]).toBeUndefined() + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a06ee1294c08..1e38ce1913a9 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { textPart } from '@/lib/chat-messages' import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer' import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session' +import { $todoHistoryBySession, clearAllSessionTodoState, rebuildSessionTodoHistory } from '@/store/todos' import type { SessionInfo } from '@/types/hermes' import type { SubmitTextOptions } from './utils' @@ -60,6 +61,7 @@ async function actRender(ui: React.ReactElement) { interface HarnessHandle { activeSessionIdRef: MutableRefObject cancelRun: () => Promise + reloadFromMessage: (parentId: string | null) => Promise restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise steerPrompt: (text: string) => Promise submitText: (text: string, options?: SubmitTextOptions) => Promise @@ -158,6 +160,8 @@ function Harness({ activeSessionIdRef, cancelRun: (...args: Parameters) => act(async () => actions.cancelRun(...args)) as Promise, + reloadFromMessage: (...args: Parameters) => + act(async () => actions.reloadFromMessage(...args)) as Promise, restoreToMessage: (...args: Parameters) => act(async () => actions.restoreToMessage(...args)) as Promise, steerPrompt: (...args: Parameters) => @@ -167,6 +171,7 @@ function Harness({ }) }, [ actions.cancelRun, + actions.reloadFromMessage, actions.restoreToMessage, actions.steerPrompt, actions.submitText, @@ -765,6 +770,72 @@ describe('usePromptActions steerPrompt', () => { }) }) +describe('usePromptActions reload task-history rollback', () => { + beforeEach(() => { + clearAllSessionTodoState() + $messages.set([]) + $busy.set(false) + }) + + afterEach(() => { + cleanup() + clearAllSessionTodoState() + $messages.set([]) + $busy.set(false) + vi.restoreAllMocks() + }) + + it('restores the original transcript and task history when primary reload fails', async () => { + const todoAssistant = (id: string, content: string) => ({ + id, + parts: [ + { + args: { todos: [{ content, id, status: 'completed' as const }] }, + toolCallId: `call-${id}`, + toolName: 'todo', + type: 'tool-call' as const + } + ], + role: 'assistant' as const + }) + + const messages = [ + { id: 'u1', parts: [textPart('first prompt')], role: 'user' as const }, + todoAssistant('a1', 'first task'), + { id: 'u2', parts: [textPart('second prompt')], role: 'user' as const }, + todoAssistant('a2', 'tail task') + ] + + $messages.set(messages) + rebuildSessionTodoHistory(RUNTIME_SESSION_ID, messages) + let lastState: Record = { messages } + let handle: HarnessHandle | null = null + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'prompt.submit') { + throw new Error('reload rejected') + } + + return {} as never + }) + + await actRender( + (handle = value)} + onSeedState={state => (lastState = state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={messages} + /> + ) + + await handle!.reloadFromMessage('a1') + + expect(lastState.messages).toEqual(messages) + expect($todoHistoryBySession.get()[RUNTIME_SESSION_ID]?.map(snapshot => snapshot.id)).toEqual(['a2', 'a1']) + }) +}) + describe('usePromptActions restoreToMessage', () => { beforeEach(() => { $busy.set(false) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1fde9070d4a3..66109d0a1990 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -32,7 +32,7 @@ import { setTurnStartedAt } from '@/store/session' import { clearSessionSubagents } from '@/store/subagents' -import { clearSessionTodos } from '@/store/todos' +import { clearSessionTodos, rebuildSessionTodoHistory } from '@/store/todos' import type { ClientSessionState, @@ -639,14 +639,16 @@ export function usePromptActions({ return } - const plan = planReload($messages.get(), parentId) + const messages = $messages.get() + const plan = planReload(messages, parentId) if (!plan) { return } clearNotifications() - updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan)) + const optimistic = updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan)) + rebuildSessionTodoHistory(activeSessionId, optimistic.messages) try { await requestGateway( @@ -658,8 +660,10 @@ export function usePromptActions({ updateSessionState(activeSessionId, state => ({ ...state, busy: false, - awaitingResponse: false + awaitingResponse: false, + messages })) + rebuildSessionTodoHistory(activeSessionId, messages) notifyError(err, copy.regenerateFailed) } }, @@ -703,7 +707,8 @@ export function usePromptActions({ setMutableRef(busyRef, true) setBusy(true) setAwaitingResponse(true) - updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex)) + const optimistic = updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex)) + rebuildSessionTodoHistory(sessionId, optimistic.messages) try { await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get()) @@ -721,6 +726,7 @@ export function usePromptActions({ awaitingResponse: false, messages })) + rebuildSessionTodoHistory(sessionId, messages) throw err } }, @@ -749,7 +755,12 @@ export function usePromptActions({ setMutableRef(busyRef, true) setBusy(true) setAwaitingResponse(true) - updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage)) + + const optimistic = updateSessionState(sessionId, state => + applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage) + ) + + rebuildSessionTodoHistory(sessionId, optimistic.messages) const isStaleTargetError = (err: unknown) => /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) @@ -777,6 +788,7 @@ export function usePromptActions({ setBusy(false) setAwaitingResponse(false) updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages })) + rebuildSessionTodoHistory(sessionId, messages) notifyError(surfaced, copy.editFailed) } }, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index f9288eb86860..c1437b6b2077 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react' import { useEffect } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getSessionMessages, type SessionInfo } from '@/hermes' +import { deleteSession, getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' @@ -24,6 +24,8 @@ import { setSelectedStoredSessionId, setSessions } from '@/store/session' +import { $sessionStates, clearAllSessionStates, publishSessionState } from '@/store/session-states' +import { $todoHistoryBySession, $todosBySession, clearAllSessionTodoState, setSessionTodos } from '@/store/todos' import { sessionRoute } from '../../routes' import type { ClientSessionState } from '../../types' @@ -432,6 +434,7 @@ describe('resumeSession failure recovery', () => { setResumeFailedSessionId(null) setMessages([]) setSessions([]) + clearAllSessionTodoState() vi.restoreAllMocks() }) @@ -596,6 +599,42 @@ describe('resumeSession failure recovery', () => { expect(renderedMessages).toContain('newest prompt') }) + it('hydrates task history under the resumed runtime id on the cold prefetch path', async () => { + const storedMessages = [ + { + content: '', + role: 'assistant', + timestamp: 2, + tool_calls: [ + { + id: 'todo-call', + function: { + name: 'todo', + arguments: JSON.stringify({ todos: [{ content: 'Cold task', id: 'task', status: 'completed' }] }) + } + } + ] + } + ] + + vi.mocked(getSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + return { session_id: 'runtime-cold', resumed: 'stored-1', messages: storedMessages, info: {} } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect($todoHistoryBySession.get()['runtime-cold']?.[0]?.todos).toEqual([ + { content: 'Cold task', id: 'task', status: 'completed' } + ]) + expect($todoHistoryBySession.get()['stored-1']).toBeUndefined() + }) + it('uses the continuation projection when resume rotates an equal-length stored transcript', async () => { const parentMessages = [ { content: 'question before compression', role: 'user', timestamp: 1 }, @@ -680,6 +719,45 @@ describe('resumeSession failure recovery', () => { expect($resumeFailedSessionId.get()).toBeNull() }) + it('hydrates task history from the REST fallback under the durable id before a runtime id exists', async () => { + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + throw new Error('request timed out: session.resume') + } + + return {} as never + }) + + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [ + { + content: '', + role: 'assistant', + timestamp: 2, + tool_calls: [ + { + id: 'todo-fallback', + function: { + name: 'todo', + arguments: JSON.stringify({ + todos: [{ content: 'Fallback task', id: 'fallback', status: 'completed' }] + }) + } + } + ] + } + ], + session_id: 'stored-1' + } as never) + + await runResume(requestGateway) + + expect($activeSessionId.get()).toBeNull() + expect($todoHistoryBySession.get()['stored-1']?.[0]?.todos).toEqual([ + { content: 'Fallback task', id: 'fallback', status: 'completed' } + ]) + }) + it('resumes via the gateway default (deferred build) — not lazy, no eager opt-out', async () => { // The switch-latency fix lives backend-side: a normal cold resume gets the // gateway's default DEFERRED build (transcript returns immediately, agent @@ -789,6 +867,92 @@ describe('resumeSession failure recovery', () => { }) }) +function DeleteHarness({ + onReady, + sessionStateByRuntimeIdRef +}: { + onReady: (removeSession: (storedSessionId: string) => Promise) => void + sessionStateByRuntimeIdRef: MutableRefObject> +}) { + const activeSessionIdRef: MutableRefObject = { current: 'runtime-delete' } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'stored-delete' } + + const actions = useSessionActions({ + activeSessionId: 'runtime-delete', + activeSessionIdRef, + busyRef: { current: false }, + creatingSessionRef: { current: false }, + ensureSessionState: () => createClientSessionState('stored-delete'), + getRouteToken: () => 'delete-token', + getRoutedStoredSessionId: () => 'stored-delete', + navigate: vi.fn() as never, + requestGateway: async () => ({}) as never, + resetViewSync: vi.fn(), + runtimeIdByStoredSessionIdRef: { current: new Map() }, + selectedStoredSessionId: 'stored-delete', + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView: vi.fn(), + updateSessionState: (sessionId, updater) => { + const next = updater(sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => onReady(actions.removeSession), [actions.removeSession, onReady]) + + return null +} + +describe('removeSession task-state cleanup', () => { + afterEach(() => { + cleanup() + clearAllSessionStates() + setSessions([]) + vi.restoreAllMocks() + }) + + it('drops primary runtime and durable REST-fallback task state after delete succeeds', async () => { + const state = createClientSessionState('stored-delete') + + const sessionStateByRuntimeIdRef: MutableRefObject> = { + current: new Map([['runtime-delete', state]]) + } + + publishSessionState('runtime-delete', state) + setSessionTodos('runtime-delete', [{ content: 'live', id: 'live', status: 'in_progress' }]) + $todoHistoryBySession.set({ + 'runtime-delete': [ + { id: 'runtime-history', state: 'completed', todos: [{ content: 'runtime', id: 'r', status: 'completed' }] } + ], + 'stored-delete': [ + { id: 'stored-history', state: 'completed', todos: [{ content: 'stored', id: 's', status: 'completed' }] } + ] + }) + setSessions([storedSession({ id: 'stored-delete' })]) + vi.mocked(deleteSession).mockResolvedValue(undefined as never) + + let removeSession: ((storedSessionId: string) => Promise) | null = null + render( + (removeSession = remove)} + sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} + /> + ) + await waitFor(() => expect(removeSession).not.toBeNull()) + await act(async () => removeSession!('stored-delete')) + + expect(deleteSession).toHaveBeenCalledWith('stored-delete', undefined) + expect(sessionStateByRuntimeIdRef.current.has('runtime-delete')).toBe(false) + expect($sessionStates.get()['runtime-delete']).toBeUndefined() + expect($todosBySession.get()['runtime-delete']).toBeUndefined() + expect($todoHistoryBySession.get()['runtime-delete']).toBeUndefined() + expect($todoHistoryBySession.get()['stored-delete']).toBeUndefined() + }) +}) + function BranchHarness({ onReady, requestGateway diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 9f48aeed5a5c..e891d3380aa5 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -57,6 +57,7 @@ import { type TileDock } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' +import { clearSessionTodoHistory, rebuildResumedSessionTodoHistory, rebuildSessionTodoHistory } from '@/store/todos' import { isWatchWindow } from '@/store/windows' import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes' @@ -596,6 +597,7 @@ export function useSessionActions({ setActiveSessionId(cachedRuntimeId) activeSessionIdRef.current = cachedRuntimeId syncSessionStateToView(cachedRuntimeId, cachedViewState) + rebuildResumedSessionTodoHistory(cachedRuntimeId, storedSessionId, cachedViewState.messages) setCurrentCwd(cachedViewState.cwd) setCurrentBranch(cachedViewState.branch) setSessionStartedAt(Date.now()) @@ -683,6 +685,8 @@ export function useSessionActions({ storedSessionId ) + rebuildResumedSessionTodoHistory(cachedRuntimeId, storedSessionId, activatedMessages) + busyRef.current = running setBusy(running) setAwaitingResponse(running) @@ -876,6 +880,7 @@ export function useSessionActions({ }), storedSessionId ) + rebuildResumedSessionTodoHistory(resumed.session_id, storedSessionId, messagesForView) } catch (err) { if (!isCurrentResume()) { return @@ -902,7 +907,10 @@ export function useSessionActions({ ? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages) : $messages.get() - setMessages(reconcileAuthoritativeMessages(fallback.messages, previousMessages)) + const fallbackMessages = reconcileAuthoritativeMessages(fallback.messages, previousMessages) + + setMessages(fallbackMessages) + rebuildSessionTodoHistory(storedSessionId, fallbackMessages) } catch (e) { // Fallback also failed: nothing to paint. Leave whatever messages are // already shown and fall through to arm the resume-failure latch so @@ -1172,12 +1180,16 @@ export function useSessionActions({ // a deleted session. const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) closeSessionTile(storedSessionId) + runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) - if (tiledRuntimeId) { - runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) - sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId) - dropSessionState(tiledRuntimeId) + for (const runtimeId of new Set([closingRuntimeId, tiledRuntimeId].filter(Boolean) as string[])) { + sessionStateByRuntimeIdRef.current.delete(runtimeId) + dropSessionState(runtimeId) } + + // REST fallback can paint history before a runtime id exists, so delete + // its durable-id cache in addition to every runtime-keyed cache above. + clearSessionTodoHistory(storedSessionId) } catch (err) { if (removed) { setSessions(prev => [removed, ...prev]) diff --git a/apps/desktop/src/components/chat/status-section.tsx b/apps/desktop/src/components/chat/status-section.tsx index 161cc6f6a698..798c1f47a47a 100644 --- a/apps/desktop/src/components/chat/status-section.tsx +++ b/apps/desktop/src/components/chat/status-section.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState } from 'react' +import { type ReactNode, useId, useState } from 'react' import { DisclosureCaret } from '@/components/ui/disclosure-caret' @@ -21,11 +21,14 @@ interface StatusSectionProps { */ export function StatusSection({ accessory, children, defaultCollapsed = true, icon, label }: StatusSectionProps) { const [collapsed, setCollapsed] = useState(defaultCollapsed) + const contentId = useId() return (
{accessory &&
{accessory}
}
- {!collapsed &&
{children}
} + {!collapsed && ( +
+ {children} +
+ )}
) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7765c0f7468f..bc06b7a0b64c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1855,6 +1855,9 @@ export const en: Translations = { background: count => `${count} Background`, subagents: count => `${count} Subagent${count === 1 ? '' : 's'}`, todos: (done, total) => `Tasks ${done}/${total}`, + taskHistory: 'Task history', + taskHistoryCompleted: 'Completed', + taskHistoryUnfinished: 'Unfinished', running: 'Running', stop: 'Stop', dismiss: 'Dismiss', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fcc27776201e..b448eebe1cb8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1779,6 +1779,9 @@ export const ja = defineLocale({ background: count => `バックグラウンド ${count} 件`, subagents: count => `サブエージェント ${count} 件`, todos: (done, total) => `タスク ${done}/${total}`, + taskHistory: 'タスク履歴', + taskHistoryCompleted: '完了', + taskHistoryUnfinished: '未完了', running: '実行中', stop: '停止', dismiss: '閉じる', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 40f224f2b242..67c44014cfa7 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1529,6 +1529,9 @@ export interface Translations { background: (count: number) => string subagents: (count: number) => string todos: (done: number, total: number) => string + taskHistory: string + taskHistoryCompleted: string + taskHistoryUnfinished: string running: string stop: string dismiss: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4c543feb986a..12eb3feb7176 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1726,6 +1726,9 @@ export const zhHant = defineLocale({ background: count => `${count} 個背景任務`, subagents: count => `${count} 個子代理`, todos: (done, total) => `任務 ${done}/${total}`, + taskHistory: '任務歷史', + taskHistoryCompleted: '已完成', + taskHistoryUnfinished: '未完成', running: '執行中', stop: '停止', dismiss: '關閉', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 07c01b7395e6..7d5176864a86 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2036,6 +2036,9 @@ export const zh: Translations = { background: count => `${count} 个后台任务`, subagents: count => `${count} 个子代理`, todos: (done, total) => `任务 ${done}/${total}`, + taskHistory: '任务历史', + taskHistoryCompleted: '已完成', + taskHistoryUnfinished: '未完成', running: '运行中', stop: '停止', dismiss: '关闭', diff --git a/apps/desktop/src/lib/todos.test.ts b/apps/desktop/src/lib/todos.test.ts index a19752c7372f..a6abc96c4f4b 100644 --- a/apps/desktop/src/lib/todos.test.ts +++ b/apps/desktop/src/lib/todos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { latestSessionTodos, parseTodos } from './todos' +import { latestSessionTodos, parseTodos, todoHistoryFromTranscript } from './todos' describe('parseTodos', () => { it('parses todo arrays with valid ids, content, and statuses', () => { @@ -78,3 +78,29 @@ describe('latestSessionTodos', () => { expect(latestSessionTodos([])).toBeNull() }) }) + +describe('todoHistoryFromTranscript', () => { + it('keeps every distinct plan from one assistant turn while collapsing status-only updates', () => { + const first = [{ content: 'First task', id: 'same', status: 'pending' as const }] + const completed = [{ content: 'First task', id: 'same', status: 'completed' as const }] + const replacement = [{ content: 'Replacement task', id: 'same', status: 'in_progress' as const }] + + expect( + todoHistoryFromTranscript([ + { + id: 'assistant-shared', + role: 'assistant', + timestamp: 10, + parts: [ + { args: { todos: first }, toolCallId: 'todo-1', toolName: 'todo', type: 'tool-call' }, + { result: { todos: completed }, toolCallId: 'todo-1', toolName: 'todo', type: 'tool-call' }, + { args: { todos: replacement }, toolCallId: 'todo-2', toolName: 'todo', type: 'tool-call' } + ] + } + ]) + ).toEqual([ + { id: 'assistant-shared:todo-2', state: 'unfinished', timestamp: 10, todos: replacement }, + { id: 'assistant-shared:todo-1', state: 'completed', timestamp: 10, todos: completed } + ]) + }) +}) diff --git a/apps/desktop/src/lib/todos.ts b/apps/desktop/src/lib/todos.ts index 6a5d8eea06d9..f1db79a183f9 100644 --- a/apps/desktop/src/lib/todos.ts +++ b/apps/desktop/src/lib/todos.ts @@ -86,3 +86,92 @@ export function latestSessionTodos(messages: readonly { parts?: unknown }[]): nu return null } + +export interface TodoHistorySnapshot { + id: string + state: 'completed' | 'unfinished' + timestamp?: number + todos: TodoItem[] +} + +export interface TodoHistoryMessage { + id?: string + parts?: unknown + role?: string + timestamp?: number +} + +// Runtime status can legitimately advance between otherwise identical todo +// lists. History represents the task plan, so identity is id + content rather +// than a transient status value. +export const todoPlanSignature = (todos: readonly TodoItem[]) => + JSON.stringify(todos.map(({ content, id }) => ({ content, id }))) + +interface TodoHistoryCandidate { + id: string + todos: TodoItem[] +} + +/** Full reconstruction used only where the caller has an authoritative + * transcript replacement (resume/hydration/rewind). Newest snapshots come + * first and repeated plans collapse to their newest occurrence. */ +export function todoHistoryFromTranscript(messages: readonly TodoHistoryMessage[]): TodoHistorySnapshot[] { + const seen = new Set() + const snapshots: TodoHistorySnapshot[] = [] + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + + if (!message || message.role !== 'assistant' || !Array.isArray(message.parts)) { + continue + } + + const candidates: TodoHistoryCandidate[] = [] + const messageSeen = new Set() + + for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex -= 1) { + const part = message.parts[partIndex] + + if (!isRecord(part) || part.type !== 'tool-call' || part.toolName !== 'todo') { + continue + } + + const todos = parseTodos(part.todos) ?? parseTodos(part.result) ?? parseTodos(part.args) + + if (!todos?.length) { + continue + } + + const signature = todoPlanSignature(todos) + + if (messageSeen.has(signature)) { + continue + } + + messageSeen.add(signature) + const toolCallId = typeof part.toolCallId === 'string' && part.toolCallId ? part.toolCallId : `part-${partIndex}` + candidates.push({ id: toolCallId, todos }) + } + + for (const candidate of candidates) { + const signature = todoPlanSignature(candidate.todos) + + if (seen.has(signature)) { + continue + } + + seen.add(signature) + const messageId = message.id || `assistant-turn-${index}` + snapshots.push({ + id: candidates.length > 1 ? `${messageId}:${candidate.id}` : messageId, + state: candidate.todos.some(todo => todo.status === 'pending' || todo.status === 'in_progress') + ? 'unfinished' + : 'completed', + timestamp: message.timestamp, + todos: [...candidate.todos] + }) + } + } + + return snapshots +} diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 869433436bbd..6d4ad5405577 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -28,6 +28,7 @@ import { revealTreePane } from '@/components/pane-shell/tree/store' import { readJson, writeJson } from '@/lib/storage' +import { clearAllSessionTodoState, clearSessionTodoHistory, clearSessionTodos } from '@/store/todos' import { $activeGatewayProfile, normalizeProfileKey } from './profile' import { @@ -172,6 +173,8 @@ export function dropSessionState(runtimeId: string) { // a just-finished session's row survives merge eviction even if its tile or // cached runtime is dropped in the meantime. clearWatchdog(runtimeId) + clearSessionTodos(runtimeId) + clearSessionTodoHistory(runtimeId) const current = $sessionStates.get() @@ -195,6 +198,7 @@ export function clearAllSessionStates() { sessionWatchdogTimers.clear() settledExpiry.clear() + clearAllSessionTodoState() $sessionStates.set({}) } diff --git a/apps/desktop/src/store/todos.test.ts b/apps/desktop/src/store/todos.test.ts index 1f1abf2e17b3..3683e826ed06 100644 --- a/apps/desktop/src/store/todos.test.ts +++ b/apps/desktop/src/store/todos.test.ts @@ -3,15 +3,133 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TodoItem } from '@/lib/todos' import { + $todoHistoryBySession, $todosBySession, clearActiveSessionTodos, + clearAllSessionTodoState, clearSessionTodos, + finalizeSessionTodoSnapshot, + rebuildSessionTodoHistory, setSessionTodos, todosForHydration } from './todos' const todo = (id: string, status: TodoItem['status']): TodoItem => ({ content: `task ${id}`, id, status }) +const todoMessage = (id: string, todos: TodoItem[], timestamp: number) => ({ + id, + role: 'assistant', + timestamp, + parts: [{ args: { todos }, toolCallId: `call-${id}`, toolName: 'todo', type: 'tool-call' }] +}) + +describe('persistent task history hydration', () => { + afterEach(() => clearAllSessionTodoState()) + + it('reconstructs completed and unfinished snapshots once from an authoritative transcript', () => { + rebuildSessionTodoHistory('s1', [ + todoMessage('turn-1', [todo('a', 'completed')], 10), + { id: 'user-2', parts: [], role: 'user' }, + todoMessage('turn-2', [todo('b', 'in_progress')], 20) + ]) + + expect($todoHistoryBySession.get().s1).toEqual([ + { id: 'turn-2', state: 'unfinished', timestamp: 20, todos: [todo('b', 'in_progress')] }, + { id: 'turn-1', state: 'completed', timestamp: 10, todos: [todo('a', 'completed')] } + ]) + }) + + it('preserves the history references when reconstruction is semantically unchanged', () => { + const messages = [todoMessage('turn-1', [todo('a', 'completed')], 10)] + + rebuildSessionTodoHistory('s1', messages) + const historyMap = $todoHistoryBySession.get() + const sessionHistory = historyMap.s1 + + rebuildSessionTodoHistory('s1', messages) + + expect($todoHistoryBySession.get()).toBe(historyMap) + expect($todoHistoryBySession.get().s1).toBe(sessionHistory) + }) + + it('finalizes from the live list and replaces a duplicate plan even when status changed', () => { + rebuildSessionTodoHistory('s1', [todoMessage('old', [todo('a', 'in_progress')], 10)]) + setSessionTodos('s1', [todo('a', 'completed')], 'turn-complete') + + finalizeSessionTodoSnapshot('s1', 'turn-complete', 20) + + expect($todoHistoryBySession.get().s1).toEqual([ + { id: 'turn-complete', state: 'completed', timestamp: 20, todos: [todo('a', 'completed')] } + ]) + }) + + it('supports edit + append without reviving the abandoned tail', () => { + rebuildSessionTodoHistory('s1', [ + todoMessage('keep', [todo('keep', 'completed')], 10), + todoMessage('stale-tail', [todo('stale', 'completed')], 20) + ]) + + rebuildSessionTodoHistory('s1', [todoMessage('keep', [todo('keep', 'completed')], 10)]) + setSessionTodos('s1', [todo('appended', 'in_progress')], 'replacement') + finalizeSessionTodoSnapshot('s1', 'replacement', 40) + + expect($todoHistoryBySession.get().s1.map(snapshot => snapshot.id)).toEqual(['replacement', 'keep']) + }) + + it('supports edit + tail replacement and rewind', () => { + rebuildSessionTodoHistory('s1', [ + todoMessage('keep', [todo('keep', 'completed')], 10), + todoMessage('stale-tail', [todo('stale', 'completed')], 20) + ]) + rebuildSessionTodoHistory('s1', [ + todoMessage('keep', [todo('keep', 'completed')], 10), + todoMessage('replacement', [todo('replacement', 'in_progress')], 40) + ]) + expect($todoHistoryBySession.get().s1.map(snapshot => snapshot.id)).toEqual(['replacement', 'keep']) + + rebuildSessionTodoHistory('s1', [todoMessage('keep', [todo('keep', 'completed')], 10)]) + expect($todoHistoryBySession.get().s1.map(snapshot => snapshot.id)).toEqual(['keep']) + }) + + it('isolates runtime sessions even when message ids and message references collide', () => { + const shared = todoMessage('same-message-id', [todo('same-todo-id', 'completed')], 10) + + rebuildSessionTodoHistory('runtime-a', [shared]) + rebuildSessionTodoHistory('runtime-b', [shared]) + setSessionTodos( + 'runtime-a', + [{ ...todo('same-todo-id', 'in_progress'), content: 'changed only in A' }], + 'same-message-id' + ) + finalizeSessionTodoSnapshot('runtime-a', 'same-message-id', 20) + + expect($todoHistoryBySession.get()['runtime-a'][0]?.todos[0]?.content).toBe('changed only in A') + expect($todoHistoryBySession.get()['runtime-b']).toEqual([ + { id: 'same-message-id', state: 'completed', timestamp: 10, todos: [todo('same-todo-id', 'completed')] } + ]) + }) + + it('clears all live and historical todo state at the session-cache boundary', () => { + rebuildSessionTodoHistory('s1', [todoMessage('one', [todo('one', 'completed')], 10)]) + rebuildSessionTodoHistory('s2', [todoMessage('two', [todo('two', 'completed')], 20)]) + setSessionTodos('s1', [todo('live', 'in_progress')]) + + clearAllSessionTodoState() + + expect($todoHistoryBySession.get()).toEqual({}) + expect($todosBySession.get()).toEqual({}) + }) + + it('retires authoritative todo ownership when stop clears the visual list', () => { + setSessionTodos('s1', [todo('a', 'in_progress')], 'turn-a') + + clearSessionTodos('s1') + finalizeSessionTodoSnapshot('s1', 'turn-a', 20) + + expect($todoHistoryBySession.get().s1).toBeUndefined() + }) +}) + describe('setSessionTodos finished-list auto-clear', () => { beforeEach(() => { vi.useFakeTimers() @@ -40,6 +158,18 @@ describe('setSessionTodos finished-list auto-clear', () => { expect($todosBySession.get().s1).toBeUndefined() }) + it('keeps turn ownership after visual linger so a late completion still finalizes history', () => { + setSessionTodos('s1', [todo('a', 'completed')], 'turn-a') + + vi.advanceTimersByTime(5_000) + finalizeSessionTodoSnapshot('s1', 'turn-a', 20) + + expect($todosBySession.get().s1).toBeUndefined() + expect($todoHistoryBySession.get().s1).toEqual([ + { id: 'turn-a', state: 'completed', timestamp: 20, todos: [todo('a', 'completed')] } + ]) + }) + it('cancels the pending clear when a new active list arrives', () => { setSessionTodos('s1', [todo('a', 'completed')]) vi.advanceTimersByTime(2_000) diff --git a/apps/desktop/src/store/todos.ts b/apps/desktop/src/store/todos.ts index 31aed642f38c..f657deec0c02 100644 --- a/apps/desktop/src/store/todos.ts +++ b/apps/desktop/src/store/todos.ts @@ -1,6 +1,12 @@ import { atom } from 'nanostores' -import type { TodoItem } from '@/lib/todos' +import { + todoHistoryFromTranscript, + type TodoHistoryMessage, + type TodoHistorySnapshot, + type TodoItem, + todoPlanSignature +} from '@/lib/todos' /** * Live todo list per runtime session, rendered by the composer status stack @@ -13,6 +19,107 @@ import type { TodoItem } from '@/lib/todos' */ export const $todosBySession = atom>({}) +/** Transcript-derived task snapshots keyed by runtime session. This atom is + * updated only at known mutation boundaries, never while plain text streams. */ +export const $todoHistoryBySession = atom>({}) + +function sameTodoHistory(a: readonly TodoHistorySnapshot[], b: readonly TodoHistorySnapshot[]): boolean { + return ( + a.length === b.length && + a.every((snapshot, index) => { + const other = b[index] + + return ( + other !== undefined && + snapshot.id === other.id && + snapshot.state === other.state && + snapshot.timestamp === other.timestamp && + snapshot.todos.length === other.todos.length && + snapshot.todos.every( + (todo, todoIndex) => + todo.id === other.todos[todoIndex]?.id && + todo.content === other.todos[todoIndex]?.content && + todo.status === other.todos[todoIndex]?.status + ) + ) + }) + ) +} + +export function rebuildSessionTodoHistory(sid: string, messages: readonly TodoHistoryMessage[]) { + if (!sid) { + return + } + + const history = $todoHistoryBySession.get() + const next = todoHistoryFromTranscript(messages) + + if (history[sid] && sameTodoHistory(history[sid], next)) { + return + } + + $todoHistoryBySession.set({ ...history, [sid]: next }) +} + +export function rebuildResumedSessionTodoHistory( + runtimeId: string, + storedSessionId: string, + messages: readonly TodoHistoryMessage[] +) { + if (runtimeId !== storedSessionId) { + clearSessionTodoHistory(storedSessionId) + } + + rebuildSessionTodoHistory(runtimeId, messages) +} + +export function clearSessionTodoHistory(sid: string) { + const history = $todoHistoryBySession.get() + + if (!(sid in history)) { + return + } + + const { [sid]: _drop, ...rest } = history + $todoHistoryBySession.set(rest) +} + +/** Finalize directly from the session's authoritative live todo turn. The + * separately rendered list may remain during its 4s linger, but only the turn + * that produced it may commit a snapshot. */ +interface LiveTodoTurn { + ownerId: string + todos: TodoItem[] +} + +const liveTodoTurns = new Map() + +export function finalizeSessionTodoSnapshot(sid: string, id: string, timestamp = Math.floor(Date.now() / 1_000)) { + const live = liveTodoTurns.get(sid) + + if (!live || live.ownerId !== id) { + return + } + + liveTodoTurns.delete(sid) + const todos = live.todos + + const signature = todoPlanSignature(todos) + const previous = $todoHistoryBySession.get()[sid] ?? [] + + const snapshot: TodoHistorySnapshot = { + id, + state: todoListActive(todos) ? 'unfinished' : 'completed', + timestamp, + todos: [...todos] + } + + $todoHistoryBySession.set({ + ...$todoHistoryBySession.get(), + [sid]: [snapshot, ...previous.filter(item => todoPlanSignature(item.todos) !== signature)] + }) +} + export const todoListActive = (todos: readonly TodoItem[]) => todos.some(t => t.status === 'pending' || t.status === 'in_progress') @@ -42,7 +149,29 @@ function cancelScheduledClear(sid: string) { } } -export function setSessionTodos(sid: string, todos: TodoItem[]) { +function dismissSessionTodos(sid: string) { + const map = $todosBySession.get() + + if (!(sid in map)) { + return + } + + const { [sid]: _drop, ...rest } = map + $todosBySession.set(rest) +} + +export function clearAllSessionTodoState() { + for (const timer of clearTimers.values()) { + clearTimeout(timer) + } + + clearTimers.clear() + liveTodoTurns.clear() + $todosBySession.set({}) + $todoHistoryBySession.set({}) +} + +export function setSessionTodos(sid: string, todos: TodoItem[], ownerId?: string | null) { if (!sid) { return } @@ -50,12 +179,16 @@ export function setSessionTodos(sid: string, todos: TodoItem[]) { cancelScheduledClear(sid) $todosBySession.set({ ...$todosBySession.get(), [sid]: todos }) + if (ownerId) { + liveTodoTurns.set(sid, { ownerId, todos: [...todos] }) + } + if (!todoListActive(todos)) { clearTimers.set( sid, setTimeout(() => { clearTimers.delete(sid) - clearSessionTodos(sid) + dismissSessionTodos(sid) }, FINISHED_LINGER_MS) ) } @@ -63,15 +196,16 @@ export function setSessionTodos(sid: string, todos: TodoItem[]) { export function clearSessionTodos(sid: string) { cancelScheduledClear(sid) + liveTodoTurns.delete(sid) + dismissSessionTodos(sid) +} - const map = $todosBySession.get() +export function releaseSessionTodoTurn(sid: string, ownerId: string | null | undefined) { + const live = liveTodoTurns.get(sid) - if (!(sid in map)) { - return + if (live && live.ownerId === ownerId) { + liveTodoTurns.delete(sid) } - - const { [sid]: _drop, ...rest } = map - $todosBySession.set(rest) } // Drop a still-active todo list (any pending/in_progress item) — used at turn From 4d28acdc341028552e821a9937c2616079a53f58 Mon Sep 17 00:00:00 2001 From: null-runner Date: Mon, 20 Jul 2026 15:59:05 +0200 Subject: [PATCH 2/2] fix(desktop): make task history coherent across error, stop, and resume Completed-turn finalize committed task plans to history, but error and stop paths only dropped the live turn's ownership. A later transcript rebuild (resume/hydration/rewind) then reconstructed those same plans from the persisted turn, so an errored or stopped task list was absent immediately yet reappeared after resume. The transcript cannot tell an errored/stopped turn apart from a completed-but-unfinished one, so the only coherent semantic is to commit on every turn end: error and stop now finalize the snapshot (as completion already did) before clearing the live list, matching what the rebuild produces. Also harden the surrounding edges surfaced by review: - finalizeSessionTodoSnapshot accepts a nullable id and early-returns, so callers drop the `?? turn-${Date.now()}` fallback that could never match a real turn owner and only masked a missing streamId. - StatusSection sets aria-controls only while expanded; the body is unmounted when collapsed, so the attribute no longer dangles at a missing id. - todoHistoryFromTranscript always ids snapshots `messageId:toolCallId` instead of only when a turn carried multiple plans, so the same snapshot's id no longer flips across rebuilds. --- .../status-stack/task-history.test.tsx | 11 +++- .../app/chat/session-tile-actions.test.tsx | 2 +- .../hooks/use-session-tile-delegate.test.tsx | 2 +- .../hooks/use-message-stream/gateway-event.ts | 13 ++-- .../use-message-stream/todo-cleanup.test.tsx | 9 ++- .../hooks/use-prompt-actions/index.test.tsx | 5 +- .../session/hooks/use-prompt-actions/index.ts | 9 ++- .../src/components/chat/status-section.tsx | 4 +- apps/desktop/src/lib/todos.ts | 6 +- apps/desktop/src/store/todos.test.ts | 64 +++++++++++++++++-- apps/desktop/src/store/todos.ts | 21 +++--- 11 files changed, 113 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx index 0685cf130fed..6467d7ed5aae 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx @@ -67,8 +67,9 @@ describe('composer task history', () => { expect(screen.getByText('Live task')).toBeTruthy() const historyButton = screen.getByRole('button', { name: 'Task history' }) expect(historyButton.getAttribute('aria-expanded')).toBe('false') - const controlledId = historyButton.getAttribute('aria-controls') ?? '' - expect(controlledId).toBeTruthy() + // Collapsed: the body is unmounted, so aria-controls must not dangle at a + // missing id. + expect(historyButton.getAttribute('aria-controls')).toBeNull() act(() => vi.advanceTimersByTime(4_000)) @@ -76,8 +77,12 @@ describe('composer task history', () => { expect(screen.getByRole('button', { name: 'Task history' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Task history' })) + const expandedButton = screen.getByRole('button', { name: 'Task history' }) expect(screen.getByText('Historical task')).toBeTruthy() - expect(screen.getByRole('button', { name: 'Task history' }).getAttribute('aria-expanded')).toBe('true') + expect(expandedButton.getAttribute('aria-expanded')).toBe('true') + // Expanded: aria-controls now points at the mounted region. + const controlledId = expandedButton.getAttribute('aria-controls') ?? '' + expect(controlledId).toBeTruthy() expect(view.container.querySelector(`[id="${controlledId}"]`)).toBeTruthy() }) diff --git a/apps/desktop/src/app/chat/session-tile-actions.test.tsx b/apps/desktop/src/app/chat/session-tile-actions.test.tsx index 84e09ce21acc..2df335e5daa9 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.test.tsx +++ b/apps/desktop/src/app/chat/session-tile-actions.test.tsx @@ -133,7 +133,7 @@ describe('useSessionTileActions task-history rollback', () => { const expectOriginalHistory = () => { expect(stateRef.current.messages).toEqual(originalMessages) - expect($todoHistoryBySession.get()[RUNTIME_ID]?.map(snapshot => snapshot.id)).toEqual(['a2', 'a1']) + expect($todoHistoryBySession.get()[RUNTIME_ID]?.map(snapshot => snapshot.id)).toEqual(['a2:call-a2', 'a1:call-a1']) } it('restores both transcript and task history when tile reload fails', async () => { diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx index 981d56856b25..51a02f3f01e1 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.tsx @@ -171,7 +171,7 @@ describe('useSessionTileDelegate task-history hydration', () => { await waitFor(() => expect(sessionTileDelegate()).not.toBeNull()) await act(async () => sessionTileDelegate()!.resumeTile('stored-tile-a')) - expect($todoHistoryBySession.get()['runtime-tile-a']?.[0]?.id).toBe('runtime-newer') + expect($todoHistoryBySession.get()['runtime-tile-a']?.[0]?.id).toBe('runtime-newer:runtime-todo') expect($todoHistoryBySession.get()['stored-tile-a']).toBeUndefined() }) }) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 92af69f34c20..bd236400ed6e 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -43,7 +43,7 @@ import { setYoloActive } from '@/store/session' import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' -import { clearActiveSessionTodos, finalizeSessionTodoSnapshot, releaseSessionTodoTurn } from '@/store/todos' +import { clearActiveSessionTodos, finalizeSessionTodoSnapshot } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' @@ -464,10 +464,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // Turn ended without a final `todo` update — finalize the last live // state first, then drop a still-unfinished live list. Finished lists // keep their 4s linger while history remains reachable. - finalizeSessionTodoSnapshot( - sessionId, - sessionStateByRuntimeIdRef.current.get(sessionId)?.streamId ?? `turn-${Date.now()}` - ) + finalizeSessionTodoSnapshot(sessionId, sessionStateByRuntimeIdRef.current.get(sessionId)?.streamId) clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) @@ -761,7 +758,11 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (sessionId) { clearAllPrompts(sessionId) clearClarifyRequest(undefined, sessionId) - releaseSessionTodoTurn(sessionId, sessionStateByRuntimeIdRef.current.get(sessionId)?.streamId) + // A turn that errors out still produced its plan — commit it to history + // (same as message.complete) so the task list stays reachable and the + // live state matches what a later transcript rebuild reconstructs from + // the persisted turn. Then drop a still-unfinished live list. + finalizeSessionTodoSnapshot(sessionId, sessionStateByRuntimeIdRef.current.get(sessionId)?.streamId) clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) compactedTurnRef.current.delete(sessionId) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx index b6f0fa9ef231..ea3a2cd0de29 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx @@ -173,7 +173,7 @@ describe('useMessageStream turn-end todo cleanup', () => { expect($todosBySession.get()[SID]).toBeUndefined() }) - it('retires a finished todo turn on error without cancelling its visual linger', async () => { + it('commits a finished todo turn to history on error while keeping its visual linger', async () => { await mountStream() act(() => handleEvent!({ @@ -184,10 +184,15 @@ describe('useMessageStream turn-end todo cleanup', () => { ) act(() => handleEvent!({ payload: { message: 'boom' }, session_id: SID, type: 'error' })) + + // Finished list still lingers visually, but the plan is now in history so it + // stays reachable and matches what a later transcript rebuild reconstructs. expect($todosBySession.get()[SID]).toEqual([todo('a', 'completed')]) + expect($todoHistoryBySession.get()[SID]).toMatchObject([{ state: 'completed', todos: [todo('a', 'completed')] }]) + // The error already consumed turn ownership, so a trailing complete is a no-op. complete() - expect($todoHistoryBySession.get()[SID]).toBeUndefined() + expect($todoHistoryBySession.get()[SID]).toMatchObject([{ state: 'completed', todos: [todo('a', 'completed')] }]) }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 1e38ce1913a9..2b7fd7f597bc 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -832,7 +832,10 @@ describe('usePromptActions reload task-history rollback', () => { await handle!.reloadFromMessage('a1') expect(lastState.messages).toEqual(messages) - expect($todoHistoryBySession.get()[RUNTIME_SESSION_ID]?.map(snapshot => snapshot.id)).toEqual(['a2', 'a1']) + expect($todoHistoryBySession.get()[RUNTIME_SESSION_ID]?.map(snapshot => snapshot.id)).toEqual([ + 'a2:call-a2', + 'a1:call-a1' + ]) }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 66109d0a1990..8da4c95b16e6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -32,7 +32,7 @@ import { setTurnStartedAt } from '@/store/session' import { clearSessionSubagents } from '@/store/subagents' -import { clearSessionTodos, rebuildSessionTodoHistory } from '@/store/todos' +import { clearSessionTodos, finalizeSessionTodoSnapshot, rebuildSessionTodoHistory } from '@/store/todos' import type { ClientSessionState, @@ -540,8 +540,11 @@ export function usePromptActions({ return } + let stoppedTurnStreamId: null | string = null + updateSessionState(sessionId, state => { const streamId = state.streamId + stoppedTurnStreamId = streamId const messages = finalizeInterruptedMessages(state.messages, streamId) return { @@ -557,6 +560,10 @@ export function usePromptActions({ } }) + // A stopped turn still produced its plan — commit it to history (same as a + // completed or errored turn) before dropping the live list, so it stays + // reachable and matches what a later transcript rebuild reconstructs. + finalizeSessionTodoSnapshot(sessionId, stoppedTurnStreamId) clearSessionTodos(sessionId) clearSessionSubagents(sessionId) resetSessionBackground(sessionId) diff --git a/apps/desktop/src/components/chat/status-section.tsx b/apps/desktop/src/components/chat/status-section.tsx index 798c1f47a47a..7ccada2b7680 100644 --- a/apps/desktop/src/components/chat/status-section.tsx +++ b/apps/desktop/src/components/chat/status-section.tsx @@ -27,7 +27,9 @@ export function StatusSection({ accessory, children, defaultCollapsed = true, ic