From 33923de4e7106f0ffdbbb4677a3832229ab0575d Mon Sep 17 00:00:00 2001 From: marcelormendes Date: Thu, 13 Aug 2026 19:23:05 -0400 Subject: [PATCH] Release v0.1.23 --- lessons.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/main.ts | 7 + src/main/herdr/engine.test.ts | 41 ++ src/main/herdr/engine.ts | 7 + src/preload.ts | 5 + src/renderer/App.tsx | 1 + ...onversationChatPanel.presentation.test.tsx | 176 +++++++ .../chat/ConversationChatPanel.test.tsx | 437 +++++++++++++++++- src/renderer/chat/ConversationChatPanel.tsx | 259 ++++++++++- src/renderer/chat/conversation-model.test.ts | 15 + src/renderer/chat/conversation-model.ts | 14 +- src/shared/desktop-api.ts | 1 + src/shared/ipc.ts | 1 + 15 files changed, 945 insertions(+), 26 deletions(-) diff --git a/lessons.md b/lessons.md index b5e63ef..f0f88b7 100644 --- a/lessons.md +++ b/lessons.md @@ -10,3 +10,4 @@ - Never live-handoff, stop, replace, or start a server on the user's active Herdr config/socket while diagnosing or smoke-testing Chat, even if a previous handoff appeared successful. Use a separate `HERDR_CONFIG_PATH`, data directory, socket, and named test session; runtime mutation requires explicit user consent. - Treat the TODO as session-scoped state that survives resumed turns; pin the latest plan above Working even when its `plan_update` belongs to the immediately preceding interrupted turn. - Preserve the user's explicitly requested UI copy and casing while fixing behavior; do not silently reinterpret a concrete format request based on incidental typography. +- For live Chat TODOs, treat every newest/newer read as an incomplete drain until a terminal page succeeds; metadata must trigger retry on any revision mismatch, and history backfill must preserve the live cursor. diff --git a/package-lock.json b/package-lock.json index 9cab265..b172636 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "drover", - "version": "0.1.22", + "version": "0.1.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "drover", - "version": "0.1.22", + "version": "0.1.23", "license": "MIT", "dependencies": { "@radix-ui/react-alert-dialog": "1.1.23", diff --git a/package.json b/package.json index 3f4a35d..514432e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "drover", "productName": "Drover", - "version": "0.1.22", + "version": "0.1.23", "description": "A native workspace for Herdr-powered agents, terminals, worktrees, and live sessions.", "main": ".vite/build/main.js", "private": true, diff --git a/src/main.ts b/src/main.ts index 30dea16..1c4e151 100644 --- a/src/main.ts +++ b/src/main.ts @@ -313,6 +313,13 @@ function registerIpcHandlers(): void { } return engine.conversationRead(parseConversationReadRequest(candidate)); }); + ipcMain.handle(IPC_CHANNELS.conversationMetadata, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + if (demoMode) { + throw new Error('Structured Chat is unavailable in demo mode.'); + } + return engine.conversationMetadata(parseConversationReadRequest(candidate)); + }); ipcMain.handle(IPC_CHANNELS.conversationPrompt, async (event, candidate: unknown) => { assertTrustedSender(event.senderFrame?.url); diff --git a/src/main/herdr/engine.test.ts b/src/main/herdr/engine.test.ts index 4ff2487..47bf571 100644 --- a/src/main/herdr/engine.test.ts +++ b/src/main/herdr/engine.test.ts @@ -728,6 +728,47 @@ describe('HerdrEngine.execute', () => { }); }); +describe('HerdrEngine.conversationMetadata', () => { + it('uses the metadata endpoint without allocating a conversation cursor', async () => { + const runner = createRunner(async () => ({ + stdout: JSON.stringify(runningStatus), + stderr: '', + })); + const requestClient: HerdrRequestClient = { + request: vi.fn(async () => ({ + type: 'agent_conversation_read', + read: { + type: 'page', + page: { + provider: 'codex', + session: { id: 'session-1' }, + capability: { availability: 'supported', reason: 'ready' }, + items: [], + has_older: false, + revision: 12, + reader_generation: 'generation-1', + }, + }, + })), + }; + const engine = new HerdrEngine( + runner, + { launch: vi.fn() }, + async () => undefined, + requestClient, + ); + + const result = await engine.conversationMetadata({ target: 'w1:p1' }); + + expect(requestClient.request).toHaveBeenCalledWith( + '/tmp/herdr.sock', + 'agent.conversation.metadata', + { target: 'w1:p1' }, + ); + expect(result).toMatchObject({ type: 'page', page: { revision: 12 } }); + }); +}); + describe('HerdrEngine.query', () => { it.each([ { diff --git a/src/main/herdr/engine.ts b/src/main/herdr/engine.ts index a95c48b..f80dc27 100644 --- a/src/main/herdr/engine.ts +++ b/src/main/herdr/engine.ts @@ -706,6 +706,13 @@ export class HerdrEngine { }); return decodeConversationReadResult(result); } + async conversationMetadata(request: ConversationReadRequest): Promise { + const socket = await this.conversationSocket(); + const result = await this.requestClient.request(socket, 'agent.conversation.metadata', { + target: request.target, + }); + return decodeConversationReadResult(result); + } async conversationPrompt(request: ConversationPromptRequest): Promise { const socket = await this.conversationSocket(); diff --git a/src/preload.ts b/src/preload.ts index 04a479c..c34c92a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -50,6 +50,11 @@ const api: DroverApi = { conversation: Object.freeze({ read: (request: ConversationReadRequest) => ipcRenderer.invoke(IPC_CHANNELS.conversationRead, request) as Promise, + metadata: (request: ConversationReadRequest) => + ipcRenderer.invoke( + IPC_CHANNELS.conversationMetadata, + request, + ) as Promise, prompt: (request: ConversationPromptRequest) => ipcRenderer.invoke(IPC_CHANNELS.conversationPrompt, request) as Promise, respond: (request: ConversationRespondRequest) => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 48d5d91..bae4eb6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1009,6 +1009,7 @@ function PaneStage({ onViewChange(item.pane_id, 'terminal')} pane={item} + visible={view === 'chat'} /> ) : null} diff --git a/src/renderer/chat/ConversationChatPanel.presentation.test.tsx b/src/renderer/chat/ConversationChatPanel.presentation.test.tsx index eca3670..c3cff96 100644 --- a/src/renderer/chat/ConversationChatPanel.presentation.test.tsx +++ b/src/renderer/chat/ConversationChatPanel.presentation.test.tsx @@ -357,6 +357,7 @@ describe('ConversationChatPanel turn projection', () => { expect(setupResult.read).toHaveBeenLastCalledWith({ target: 'w1:p1', direction: 'newer', + limit: 256, cursor: 'cursor-1', }); @@ -393,6 +394,181 @@ describe('ConversationChatPanel turn projection', () => { await waitFor(() => expect(screen.queryByText('Mirror terminal TODO')).not.toBeInTheDocument()); expect(document.querySelector('[data-slot="active-plan"]')).toBeNull(); }); + it('requests the full conversation tail so an active TODO outside the default page remains visible', async () => { + const started: ConversationItem = { + id: 'started-tail', + sequence: 1, + provider: 'omp', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'turn_state', + state: 'started', + started_ms: Date.now(), + }; + const plan: ConversationItem = { + id: 'plan-tail', + sequence: 2, + provider: 'omp', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [ + { label: 'Recover the active TODO', status: 'active' }, + { label: 'Keep it synchronized', status: 'pending' }, + ], + }; + const read = vi.fn(); + read.mockImplementation(async (request) => + page(request.limit === 256 ? [started, plan] : [started]), + ); + + setup(page([started]), { read }); + + expect(await screen.findByText('Recover the active TODO')).toBeVisible(); + expect(read).toHaveBeenCalledWith({ + target: 'w1:p1', + direction: 'newest', + limit: 256, + }); + }); + + it('backfills older pages when the active TODO is outside the newest page', async () => { + const initialItems: ConversationItem[] = [ + ...Array.from({ length: 255 }, (_, index) => + assistant(index + 2, 'final', `tail item ${index}`), + ), + { + id: 'started-tail', + sequence: 257, + provider: 'pi', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'turn_state', + state: 'started', + }, + ]; + const plan: ConversationItem = { + id: 'plan-outside-tail', + sequence: 1, + provider: 'pi', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [ + { label: 'Recover an old TODO', status: 'active' }, + { label: 'Keep it visible', status: 'pending' }, + ], + }; + const read = vi + .fn() + .mockImplementation(async (request) => + request.direction === 'older' + ? page([plan]) + : page(initialItems, { previousCursor: 'older-plan', nextCursor: 'newer-tail' }), + ); + + setup(page(initialItems, { previousCursor: 'older-plan', nextCursor: 'newer-tail' }), { read }); + + expect((await screen.findAllByText('Recover an old TODO')).length).toBeGreaterThan(0); + expect(read).toHaveBeenNthCalledWith(2, { + target: 'w1:p1', + direction: 'older', + limit: 256, + cursor: 'older-plan', + }); + }); + it('refreshes the live cursor while backfilling an old TODO boundary', async () => { + const started: ConversationItem = { + id: 'started-long-history', + sequence: 1, + provider: 'pi', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'turn_state', + state: 'started', + }; + const plan: ConversationItem = { + id: 'plan-after-long-history', + sequence: 65, + provider: 'pi', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [{ label: 'Recover TODO beyond sixty-four pages', status: 'active' }], + }; + let olderCalls = 0; + let newestCalls = 0; + const read = vi + .fn() + .mockImplementation(async (request) => { + if (request.direction !== 'older') { + newestCalls += 1; + return page([started], { + previousCursor: newestCalls === 1 ? 'history-0' : 'history-refresh', + nextCursor: newestCalls === 1 ? 'live-0' : 'live-refresh', + }); + } + olderCalls += 1; + return olderCalls === 65 + ? page([plan]) + : page([], { previousCursor: `history-${olderCalls}` }); + }); + + setup(page([started], { previousCursor: 'history-0', nextCursor: 'live-0' }), { read }); + + expect(await screen.findByText('Recover TODO beyond sixty-four pages')).toBeVisible(); + expect(olderCalls).toBe(65); + expect(newestCalls).toBeGreaterThan(1); + }); + it('hydrates an active TODO when only a running tool is in the newest page', async () => { + const runningTool: ConversationItem = { + id: 'running-tool-tail', + sequence: 256, + provider: 'pi', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'tool_activity', + action: 'long-running-command', + label: 'running', + status: 'running', + }; + const initialItems = [ + ...Array.from({ length: 255 }, (_, index) => + assistant(index + 1, 'final', `history item ${index}`), + ), + runningTool, + ]; + const plan: ConversationItem = { + id: 'plan-running-tool', + sequence: 1, + provider: 'pi', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [{ label: 'Recover TODO from running tool', status: 'active' }], + }; + const read = vi + .fn() + .mockImplementation(async (request) => + request.direction === 'older' + ? page([plan]) + : page(initialItems, { previousCursor: 'older-running', nextCursor: 'live-running' }), + ); + + setup(page(initialItems, { previousCursor: 'older-running', nextCursor: 'live-running' }), { + read, + }); + + expect((await screen.findAllByText('Recover TODO from running tool')).length).toBeGreaterThan( + 0, + ); + expect(read).toHaveBeenNthCalledWith(2, { + target: 'w1:p1', + direction: 'older', + limit: 256, + cursor: 'older-running', + }); + }); it('preserves the pane working duration across chat view remounts', async () => { let now = 1_800_000_000_000; diff --git a/src/renderer/chat/ConversationChatPanel.test.tsx b/src/renderer/chat/ConversationChatPanel.test.tsx index b53c622..cd41b73 100644 --- a/src/renderer/chat/ConversationChatPanel.test.tsx +++ b/src/renderer/chat/ConversationChatPanel.test.tsx @@ -33,7 +33,11 @@ function item(sequence: number): ConversationItem { }; } -function page(items: ConversationItem[], nextCursor: string): ConversationReadResult { +function page( + items: ConversationItem[], + nextCursor: string, + previousCursor?: string, +): ConversationReadResult { return { type: 'page', page: { @@ -42,13 +46,28 @@ function page(items: ConversationItem[], nextCursor: string): ConversationReadRe capability: { availability: 'supported', reason: 'ready' }, items, next_cursor: nextCursor, - previous_cursor: 'older-1', - has_older: true, + previous_cursor: previousCursor, + has_older: previousCursor !== undefined, revision: items.at(-1)?.sequence ?? 0, reader_generation: 'generation-1', }, }; } +function pageEnd(items: ConversationItem[]): ConversationReadResult { + const result = page(items, 'terminal'); + if (result.type !== 'page') { + return result; + } + return { + type: 'page', + page: { + ...result.page, + next_cursor: undefined, + previous_cursor: undefined, + has_older: false, + }, + }; +} beforeEach(() => { pruneConversationChatState([]); @@ -101,17 +120,207 @@ describe('ConversationChatPanel', () => { expect(read).toHaveBeenNthCalledWith(2, { target: 'w1:p1', direction: 'newer', + limit: 256, cursor: 'cursor-1', }); expect(read).toHaveBeenNthCalledWith(3, { target: 'w1:p1', direction: 'newer', + limit: 256, cursor: 'cursor-2', }); expect(screen.getByText('answer 1')).toBeInTheDocument(); expect(screen.getByText('answer 2')).toBeInTheDocument(); expect(screen.getByText('answer 3')).toBeInTheDocument(); }); + it('retries a failed newer drain even when metadata revision is unchanged', async () => { + vi.useFakeTimers(); + try { + const started: ConversationItem = { + id: 'started', + sequence: 1, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'turn_state', + state: 'started', + }; + const plan: ConversationItem = { + id: 'plan', + sequence: 2, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [{ label: 'Retry the missing TODO', status: 'active' }], + }; + const read = vi + .fn() + .mockResolvedValueOnce(page([started], 'cursor-1')) + .mockResolvedValueOnce(page([plan], 'cursor-2')) + .mockRejectedValueOnce(new Error('temporary read failure')) + .mockResolvedValueOnce(pageEnd([plan])); + const metadata = vi + .fn>() + .mockResolvedValue(page([plan], 'metadata')); + let onEvent: ((event: { event: string; data: Record }) => void) | undefined; + window.herdr = { + conversation: { + read, + metadata, + prompt: vi.fn(), + respond: vi.fn(), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + }, + onSessionEvent: vi.fn((callback) => { + onEvent = callback; + return () => undefined; + }), + } as unknown as Window['herdr']; + + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + act(() => { + onEvent?.({ + event: 'agent.conversation_changed', + data: { + pane_id: 'w1:p1', + workspace_id: 'w1', + session: { id: 'session-1' }, + reader_generation: 'generation-1', + revision: 2, + reset_required: false, + }, + }); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(read).toHaveBeenCalledTimes(3); + + await act(async () => { + vi.advanceTimersByTime(1_500); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(metadata).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenNthCalledWith(4, { + target: 'w1:p1', + direction: 'newer', + limit: 256, + cursor: 'cursor-2', + }); + expect(screen.getByText('Retry the missing TODO')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it('retains a newer retry marker when another event arrives mid-drain', async () => { + vi.useFakeTimers(); + try { + let finishSecondDrain: ((result: ConversationReadResult) => void) | undefined; + const read = vi + .fn() + .mockResolvedValueOnce(page([item(1)], 'cursor-1')) + .mockResolvedValueOnce(page([item(2)], 'cursor-2')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecondDrain = resolve; + }), + ) + .mockRejectedValueOnce(new Error('second event read failed')) + .mockResolvedValueOnce(pageEnd([item(4)])); + const metadata = vi + .fn>() + .mockResolvedValue(page([item(3)], 'metadata')); + let onEvent: ((event: { event: string; data: Record }) => void) | undefined; + window.herdr = { + conversation: { + read, + metadata, + prompt: vi.fn(), + respond: vi.fn(), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + }, + onSessionEvent: vi.fn((callback) => { + onEvent = callback; + return () => undefined; + }), + } as unknown as Window['herdr']; + + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + act(() => { + onEvent?.({ + event: 'agent.conversation_changed', + data: { + pane_id: 'w1:p1', + workspace_id: 'w1', + session: { id: 'session-1' }, + reader_generation: 'generation-1', + revision: 2, + reset_required: false, + }, + }); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(read).toHaveBeenCalledTimes(3); + + act(() => { + onEvent?.({ + event: 'agent.conversation_changed', + data: { + pane_id: 'w1:p1', + workspace_id: 'w1', + session: { id: 'session-1' }, + reader_generation: 'generation-1', + revision: 4, + reset_required: false, + }, + }); + finishSecondDrain?.(page([item(3)], 'cursor-2')); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(read).toHaveBeenCalledTimes(4); + + await act(async () => { + vi.advanceTimersByTime(1_500); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(metadata).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenNthCalledWith(5, { + target: 'w1:p1', + direction: 'newer', + limit: 256, + cursor: 'cursor-2', + }); + } finally { + vi.useRealTimers(); + } + }); it('continues draining after the first 64 newer pages', async () => { const responses = [page([item(1)], 'cursor-1')]; @@ -178,12 +387,20 @@ describe('ConversationChatPanel', () => { const view = render(); await waitFor(() => - expect(read).toHaveBeenCalledWith({ target: 'w1:p1', direction: 'newest' }), + expect(read).toHaveBeenCalledWith({ + target: 'w1:p1', + direction: 'newest', + limit: 256, + }), ); view.rerender(); resolvers[0]?.(page([item(1)], 'cursor-old')); await waitFor(() => - expect(read).toHaveBeenCalledWith({ target: 'w1:p2', direction: 'newest' }), + expect(read).toHaveBeenCalledWith({ + target: 'w1:p2', + direction: 'newest', + limit: 256, + }), ); resolvers[1]?.(page([item(2)], 'cursor-new')); @@ -364,6 +581,12 @@ describe('ConversationChatPanel', () => { const second = render(); expect(screen.getByText('answer 1')).toBeInTheDocument(); await waitFor(() => expect(read).toHaveBeenCalledTimes(2)); + expect(read).toHaveBeenNthCalledWith(2, { + target: 'w-large:p1', + direction: 'newer', + limit: 256, + cursor: 'cursor-1', + }); act(() => finishRefresh?.(page([item(2)], 'cursor-2'))); expect(await screen.findByText('answer 2')).toBeInTheDocument(); @@ -378,6 +601,59 @@ describe('ConversationChatPanel', () => { expect(screen.queryByText('answer 2')).not.toBeInTheDocument(); replacement.unmount(); }); + it('does not rescan history after remounting a cached TODO boundary', async () => { + const started: ConversationItem = { + id: 'cached-started', + sequence: 1, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'turn_state', + state: 'started', + }; + const plan: ConversationItem = { + id: 'cached-plan', + sequence: 2, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [{ label: 'Keep cached TODO visible', status: 'active' }], + }; + const read = vi + .fn() + .mockResolvedValueOnce(page([started, plan], 'live-1', 'old-1')) + .mockResolvedValueOnce(page([started], 'live-1', 'old-2')); + window.herdr = { + conversation: { + read, + prompt: vi.fn(), + respond: vi.fn(), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + }, + onSessionEvent: vi.fn(() => () => undefined), + } as unknown as Window['herdr']; + const sessionPane = pane('w-cache:p1', { + conversation_session: { id: 'session-1' }, + }); + + const first = render(); + expect(await screen.findByText('Keep cached TODO visible')).toBeInTheDocument(); + first.unmount(); + + const second = render(); + await waitFor(() => expect(read).toHaveBeenCalledTimes(2)); + expect(read).toHaveBeenNthCalledWith(2, { + target: 'w-cache:p1', + direction: 'newer', + limit: 256, + cursor: 'live-1', + }); + expect(read).not.toHaveBeenCalledWith(expect.objectContaining({ direction: 'older' })); + expect(screen.getByText('Keep cached TODO visible')).toBeInTheDocument(); + second.unmount(); + }); }); describe('ConversationChatPanel onboarding', () => { @@ -575,6 +851,157 @@ describe('ConversationChatPanel live state', () => { expect(await screen.findByRole('status')).toHaveTextContent('Working'); expect(screen.getByText(/for \d+S/)).toBeInTheDocument(); }); + it('keeps polling a readable conversation when pane status is stale', async () => { + vi.useFakeTimers(); + try { + const started: ConversationItem = { + id: 'turn-started', + sequence: 1, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'turn_state', + state: 'started', + started_ms: Date.now(), + }; + const plan: ConversationItem = { + id: 'plan-after-poll', + sequence: 2, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [ + { label: 'Plan arrives after event drop', status: 'active' }, + { label: 'Keep polling while working', status: 'pending' }, + ], + }; + const read = vi + .fn() + .mockResolvedValueOnce(page([started], 'cursor-1', undefined)) + .mockResolvedValue(page([plan], 'cursor-1')); + window.herdr = { + conversation: { + read, + prompt: vi.fn(), + respond: vi.fn(), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + attachment: { + begin: vi.fn(), + chunk: vi.fn(), + finish: vi.fn(), + abort: vi.fn(), + }, + }, + onSessionEvent: vi.fn(() => () => undefined), + } as unknown as Window['herdr']; + + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(1_500); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(read).toHaveBeenNthCalledWith(2, { + target: 'w1:p1', + direction: 'newer', + limit: 256, + cursor: 'cursor-1', + }); + expect(screen.getByText('Plan arrives after event drop')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it('uses visible metadata polling before fetching changed conversation items', async () => { + vi.useFakeTimers(); + try { + const started: ConversationItem = { + id: 'turn-started', + sequence: 1, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'turn_state', + state: 'started', + started_ms: Date.now(), + }; + const plan: ConversationItem = { + id: 'plan-after-metadata', + sequence: 2, + provider: 'codex', + session_id: 'session-1', + turn_id: 'turn-1', + type: 'plan_update', + steps: [{ label: 'Refresh from metadata', status: 'active' }], + }; + const read = vi + .fn() + .mockResolvedValueOnce(page([started], 'cursor-1', undefined)) + .mockResolvedValue(page([plan], 'cursor-2', undefined)); + const metadata = vi + .fn>() + .mockResolvedValueOnce(page([started], 'metadata-1', undefined)) + .mockResolvedValue(page([plan], 'metadata-2', undefined)); + window.herdr = { + conversation: { + read, + metadata, + prompt: vi.fn(), + respond: vi.fn(), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + attachment: { + begin: vi.fn(), + chunk: vi.fn(), + finish: vi.fn(), + abort: vi.fn(), + }, + }, + onSessionEvent: vi.fn(() => () => undefined), + } as unknown as Window['herdr']; + + const view = render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(1_500); + await Promise.resolve(); + }); + expect(metadata).not.toHaveBeenCalled(); + + view.rerender(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(metadata).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1_500); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(read).toHaveBeenNthCalledWith(2, { + target: 'w1:p1', + direction: 'newer', + limit: 256, + cursor: 'cursor-1', + }); + expect(screen.getByText('Refresh from metadata')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); it.each([ ['claude', 'Claude Code'], diff --git a/src/renderer/chat/ConversationChatPanel.tsx b/src/renderer/chat/ConversationChatPanel.tsx index 23f3deb..da683d3 100644 --- a/src/renderer/chat/ConversationChatPanel.tsx +++ b/src/renderer/chat/ConversationChatPanel.tsx @@ -62,6 +62,12 @@ const conversationStoreByPane = new Map(); const composerStateByPane = new Map(); const workingStartedMsByPane = new Map(); const sentAttachmentPreviewUrlsByPane = new Map>(); +const CONVERSATION_READ_LIMIT = 256; +// Older pages issue two server cursors; refresh the live tail before either +// cursor can evict the current live cursor from the bounded registry. +const PLAN_HISTORY_REFRESH_INTERVAL = 32; +const PLAN_HYDRATION_RETRY_DELAY_MS = 500; +const PLAN_HYDRATION_MAX_RETRY_DELAY_MS = 5_000; export function pruneConversationChatState(activePaneIds: readonly string[]): void { const active = new Set(activePaneIds); @@ -96,6 +102,7 @@ export function pruneConversationChatState(activePaneIds: readonly string[]): vo interface ConversationChatPanelProps { pane: PaneInfo; onOpenTerminal?: () => void; + visible?: boolean; } function itemText(items: readonly ConversationItem[]): string { for (let index = items.length - 1; index >= 0; index -= 1) { @@ -228,7 +235,11 @@ export function ConversationChatPanel(props: ConversationChatPanelProps) { return ; } -function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChatPanelProps) { +function ConversationChatPanelForPane({ + pane, + onOpenTerminal, + visible = true, +}: ConversationChatPanelProps) { const cachedStore = conversationStoreByPane.get(pane.pane_id); const savedStore = cachedStore?.session?.id === pane.conversation_session?.id ? cachedStore : undefined; @@ -246,18 +257,33 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat const [loading, setLoading] = useState(savedStore === undefined); const [sending, setSending] = useState(false); const [error, setError] = useState(); + const [planHydrationRetry, setPlanHydrationRetry] = useState(false); const capability = pane.conversation_capability; const preSessionChat = isPreSessionConversationCapability(capability); const conversationReadable = capability?.availability === 'supported'; + const planHistorySupported = pane.agent?.toLowerCase() !== 'claude'; const paneWorking = pane.agent_status === 'working'; const [statusStartedMs, setStatusStartedMs] = useState(() => workingStartedMsByPane.get(pane.pane_id), ); const paneRef = useRef(pane); + const visibleRef = useRef(visible); + visibleRef.current = visible; paneRef.current = pane; const storeRef = useRef(store); const readQueueRef = useRef(Promise.resolve()); const requestEpochRef = useRef(0); + const planBoundaryKnownRef = useRef( + savedStore?.items.some((item) => item.type === 'plan_update') ?? false, + ); + const planHydrationPromiseRef = useRef | undefined>(undefined); + const pollInFlightRef = useRef(false); + const pendingRefreshRevisionRef = useRef(undefined); + const pendingPayloadDrainRef = useRef(false); + const planHydrationRetryRef = useRef(0); + const planHydrationRetryTimerRef = useRef(undefined); + const planHydrationAllowedRef = useRef(false); + const wasVisibleRef = useRef(visible); const scrollViewportRef = useRef(null); const followLatestRef = useRef(true); const olderAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | undefined>(undefined); @@ -417,6 +443,23 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat throw reason; } } + const wakePlanHydration = useCallback(() => { + queueMicrotask(() => { + if ( + planHydrationAllowedRef.current && + planHydrationPromiseRef.current === undefined && + !pendingPayloadDrainRef.current && + !planBoundaryKnownRef.current && + storeRef.current.olderCursor !== undefined + ) { + if (planHydrationRetryTimerRef.current !== undefined) { + window.clearTimeout(planHydrationRetryTimerRef.current); + planHydrationRetryTimerRef.current = undefined; + } + setPlanHydrationRetry(true); + } + }); + }, []); const read = useCallback( (direction: 'newest' | 'older' | 'newer', cursor?: string, epoch = requestEpochRef.current) => { @@ -438,15 +481,26 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat nextCursor === undefined && requestedDirection !== 'newest' ? 'newest' : requestedDirection; + if (effectiveDirection !== 'older') { + pendingPayloadDrainRef.current = true; + } const result = await window.herdr.conversation.read({ target: pane.pane_id, direction: effectiveDirection, + limit: CONVERSATION_READ_LIMIT, ...(nextCursor === undefined ? {} : { cursor: nextCursor }), }); lastResult = result; if (epoch !== requestEpochRef.current) { return result; } + if (result.type === 'reset_required') { + planBoundaryKnownRef.current = false; + } else if (result.page.items.some((item) => item.type === 'plan_update')) { + planBoundaryKnownRef.current = true; + } else if (effectiveDirection === 'newest') { + planBoundaryKnownRef.current = false; + } setStore((current) => applyConversationRead(current, result, effectiveDirection)); if (result.type === 'reset_required' && effectiveDirection !== 'newest') { requestedDirection = 'newest'; @@ -460,6 +514,15 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat result.page.next_cursor === undefined || result.page.next_cursor === nextCursor ) { + if (result.type === 'page' && effectiveDirection !== 'older') { + pendingPayloadDrainRef.current = false; + planHydrationRetryRef.current = 0; + const pendingRevision = pendingRefreshRevisionRef.current; + if (pendingRevision !== undefined && result.page.revision >= pendingRevision) { + pendingRefreshRevisionRef.current = undefined; + } + wakePlanHydration(); + } return result; } nextCursor = result.page.next_cursor; @@ -472,17 +535,91 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat ); return queued; }, - [pane.pane_id], + [pane.pane_id, wakePlanHydration], ); + const hydratePlanHistory = useCallback(async (): Promise => { + const epoch = requestEpochRef.current; + let cursor = storeRef.current.olderCursor; + const seenCursors = new Set(); + let pagesRead = 0; + while ( + cursor !== undefined && + !seenCursors.has(cursor) && + !planBoundaryKnownRef.current && + planHydrationAllowedRef.current && + !pendingPayloadDrainRef.current + ) { + seenCursors.add(cursor); + pagesRead += 1; + const result = await read('older', cursor, epoch); + if (result?.type !== 'page') { + return false; + } + cursor = result.page.previous_cursor; + if ( + cursor !== undefined && + pagesRead % PLAN_HISTORY_REFRESH_INTERVAL === 0 && + planHydrationAllowedRef.current && + !pendingPayloadDrainRef.current && + !planBoundaryKnownRef.current + ) { + const refreshed = await read('newest', undefined, epoch); + if (refreshed?.type !== 'page') { + return false; + } + } + } + return ( + cursor !== undefined && + !planBoundaryKnownRef.current && + (!planHydrationAllowedRef.current || pendingPayloadDrainRef.current) + ); + }, [read]); + const pollConversation = useCallback(async () => { + if (pollInFlightRef.current) { + return; + } + pollInFlightRef.current = true; + try { + const metadata = window.herdr.conversation.metadata; + if (typeof metadata !== 'function') { + await read('newer'); + return; + } + let result: ConversationReadResult; + try { + result = await metadata({ target: pane.pane_id }); + } catch { + await read('newer'); + return; + } + const current = storeRef.current; + const needsRefresh = + result.type === 'reset_required' || + pendingRefreshRevisionRef.current !== undefined || + pendingPayloadDrainRef.current || + current.resetRequired || + current.readerGeneration !== result.page.reader_generation || + current.session?.id !== result.page.session.id || + result.page.revision !== current.revision; + if (needsRefresh) { + await read(result.type === 'reset_required' ? 'newest' : 'newer'); + } + } finally { + pollInFlightRef.current = false; + } + }, [pane.pane_id, read]); useEffect(() => { const epoch = ++requestEpochRef.current; readQueueRef.current = Promise.resolve(); let cancelled = false; + const initialCursor = storeRef.current.newerCursor; + const initialDirection = initialCursor === undefined ? 'newest' : 'newer'; setLoading(conversationReadable && storeRef.current.items.length === 0); setError(undefined); if (conversationReadable) { - void read('newest', undefined, epoch) + void read(initialDirection, initialCursor, epoch) .catch((reason: unknown) => { if (!cancelled) { setError(reason instanceof Error ? reason.message : 'Could not load conversation.'); @@ -501,28 +638,41 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat return; } setStore((current) => applyConversationChanged(current, changed)); + pendingRefreshRevisionRef.current = changed.reset_required + ? 0 + : Math.max(pendingRefreshRevisionRef.current ?? 0, changed.revision); void read(changed.reset_required ? 'newest' : 'newer').catch(() => undefined); }); - // Fallback live poll: subscription notifications can be missed during - // engine restarts or subscription reconnects, and the engine's newer read - // is cheap (metadata-only when nothing changed). This guarantees the chat - // keeps streaming while the agent is working even if an event is dropped. + // Poll metadata only while Chat is visible. A changed revision triggers + // the cursor-based payload read; unchanged metadata must not churn cursors. const livePoll = window.setInterval(() => { if ( - paneRef.current.conversation_capability?.availability === 'supported' && - (paneRef.current.agent_status === 'working' || paneRef.current.agent_status === 'blocked') + visibleRef.current && + paneRef.current.conversation_capability?.availability === 'supported' ) { - void read('newer').catch(() => undefined); + void pollConversation().catch(() => undefined); } }, 1_500); return () => { cancelled = true; requestEpochRef.current += 1; + planHydrationAllowedRef.current = false; + if (planHydrationRetryTimerRef.current !== undefined) { + window.clearTimeout(planHydrationRetryTimerRef.current); + planHydrationRetryTimerRef.current = undefined; + } window.clearInterval(livePoll); unsubscribe(); void window.herdr.conversation.unsubscribe(pane.pane_id).catch(() => undefined); }; - }, [conversationReadable, pane.pane_id, read]); + }, [conversationReadable, pane.pane_id, pollConversation, read]); + useEffect(() => { + const wasVisible = wasVisibleRef.current; + wasVisibleRef.current = visible; + if (!wasVisible && visible && conversationReadable) { + void pollConversation().catch(() => undefined); + } + }, [conversationReadable, pollConversation, visible]); const send = async (retry?: { id: string; text: string }) => { const text = retry?.text ?? draft.trim(); @@ -633,6 +783,9 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat break; } } + const runningTool = store.items.some( + (item) => item.type === 'tool_activity' && item.status === 'running', + ); if (latestState?.state === 'started') { let finalAnswerReceived = false; for (let index = latestStateIndex + 1; index < store.items.length; index += 1) { @@ -660,6 +813,12 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat plan, }; } + if (runningTool) { + return { + startedMs: statusStartedMs, + plan, + }; + } let activeTurnId: string | undefined; for (let index = store.items.length - 1; index > latestStateIndex; index -= 1) { const item = store.items[index]; @@ -684,6 +843,84 @@ function ConversationChatPanelForPane({ pane, onOpenTerminal }: ConversationChat plan, }; }, [paneWorking, sending, statusStartedMs, store.items, store.pending]); + useEffect(() => { + const canHydrate = + visible && planHistorySupported && conversationReadable && activeWork !== null; + const wasAllowed = planHydrationAllowedRef.current; + if (!canHydrate) { + planHydrationAllowedRef.current = false; + planHydrationRetryRef.current = 0; + } else { + if (!wasAllowed) { + planHydrationRetryRef.current = 0; + } + planHydrationAllowedRef.current = true; + } + if (planHydrationRetry) { + setPlanHydrationRetry(false); + } + const clearRetryTimer = () => { + if (planHydrationRetryTimerRef.current !== undefined) { + window.clearTimeout(planHydrationRetryTimerRef.current); + planHydrationRetryTimerRef.current = undefined; + } + }; + if ( + !canHydrate || + planBoundaryKnownRef.current || + planHydrationPromiseRef.current !== undefined || + store.olderCursor === undefined + ) { + if (!canHydrate || planBoundaryKnownRef.current || store.olderCursor === undefined) { + clearRetryTimer(); + } + return; + } + const scheduleRetry = () => { + if (!planHydrationAllowedRef.current || planHydrationRetryTimerRef.current !== undefined) { + return; + } + const attempt = Math.min(planHydrationRetryRef.current, 4); + planHydrationRetryRef.current = attempt + 1; + const delay = Math.min( + PLAN_HYDRATION_RETRY_DELAY_MS * 2 ** attempt, + PLAN_HYDRATION_MAX_RETRY_DELAY_MS, + ); + planHydrationRetryTimerRef.current = window.setTimeout(() => { + planHydrationRetryTimerRef.current = undefined; + if (planHydrationAllowedRef.current) { + setPlanHydrationRetry(true); + } + }, delay); + }; + let failed = false; + const hydration = hydratePlanHistory().catch(() => { + failed = true; + scheduleRetry(); + return false; + }); + planHydrationPromiseRef.current = hydration; + void hydration.then((paused) => { + if (planHydrationPromiseRef.current === hydration) { + planHydrationPromiseRef.current = undefined; + if (!failed) { + planHydrationRetryRef.current = 0; + if (paused) { + wakePlanHydration(); + } + } + } + }); + }, [ + activeWork, + conversationReadable, + hydratePlanHistory, + planHistorySupported, + planHydrationRetry, + wakePlanHydration, + store.olderCursor, + visible, + ]); const respond = useCallback( async ( diff --git a/src/renderer/chat/conversation-model.test.ts b/src/renderer/chat/conversation-model.test.ts index 4e3ae15..9a62b29 100644 --- a/src/renderer/chat/conversation-model.test.ts +++ b/src/renderer/chat/conversation-model.test.ts @@ -85,6 +85,21 @@ describe('conversation model', () => { expect(store.resetRequired).toBe(false); expect(store.items).toHaveLength(1); }); + it('replaces retained items after a same-generation reset', () => { + let store = applyConversationRead( + createConversationStore('w1:p1'), + page([item(10, 'old-item', 'stale item')], 'reader-1', 10), + ); + store = applyConversationRead(store, { + type: 'reset_required', + session: { id: 'session' }, + reader_generation: 'reader-1', + }); + store = applyConversationRead(store, page([item(1, 'new-item', 'new item')], 'reader-1', 1)); + + expect(store.items.map(({ id }) => id)).toEqual(['new-item']); + expect(store.resetRequired).toBe(false); + }); it('ignores conversation events for other panes and resets matching panes', () => { let store = applyConversationRead(createConversationStore('w1:p1'), page([item(1)])); diff --git a/src/renderer/chat/conversation-model.ts b/src/renderer/chat/conversation-model.ts index 3137669..8ded952 100644 --- a/src/renderer/chat/conversation-model.ts +++ b/src/renderer/chat/conversation-model.ts @@ -90,6 +90,7 @@ function replacePage( ): ConversationStore { const sameReader = store.readerGeneration === undefined || store.readerGeneration === page.reader_generation; + const canMergeExisting = sameReader && !store.resetRequired; // Optimistic echoes resolve once the durable transcript contains the same // user text (the engine's prompt submission queues before persisting). const pending = [...store.pending]; @@ -106,8 +107,8 @@ function replacePage( } } } - let olderCursor = sameReader ? store.olderCursor : undefined; - let newerCursor = sameReader ? store.newerCursor : undefined; + let olderCursor = canMergeExisting ? store.olderCursor : undefined; + let newerCursor = canMergeExisting ? store.newerCursor : undefined; if (direction === 'newest') { olderCursor = page.previous_cursor; newerCursor = page.next_cursor; @@ -117,8 +118,7 @@ function replacePage( newerCursor = page.next_cursor; } if ( - sameReader && - !store.resetRequired && + canMergeExisting && page.items.length === 0 && store.provider === page.provider && store.session?.id === page.session.id && @@ -131,7 +131,7 @@ function replacePage( return store; } const byId = new Map( - sameReader ? store.items.map((item) => [item.id, item]) : [], + canMergeExisting ? store.items.map((item) => [item.id, item]) : [], ); for (const item of page.items) { const nextItem = withAttachmentPreviews( @@ -148,8 +148,8 @@ function replacePage( readerGeneration: page.reader_generation, capability: page.capability, items: sortItems(byId.values()), - pending: sameReader ? pending : store.pending, - revision: sameReader ? Math.max(store.revision, page.revision) : page.revision, + pending: canMergeExisting ? pending : store.pending, + revision: canMergeExisting ? Math.max(store.revision, page.revision) : page.revision, olderCursor, newerCursor, resetRequired: false, diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index c18a5b4..fdbea18 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -453,6 +453,7 @@ export interface DroverApi { query(query: HerdrQuery): Promise; conversation: { read(request: ConversationReadRequest): Promise; + metadata?(request: ConversationReadRequest): Promise; prompt(request: ConversationPromptRequest): Promise; respond(request: ConversationRespondRequest): Promise; subscribe(paneId: string): Promise; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 41039ff..65406b3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -4,6 +4,7 @@ export const IPC_CHANNELS = { command: 'herdr:command', query: 'herdr:query', conversationRead: 'herdr:conversation-read', + conversationMetadata: 'herdr:conversation-metadata', conversationPrompt: 'herdr:conversation-prompt', conversationRespond: 'herdr:conversation-respond', conversationSubscribe: 'herdr:conversation-subscribe',