From e659a7098e111222fd2a4a6958505d9a694792e2 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 25 Aug 2026 22:29:25 -0500 Subject: [PATCH] feat(thought-stream): interleave tool calls with reasoning on one timeline The Thought Stream captured only `process:thinking-chunk`, so it showed an agent's reasoning but never its actions. Tool calls do render in the chat transcript, but only when a tab's `showThinking` is on - and an Auto Run spawns as `{sessionId}-batch-{ts}` with no AI tab at all, so it has no transcript for them to live in. Net effect: no surface anywhere showed what an Auto Run agent was actually doing while it burned tokens. Tool calls now stream into the Thought Stream interleaved with the reasoning, each reduced to ONE short plain-language line: 3:42:07 PM * Ran npm test 3:42:04 PM v Read src/renderer/components/ThoughtStreamPanel.tsx 3:42:01 PM v Searched for THOUGHT_BLOCK_GAP_MS 3:41:58 PM ! Edited src/renderer/constants/themes.ts Ordering is the product here, so it is structural rather than sorted. A session's buffer is ONE chronological array holding both kinds of event in arrival order, and `buildActivityFeed` walks it once: consecutive thinking coalesces into a block, and a tool call CLOSES the open block so the reasoning that followed it starts a new one below. Keeping thoughts and tools in two lists and merging at display time cannot express this - a block carries a single timestamp, so a tool call that happened mid-block has nowhere to go and surfaces after reasoning that actually followed it. A completion merges into the entry its start created, in place, keeping both its slot and its start timestamp: the feed lists actions, not state transitions, and a long build does not leapfrog the reasoning that happened while it ran. Matching is by `toolCallId`, else by newest still-running call of the same name in the same tab (the rule the transcript already uses for providers that send no id). - `utils/toolActivityLabel.ts` (new): normalizes tool names across Claude Code, OpenCode, Codex, Copilot, and MCP onto plain English. Unknown tools degrade to `Used ` rather than vanishing. - `useThoughtStreamToolListener` (new): taps `process:tool-execution`, scoped by the same `AUTO_RUN_SESSION_TYPES` set the thinking listener uses and importing it rather than restating it - the two feed one timeline, so any divergence would interleave one stream's actions with another's reasoning. - Panel renders tool rows as plain text (a shell command is not markdown), search matches the rendered line and the raw tool name, and the header counts thoughts and actions separately - a climbing action count against flat reasoning is what a loop looks like. Capture stays ambient, so opening the panel on a run that has been wedged for ten minutes shows those ten minutes. Closes #1312 --- CLAUDE.md | 2 +- docs/agent-guides/SHARED-UTILS.md | 10 + docs/autorun-playbooks.md | 27 +- .../components/ThoughtStreamPanel.test.tsx | 87 +++++ .../useThoughtStreamToolListener.test.tsx | 201 +++++++++++ .../renderer/hooks/useAgentListeners.test.ts | 28 +- .../stores/thoughtStreamStore.test.ts | 256 +++++++++++++- .../renderer/utils/toolActivityLabel.test.ts | 178 ++++++++++ src/renderer/components/AutoRun/AutoRun.tsx | 4 +- src/renderer/components/RightPanel.tsx | 4 +- .../components/ThoughtStreamPanel.tsx | 200 ++++++++--- .../internal/useThoughtStreamToolListener.ts | 111 ++++++ src/renderer/hooks/agent/useAgentListeners.ts | 5 + src/renderer/stores/thoughtStreamStore.ts | 320 +++++++++++++++--- src/renderer/utils/toolActivityLabel.ts | 238 +++++++++++++ 15 files changed, 1555 insertions(+), 116 deletions(-) create mode 100644 src/__tests__/renderer/hooks/agent/internal/useThoughtStreamToolListener.test.tsx create mode 100644 src/__tests__/renderer/utils/toolActivityLabel.test.ts create mode 100644 src/renderer/hooks/agent/internal/useThoughtStreamToolListener.ts create mode 100644 src/renderer/utils/toolActivityLabel.ts diff --git a/CLAUDE.md b/CLAUDE.md index 936a5f915c..4b32076922 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -369,7 +369,7 @@ src/ | Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler | | Modify history components | `src/renderer/components/History/` | | Modify history activity graph | `src/renderer/components/History/ActivityGraph.tsx`, `src/main/utils/history-bucket-cache.ts` (disk-cached aggregates), `src/main/utils/history-bucket-builder.ts` | -| Modify Auto Run Thought Stream | `src/renderer/stores/thoughtStreamStore.ts` (in-memory capture + `groupThoughtsIntoBlocks`), `src/renderer/components/ThoughtStreamPanel.tsx` (panel), `src/renderer/hooks/agent/internal/useThoughtStreamCaptureListener.ts` (taps `process:thinking-chunk`) | +| Modify Auto Run Thought Stream | `src/renderer/stores/thoughtStreamStore.ts` (one timeline of thoughts + tool calls, `buildActivityFeed`), `ThoughtStreamPanel.tsx`, `hooks/agent/internal/useThoughtStream{Capture,Tool}Listener.ts`, `src/renderer/utils/toolActivityLabel.ts` | | Add Cue event type | `src/main/cue/cue-types.ts`, `src/main/cue/cue-engine.ts` | | Add Cue template variable | `src/shared/templateVariables.ts`, `src/main/cue/cue-executor.ts` | | Modify Cue modal | `src/renderer/components/CueModal.tsx` | diff --git a/docs/agent-guides/SHARED-UTILS.md b/docs/agent-guides/SHARED-UTILS.md index a0871d36a6..dbd7c497ab 100644 --- a/docs/agent-guides/SHARED-UTILS.md +++ b/docs/agent-guides/SHARED-UTILS.md @@ -840,6 +840,16 @@ Which agents the Left Bar may surface AT ALL - a different question from the unr | `captureException(error, captureContext?)` | `(Error \| unknown, { extra? }?) => void` | Report error to Sentry from renderer. | | `captureMessage(message, captureContext?)` | `(string, { level?, extra? }?) => void` | Report message to Sentry from renderer. | +### Tool Activity Labels (`src/renderer/utils/toolActivityLabel.ts`) + +`describeToolActivity(toolName, input)` turns one raw agent tool call into ONE short line of plain English (`Read src/App.tsx`, `Ran npm test`, `Edited themes.ts`), returning `{ verb, target }`. + +Tool names differ per provider (Claude Code `Read`/`Bash`/`MultiEdit`, OpenCode lowercase `read`/`bash`, Codex `shell`/`apply_patch`/`update_plan`, Copilot `write_to_file`, MCP `mcp__server__tool`), so matching runs on a normalized name. An unrecognized tool still returns a usable `Used ` line rather than being dropped, so a provider that ships a new tool degrades to something readable instead of a hole in the feed. It never throws: a raw-string input (Codex `apply_patch` sends a whole diff, not an object) and an argv-array `command` are both handled. + +**Do NOT confuse it with `summarizeToolInput()`** (`components/TerminalOutput/utils/toolSummaries.ts`), which builds the VERBOSE in-chat tool cell: every input key as `key=value`, the untruncated command, plus an output preview. Pick by surface - a chat transcript the user reads line by line wants the verbose cell; the Thought Stream's activity feed wants the one-liner, because its whole job is being scannable enough to spot a loop. + +--- + ### Touch Primitives (`src/renderer/utils/touch.ts`) The desktop renderer also runs on phones (web-desktop build). These are the canonical touch helpers - do NOT re-derive `navigator.vibrate` calls or pointer-media queries. Hoisted out of the legacy mobile bundle (retired in Phase 06); the touch gesture hook `useLongPress` (see [UI-PATTERNS.md](UI-PATTERNS.md)) is built on `triggerHaptic`/`HAPTIC_PATTERNS`. diff --git a/docs/autorun-playbooks.md b/docs/autorun-playbooks.md index 295a6a0040..88fb54ed73 100644 --- a/docs/autorun-playbooks.md +++ b/docs/autorun-playbooks.md @@ -195,18 +195,33 @@ The runner will: ## Thought Stream -While a run is active, you can watch the agent's live reasoning without changing any settings. In the **Auto Run** card, click **View Thoughts** (the brain icon) to open the **Thought Stream** - a floating, searchable panel that streams the agent's thinking as it works. +While a run is active, you can watch what the agent is doing without changing any settings. In the **Auto Run** card, click **View Thoughts** (the brain icon) to open the **Thought Stream** - a floating, searchable panel that streams the agent's reasoning _and_ its tool calls as it works. -Thoughts are buffered from the moment the agent starts thinking, whether or not the panel is open. That is deliberate: you usually go looking at the thought stream _because_ a run has been sitting still for a while, and a stream that only started recording when you opened it would hand you an empty log at exactly the wrong moment. Open it after twenty quiet minutes and you get those twenty minutes. +Every tool call is reduced to one short line in plain language, interleaved with the reasoning that produced it: -It works the same for **Spec-Driven** and **Goal-Driven** runs, because both flow through the same agent. The panel captures the raw reasoning stream directly, so it shows thoughts even when an AI tab's "show thinking" display is turned off. +``` +3:42:07 PM ⟳ Ran npm test +3:42:04 PM ✓ Read src/renderer/components/ThoughtStreamPanel.tsx +3:42:01 PM ✓ Searched for THOUGHT_BLOCK_GAP_MS +3:41:58 PM ! Edited src/renderer/constants/themes.ts +``` + +A spinner marks a call still in flight; a check or a warning marks how it ended. The full inputs and outputs stay in the chat transcript - this feed is built to be _scanned_, so that an agent stuck in a loop or grinding on an unproductive task is obvious at a glance and you can stop it before it burns more tokens. + +Tool names are normalized across providers (Claude Code, Codex, OpenCode, Copilot, and MCP servers), so the lines read the same no matter which agent is running. + +Thoughts and tool calls are buffered from the moment the agent starts working, whether or not the panel is open. That is deliberate: you usually go looking at the thought stream _because_ a run has been sitting still for a while, and a stream that only started recording when you opened it would hand you an empty log at exactly the wrong moment. Open it after twenty quiet minutes and you get those twenty minutes. + +It works the same for **Spec-Driven** and **Goal-Driven** runs, because both flow through the same agent. The panel captures the raw streams directly, so it shows thinking and tool calls even when an AI tab's "show thinking" and tool-call display are turned off. For an Auto Run this is the only place the tool calls appear at all: a run has no chat tab of its own for a transcript to live in. - **Newest on top** - the live thought sits at the top and grows; scroll down to read the history of the run. - **Timestamped blocks** - a continuous burst of thinking is grouped into one block with a time stamp; a pause (or a switch between parallel tabs) starts a new block. - **Formatted** - thoughts render as formatted markdown (headings, lists, bold, inline code, code fences), so structured reasoning stays readable. -- **Search** - filter the captured thoughts with the search box; matches are highlighted. +- **In order** - a tool call renders between the reasoning that led to it and the reasoning that followed, so the feed reads as the sequence the agent actually performed. +- **Search** - filter the feed with the search box; matches are highlighted. Searching a tool name ("Bash") finds calls the feed renders under a plain-language verb ("Ran ..."). +- **Counts** - the header tracks thoughts and actions separately. A climbing action count against flat reasoning is what a loop looks like. -The button highlights once there are buffered thoughts waiting to be read, and its tooltip gives the count. +The button highlights once there is anything buffered to read, and its tooltip gives the count. **Open, close, clear:** @@ -218,7 +233,7 @@ There is no minimize. It used to mean "hide the panel but keep capturing," which Once a run finishes, the Right Panel's run card goes away and takes its **View Thoughts** button with it. The buffer outlives the run, so a **Thoughts** button appears at the bottom of the Auto Run panel for as long as there is something buffered to read. -Capture is in-memory only - it does not survive an app restart, and it is bounded on three axes so a fleet of agents running all day can't grow memory without limit: thoughts per agent, characters per agent, and how many agents keep a buffer at all (the least recently active is dropped first, and the agent you have open is never dropped). Trimming within an agent is noted as "trimmed" in the panel header. Running several Auto Runs at once? Each agent buffers independently; opening the panel for one agent never mixes in another's thoughts. +Capture is in-memory only - it does not survive an app restart, and it is bounded on three axes so a fleet of agents running all day can't grow memory without limit: timeline entries per agent, characters per agent, and how many agents keep a buffer at all (the least recently active is dropped first, and the agent you have open is never dropped). Trimming within an agent is noted as "trimmed" in the panel header. Running several Auto Runs at once? Each agent buffers independently; opening the panel for one agent never mixes in another's thoughts. ## Session Isolation diff --git a/src/__tests__/renderer/components/ThoughtStreamPanel.test.tsx b/src/__tests__/renderer/components/ThoughtStreamPanel.test.tsx index 940372b1f0..ff226423d8 100644 --- a/src/__tests__/renderer/components/ThoughtStreamPanel.test.tsx +++ b/src/__tests__/renderer/components/ThoughtStreamPanel.test.tsx @@ -115,3 +115,90 @@ describe('ThoughtStreamPanel', () => { expect(screen.getByText('Thought Stream')).toBeInTheDocument(); }); }); + +/** + * The action feed. A tool call renders as ONE plain-language line, and it + * renders in timeline position relative to the reasoning around it - which is + * the whole point of the feature (spot a loop, interrupt it before it burns + * more tokens). + */ +describe('ThoughtStreamPanel tool activity', () => { + const TAB = 'tab-a'; + + function seed() { + const store = useThoughtStreamStore.getState(); + store.appendThought(SID, TAB, 'I should check the tests. '); + store.appendToolActivity(SID, TAB, { + toolName: 'Bash', + label: { verb: 'Ran', target: 'npm test' }, + status: 'completed', + toolCallId: 'c1', + }); + store.appendThought(SID, TAB, 'They passed.'); + store.openPanel(SID); + } + + it('renders a tool call as one plain-language line', () => { + seed(); + renderPanel(); + expect(screen.getByText('Ran npm test')).toBeInTheDocument(); + }); + + it('shows a running call with a spinner and a failed one with a warning', () => { + const store = useThoughtStreamStore.getState(); + store.appendToolActivity(SID, TAB, { + toolName: 'Bash', + label: { verb: 'Ran', target: 'npm run build' }, + status: 'running', + toolCallId: 'r1', + }); + store.appendToolActivity(SID, TAB, { + toolName: 'Edit', + label: { verb: 'Edited', target: 'themes.ts' }, + status: 'failed', + toolCallId: 'f1', + }); + store.openPanel(SID); + renderPanel(); + + expect(screen.getByLabelText('running')).toBeInTheDocument(); + expect(screen.getByLabelText('failed')).toBeInTheDocument(); + }); + + it('counts thoughts and actions separately in the header', () => { + seed(); + renderPanel(); + // Two blocks of reasoning (the tool call split them) and one action. + expect(screen.getByText(/2 thoughts · 1 action/)).toBeInTheDocument(); + }); + + it('renders the tool call BETWEEN the reasoning it interrupted', () => { + seed(); + const { container } = renderPanel(); + const text = container.textContent ?? ''; + // Newest-on-top display, so the later reasoning comes first. + expect(text.indexOf('They passed.')).toBeLessThan(text.indexOf('Ran npm test')); + expect(text.indexOf('Ran npm test')).toBeLessThan(text.indexOf('I should check the tests.')); + }); + + it('search matches the rendered line', () => { + seed(); + renderPanel(); + fireEvent.change(screen.getByPlaceholderText('Search activity...'), { + target: { value: 'npm test' }, + }); + expect(screen.getByText('npm test')).toBeInTheDocument(); + expect(screen.queryByText('They passed.')).not.toBeInTheDocument(); + }); + + it('search also matches the raw provider tool name', () => { + // The feed renders "Ran npm test", so searching the tool the user knows + // they configured ("Bash") has to find it anyway. + seed(); + renderPanel(); + fireEvent.change(screen.getByPlaceholderText('Search activity...'), { + target: { value: 'Bash' }, + }); + expect(screen.getByText('Ran npm test')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/hooks/agent/internal/useThoughtStreamToolListener.test.tsx b/src/__tests__/renderer/hooks/agent/internal/useThoughtStreamToolListener.test.tsx new file mode 100644 index 0000000000..e109be9d5d --- /dev/null +++ b/src/__tests__/renderer/hooks/agent/internal/useThoughtStreamToolListener.test.tsx @@ -0,0 +1,201 @@ +/** + * useThoughtStreamToolListener tests + * + * The action half of the Thought Stream. What matters here: + * - Auto Run `-batch-` tool calls ARE captured (the in-chat transcript listener + * matches `REGEX_AI_TAB` only, so an Auto Run has no other surface at all). + * - Interactive `-ai-` tool calls are NOT (same scoping as the thinking + * listener; capturing them is what once made the panel show ordinary chat). + * - Provider status wording normalizes onto running/completed/failed. + * - A completion merges into its start rather than appending a second row. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useThoughtStreamToolListener } from '../../../../../renderer/hooks/agent/internal/useThoughtStreamToolListener'; +import { + useThoughtStreamStore, + isToolEvent, + type ToolActivityEntry, +} from '../../../../../renderer/stores/thoughtStreamStore'; + +type ToolHandler = ( + sessionId: string, + toolEvent: { toolName: string; state?: unknown; timestamp: number; toolCallId?: string } +) => void; + +let toolHandler: ToolHandler | undefined; +const mockUnsubscribe = vi.fn(); + +const SESSION_ID = 'session-abc'; +const BATCH = `${SESSION_ID}-batch-1700000000000`; + +/** Tool events for a session, in timeline order. */ +function toolEvents(sessionId = SESSION_ID): ToolActivityEntry[] { + const entries = useThoughtStreamStore.getState().buffers[sessionId]?.entries ?? []; + return entries.filter(isToolEvent); +} + +beforeEach(() => { + vi.clearAllMocks(); + toolHandler = undefined; + + (window as any).maestro = { + ...((window as any).maestro || {}), + process: { + ...((window as any).maestro?.process || {}), + onToolExecution: vi.fn((h: ToolHandler) => { + toolHandler = h; + return mockUnsubscribe; + }), + }, + }; + + useThoughtStreamStore.setState({ panelSessionId: null, buffers: {} }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useThoughtStreamToolListener', () => { + it('captures Auto Run tool calls despite the `-batch-` streaming id', () => { + // The gap this feature exists to close: `useAgentToolExecutionListener` + // matches REGEX_AI_TAB, so during an Auto Run every tool call was dropped + // and no surface anywhere showed what the agent was doing. + renderHook(() => useThoughtStreamToolListener()); + + act(() => { + toolHandler?.(BATCH, { + toolName: 'Bash', + state: { status: 'running', input: { command: 'npm test' } }, + timestamp: 1000, + }); + }); + + const events = toolEvents(); + expect(events).toHaveLength(1); + expect(events[0].tool.name).toBe('Bash'); + expect(events[0].tool.label).toEqual({ verb: 'Ran', target: 'npm test' }); + expect(events[0].tool.status).toBe('running'); + }); + + it('does not capture interactive `-ai-` tab tool calls', () => { + renderHook(() => useThoughtStreamToolListener()); + + act(() => { + toolHandler?.(`${SESSION_ID}-ai-tab1`, { + toolName: 'Read', + state: { status: 'completed', input: { file_path: '/tmp/a.ts' } }, + timestamp: 1000, + }); + }); + + expect(useThoughtStreamStore.getState().buffers[SESSION_ID]).toBeUndefined(); + }); + + it('does not capture synopsis spawns', () => { + renderHook(() => useThoughtStreamToolListener()); + + act(() => { + toolHandler?.(`${SESSION_ID}-synopsis-1700000000000`, { + toolName: 'Read', + state: { status: 'completed' }, + timestamp: 1000, + }); + }); + + expect(useThoughtStreamStore.getState().buffers[SESSION_ID]).toBeUndefined(); + }); + + it('merges a completion into the call it started, keeping one row', () => { + renderHook(() => useThoughtStreamToolListener()); + + act(() => { + toolHandler?.(BATCH, { + toolName: 'Bash', + state: { status: 'running', input: { command: 'npm test' } }, + timestamp: 1000, + toolCallId: 'call-1', + }); + toolHandler?.(BATCH, { + toolName: 'Bash', + state: { status: 'completed' }, + timestamp: 8000, + toolCallId: 'call-1', + }); + }); + + const events = toolEvents(); + expect(events).toHaveLength(1); + expect(events[0].tool.status).toBe('completed'); + // The row keeps its START time so it does not jump position on finishing. + expect(events[0].timestamp).toBe(1000); + expect(events[0].tool.endedAt).toBe(8000); + }); + + it('normalizes `error` onto `failed`', () => { + renderHook(() => useThoughtStreamToolListener()); + + act(() => { + toolHandler?.(BATCH, { + toolName: 'Bash', + state: { status: 'error' }, + timestamp: 1000, + }); + }); + + expect(toolEvents()[0].tool.status).toBe('failed'); + }); + + it('treats a missing status as still running', () => { + // An unfinished call is the reading that cannot mislead: it resolves + // itself the moment a completion arrives. + renderHook(() => useThoughtStreamToolListener()); + + act(() => { + toolHandler?.(BATCH, { toolName: 'Bash', timestamp: 1000 }); + }); + + expect(toolEvents()[0].tool.status).toBe('running'); + }); + + it('interleaves with reasoning on ONE timeline, in arrival order', () => { + renderHook(() => useThoughtStreamToolListener()); + const { appendThought } = useThoughtStreamStore.getState(); + + act(() => { + appendThought(SESSION_ID, BATCH, 'let me check the tests '); + toolHandler?.(BATCH, { + toolName: 'Bash', + state: { status: 'completed', input: { command: 'npm test' } }, + timestamp: 2000, + }); + appendThought(SESSION_ID, BATCH, 'they passed'); + }); + + const entries = useThoughtStreamStore.getState().buffers[SESSION_ID].entries; + expect(entries.map(isToolEvent)).toEqual([false, true, false]); + }); + + it('keeps parallel runs in their own buffers', () => { + renderHook(() => useThoughtStreamToolListener()); + + act(() => { + toolHandler?.(BATCH, { toolName: 'Bash', state: { status: 'running' }, timestamp: 1 }); + toolHandler?.('other-session-batch-1700000000000', { + toolName: 'Read', + state: { status: 'running' }, + timestamp: 2, + }); + }); + + expect(toolEvents().map((e) => e.tool.name)).toEqual(['Bash']); + expect(toolEvents('other-session').map((e) => e.tool.name)).toEqual(['Read']); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useThoughtStreamToolListener()); + act(() => unmount()); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/renderer/hooks/useAgentListeners.test.ts b/src/__tests__/renderer/hooks/useAgentListeners.test.ts index 37f3a416c8..d0759a4d88 100644 --- a/src/__tests__/renderer/hooks/useAgentListeners.test.ts +++ b/src/__tests__/renderer/hooks/useAgentListeners.test.ts @@ -123,7 +123,12 @@ const mockProcess = { return mockUnsubscribeSshRemote; }), onToolExecution: vi.fn((handler: ListenerCallback) => { - onToolExecutionHandler = handler; + // TWO subscribers, as with onThinkingChunk: useAgentToolExecutionListener + // (writes tool cells into a tab's logs) registers FIRST, then + // useThoughtStreamToolListener (feeds the Thought Stream's action feed). + // The tests below drive the transcript listener, so keep the first + // registration rather than letting the later one overwrite it. + onToolExecutionHandler ??= handler; return mockUnsubscribeToolExecution; }), onUserInput: vi.fn((handler: ListenerCallback) => { @@ -257,11 +262,16 @@ describe('getErrorTitleForType', () => { describe('useAgentListeners', () => { describe('listener registration', () => { - // onThinkingChunk has TWO subscribers: useAgentThinkingListener (records to - // tab logs, gated by showThinking) and useThoughtStreamCaptureListener (feeds - // the Thought Stream panel, independent of showThinking). So 11 channels but - // 12 subscriptions, with thinking-chunk subscribed twice. - it('registers all IPC listeners on mount (thinking-chunk subscribed twice)', () => { + // Two channels carry TWO subscribers each, for the same reason: the + // transcript needs the event gated by a tab's display settings, and the + // Thought Stream needs it raw. + // - onThinkingChunk: useAgentThinkingListener (tab logs, gated by + // showThinking) + useThoughtStreamCaptureListener (panel, ungated). + // - onToolExecution: useAgentToolExecutionListener (tab logs) + + // useThoughtStreamToolListener (panel's action feed, and the only + // surface that sees an Auto Run's tool calls at all). + // So 11 channels but 13 subscriptions. + it('registers all IPC listeners on mount (thinking-chunk and tool-execution twice)', () => { const deps = createMockDeps(); renderHook(() => useAgentListeners(deps)); @@ -275,10 +285,10 @@ describe('useAgentListeners', () => { expect(mockProcess.onAgentError).toHaveBeenCalledTimes(1); expect(mockProcess.onThinkingChunk).toHaveBeenCalledTimes(2); expect(mockProcess.onSshRemote).toHaveBeenCalledTimes(1); - expect(mockProcess.onToolExecution).toHaveBeenCalledTimes(1); + expect(mockProcess.onToolExecution).toHaveBeenCalledTimes(2); }); - it('unsubscribes all listeners on unmount (thinking-chunk twice)', () => { + it('unsubscribes all listeners on unmount (thinking-chunk and tool-execution twice)', () => { const deps = createMockDeps(); const { unmount } = renderHook(() => useAgentListeners(deps)); @@ -294,7 +304,7 @@ describe('useAgentListeners', () => { expect(mockUnsubscribeAgentError).toHaveBeenCalledTimes(1); expect(mockUnsubscribeThinkingChunk).toHaveBeenCalledTimes(2); expect(mockUnsubscribeSshRemote).toHaveBeenCalledTimes(1); - expect(mockUnsubscribeToolExecution).toHaveBeenCalledTimes(1); + expect(mockUnsubscribeToolExecution).toHaveBeenCalledTimes(2); }); it('does not register listeners twice on re-render', () => { diff --git a/src/__tests__/renderer/stores/thoughtStreamStore.test.ts b/src/__tests__/renderer/stores/thoughtStreamStore.test.ts index 829e73d8e0..591ceea306 100644 --- a/src/__tests__/renderer/stores/thoughtStreamStore.test.ts +++ b/src/__tests__/renderer/stores/thoughtStreamStore.test.ts @@ -6,21 +6,26 @@ * - ambient capture (thoughts buffer with no panel ever opened) * - closing keeps the buffer; only clearBuffer discards * - per-session entry cap, per-session character cap, session LRU eviction - * - the selectThoughtCount / live selectors + * - the selectActivityCount / live selectors */ import { describe, it, expect, beforeEach } from 'vitest'; import { useThoughtStreamStore, - selectThoughtCount, + selectActivityCount, selectLastAppendAt, isThoughtStreamLive, groupThoughtsIntoBlocks, + buildActivityFeed, + isToolEvent, THOUGHT_BLOCK_GAP_MS, THOUGHT_LIVE_WINDOW_MS, MAX_THOUGHTS_PER_SESSION, MAX_THOUGHT_CHARS_PER_SESSION, MAX_CAPTURED_SESSIONS, type ThoughtEntry, + type StreamEvent, + type ToolActivityEntry, + type ToolActivityStatus, } from '../../../renderer/stores/thoughtStreamStore'; const SID = 'session-1'; @@ -258,13 +263,13 @@ describe('thoughtStreamStore', () => { }); }); - it('selectThoughtCount reports how much is buffered', () => { + it('selectActivityCount reports how much is buffered', () => { const store = useThoughtStreamStore.getState(); - expect(selectThoughtCount(SID)(useThoughtStreamStore.getState())).toBe(0); - expect(selectThoughtCount(undefined)(useThoughtStreamStore.getState())).toBe(0); + expect(selectActivityCount(SID)(useThoughtStreamStore.getState())).toBe(0); + expect(selectActivityCount(undefined)(useThoughtStreamStore.getState())).toBe(0); store.appendThought(SID, TAB, 'a'); store.appendThought(SID, TAB, 'b'); - expect(selectThoughtCount(SID)(useThoughtStreamStore.getState())).toBe(2); + expect(selectActivityCount(SID)(useThoughtStreamStore.getState())).toBe(2); }); it('selectLastAppendAt drives the live indicator', () => { @@ -335,3 +340,242 @@ describe('groupThoughtsIntoBlocks', () => { expect(blocks).toHaveLength(2); }); }); + +/** + * The action half of the timeline. + * + * The design constraint these pin down: reasoning and tool calls live in ONE + * array in arrival order. A previous attempt at this feature kept them in two + * lists and merged at display time, which cannot place a tool call inside a + * block of reasoning - the block has a single timestamp, so a fast tool call + * rendered AFTER the reasoning that actually followed it. For a feed whose + * whole purpose is "watch what the agent is doing, in order", that is the bug. + */ +describe('tool activity capture', () => { + beforeEach(reset); + + const label = (verb: string, target = '') => ({ verb, target }); + + function tool( + sessionId: string, + tabId: string, + toolName: string, + status: ToolActivityStatus, + extra: { toolCallId?: string; timestamp?: number; target?: string } = {} + ) { + useThoughtStreamStore.getState().appendToolActivity(sessionId, tabId, { + toolName, + label: label(toolName, extra.target ?? ''), + status, + toolCallId: extra.toolCallId, + timestamp: extra.timestamp, + }); + } + + it('records a tool call onto the same timeline as the reasoning', () => { + const store = useThoughtStreamStore.getState(); + store.appendThought(SID, TAB, 'thinking '); + tool(SID, TAB, 'Bash', 'running'); + + const { entries } = useThoughtStreamStore.getState().buffers[SID]; + expect(entries).toHaveLength(2); + expect(isToolEvent(entries[0])).toBe(false); + expect(isToolEvent(entries[1])).toBe(true); + }); + + it('ignores an event with no tool name', () => { + tool(SID, TAB, ' ', 'running'); + expect(useThoughtStreamStore.getState().buffers[SID]).toBeUndefined(); + }); + + it('merges a completion into the running entry by toolCallId', () => { + tool(SID, TAB, 'Bash', 'running', { toolCallId: 'call-1', timestamp: 1000 }); + tool(SID, TAB, 'Bash', 'completed', { toolCallId: 'call-1', timestamp: 5000 }); + + const { entries } = useThoughtStreamStore.getState().buffers[SID]; + // One ACTION, not two state transitions. + expect(entries).toHaveLength(1); + const event = entries[0] as ToolActivityEntry; + expect(event.tool.status).toBe('completed'); + expect(event.tool.endedAt).toBe(5000); + }); + + it('keeps the START timestamp when a call completes, so the row does not jump', () => { + tool(SID, TAB, 'Bash', 'running', { toolCallId: 'call-1', timestamp: 1000 }); + tool(SID, TAB, 'Bash', 'completed', { toolCallId: 'call-1', timestamp: 90_000 }); + + const event = useThoughtStreamStore.getState().buffers[SID].entries[0] as ToolActivityEntry; + expect(event.timestamp).toBe(1000); + }); + + it('merges into its ORIGINAL slot, leaving later events after it', () => { + const store = useThoughtStreamStore.getState(); + tool(SID, TAB, 'Bash', 'running', { toolCallId: 'call-1', timestamp: 1000 }); + store.appendThought(SID, TAB, 'while that runs'); + tool(SID, TAB, 'Bash', 'completed', { toolCallId: 'call-1', timestamp: 9000 }); + + const { entries } = useThoughtStreamStore.getState().buffers[SID]; + expect(entries).toHaveLength(2); + // The completed call stays FIRST - a long build must not leapfrog the + // reasoning that happened while it ran. + expect(isToolEvent(entries[0])).toBe(true); + expect((entries[0] as ToolActivityEntry).tool.status).toBe('completed'); + expect(isToolEvent(entries[1])).toBe(false); + }); + + it('merges by newest running call of the same name when the provider sends no id', () => { + tool(SID, TAB, 'shell', 'running', { timestamp: 1000 }); + tool(SID, TAB, 'shell', 'running', { timestamp: 2000 }); + tool(SID, TAB, 'shell', 'failed', { timestamp: 3000 }); + + const { entries } = useThoughtStreamStore.getState().buffers[SID]; + expect(entries).toHaveLength(2); + // The NEWEST running call is the one that finished. + expect((entries[0] as ToolActivityEntry).tool.status).toBe('running'); + expect((entries[1] as ToolActivityEntry).tool.status).toBe('failed'); + }); + + it('never merges a completion across tabs', () => { + tool(SID, 'tab-a', 'shell', 'running', { timestamp: 1000 }); + tool(SID, 'tab-b', 'shell', 'completed', { timestamp: 2000 }); + + const { entries } = useThoughtStreamStore.getState().buffers[SID]; + expect(entries).toHaveLength(2); + expect((entries[0] as ToolActivityEntry).tool.status).toBe('running'); + }); + + it('appends a completion with no matching start rather than dropping it', () => { + tool(SID, TAB, 'Read', 'completed', { timestamp: 1000 }); + expect(useThoughtStreamStore.getState().buffers[SID].entries).toHaveLength(1); + }); + + it('prefers a descriptive label when the running event carried no target', () => { + useThoughtStreamStore.getState().appendToolActivity(SID, TAB, { + toolName: 'Bash', + label: { verb: 'Ran', target: '' }, + status: 'running', + toolCallId: 'c1', + }); + useThoughtStreamStore.getState().appendToolActivity(SID, TAB, { + toolName: 'Bash', + label: { verb: 'Ran', target: 'npm test' }, + status: 'completed', + toolCallId: 'c1', + }); + const event = useThoughtStreamStore.getState().buffers[SID].entries[0] as ToolActivityEntry; + expect(event.tool.label.target).toBe('npm test'); + }); + + it('counts tool calls toward the timeline cap', () => { + for (let i = 0; i < MAX_THOUGHTS_PER_SESSION + 5; i++) { + tool(SID, TAB, `tool-${i}`, 'completed', { timestamp: 1000 + i }); + } + const buf = useThoughtStreamStore.getState().buffers[SID]; + expect(buf.entries).toHaveLength(MAX_THOUGHTS_PER_SESSION); + expect(buf.trimmed).toBe(true); + }); + + it('a tool call keeps the session out of LRU eviction', () => { + tool(SID, TAB, 'Bash', 'running', { timestamp: 5000 }); + expect(selectActivityCount(SID)(useThoughtStreamStore.getState())).toBe(1); + expect(selectLastAppendAt(SID)(useThoughtStreamStore.getState())).toBe(5000); + }); +}); + +/** + * buildActivityFeed - the ordering guarantee. + * + * These are the regression tests for the defect that sank the first attempt at + * this feature: a tool call that happens in the MIDDLE of a run of reasoning + * has to render between the two halves of it. That is only expressible because + * both kinds of event come off one array in arrival order. + */ +describe('buildActivityFeed', () => { + const toolEvent = ( + id: string, + timestamp: number, + name: string, + tabId = TAB, + status: ToolActivityStatus = 'completed' + ): StreamEvent => ({ + id, + timestamp, + tabId, + tool: { name, label: { verb: name, target: '' }, status }, + }); + + it('returns an empty feed for no events', () => { + expect(buildActivityFeed([])).toEqual([]); + }); + + it('splits a block of reasoning around a tool call that interrupted it', () => { + // All three thoughts are inside the gap window, so WITHOUT the tool they + // would coalesce into a single block and the call would have nowhere to go. + const feed = buildActivityFeed([ + entry('t1', 1000, 'I should check the tests. '), + toolEvent('x1', 1100, 'Bash'), + entry('t2', 1200, 'They passed, so '), + entry('t3', 1300, 'the bug is elsewhere.'), + ]); + + expect(feed.map((i) => i.kind)).toEqual(['thought', 'tool', 'thought']); + expect(feed[0].kind === 'thought' && feed[0].block.text).toBe('I should check the tests. '); + expect(feed[1].kind === 'tool' && feed[1].activity.id).toBe('x1'); + // The reasoning AFTER the call is its own block, below it. + expect(feed[2].kind === 'thought' && feed[2].block.text).toBe( + 'They passed, so the bug is elsewhere.' + ); + }); + + it('renders a fast tool call before the reasoning that followed it', () => { + // The exact failure of the two-list design: `x1` is 1ms after the block + // started, so a feed sorted by block START time buried it underneath. + const feed = buildActivityFeed([ + entry('t1', 1000, 'first'), + toolEvent('x1', 1001, 'Read'), + entry('t2', 1002, 'second'), + ]); + const toolIndex = feed.findIndex((i) => i.kind === 'tool'); + const afterIndex = feed.findIndex( + (i) => i.kind === 'thought' && i.block.text.includes('second') + ); + expect(toolIndex).toBeLessThan(afterIndex); + }); + + it('keeps consecutive tool calls as separate rows in order', () => { + const feed = buildActivityFeed([ + toolEvent('x1', 1000, 'Read'), + toolEvent('x2', 1100, 'Bash'), + toolEvent('x3', 1200, 'Edit'), + ]); + expect(feed).toHaveLength(3); + expect(feed.map((i) => (i.kind === 'tool' ? i.activity.tool.name : null))).toEqual([ + 'Read', + 'Bash', + 'Edit', + ]); + }); + + it('is emitted oldest-first so the panel can reverse for newest-on-top', () => { + const feed = buildActivityFeed([entry('t1', 1000, 'old'), toolEvent('x1', 2000, 'Bash')]); + expect(feed[0].kind).toBe('thought'); + expect(feed[1].kind).toBe('tool'); + }); + + it('still coalesces reasoning when no tool interrupts it', () => { + const feed = buildActivityFeed([entry('a', 1000, 'one '), entry('b', 1500, 'two')]); + expect(feed).toHaveLength(1); + expect(feed[0].kind === 'thought' && feed[0].block.text).toBe('one two'); + }); + + it('groupThoughtsIntoBlocks projects the same walk, dropping tool rows', () => { + const events: StreamEvent[] = [ + entry('t1', 1000, 'before '), + toolEvent('x1', 1100, 'Bash'), + entry('t2', 1200, 'after'), + ]; + // A tool call still SPLITS the reasoning here, because both views are the + // one walk - they can never disagree about where a block starts. + expect(groupThoughtsIntoBlocks(events).map((b) => b.text)).toEqual(['before ', 'after']); + }); +}); diff --git a/src/__tests__/renderer/utils/toolActivityLabel.test.ts b/src/__tests__/renderer/utils/toolActivityLabel.test.ts new file mode 100644 index 0000000000..412a707e66 --- /dev/null +++ b/src/__tests__/renderer/utils/toolActivityLabel.test.ts @@ -0,0 +1,178 @@ +/** + * toolActivityLabel tests + * + * The live activity feed's whole value is that a user can scan it, so the + * per-provider tool-name variance (Claude Code `Read`/`Bash`, OpenCode lowercase + * `read`/`bash`, Codex `shell`/`apply_patch`/`update_plan`, Copilot + * `write_to_file`, MCP `mcp__server__tool`) must all collapse to plain English, + * and an unrecognized tool must still produce a usable line rather than nothing. + */ +import { describe, it, expect } from 'vitest'; +import { describeToolActivity } from '../../../renderer/utils/toolActivityLabel'; + +describe('describeToolActivity', () => { + describe('file reads', () => { + it('labels Claude Code Read with the file path', () => { + expect(describeToolActivity('Read', { file_path: 'src/App.tsx' })).toEqual({ + verb: 'Read', + target: 'src/App.tsx', + }); + }); + + it('labels OpenCode lowercase read via its `path` key', () => { + expect(describeToolActivity('read', { path: 'README.md' })).toEqual({ + verb: 'Read', + target: 'README.md', + }); + }); + + it('truncates a very long path from the left, keeping the filename', () => { + const long = `/Users/someone/${'nested/'.repeat(20)}target.ts`; + const { verb, target } = describeToolActivity('Read', { file_path: long }); + expect(verb).toBe('Read'); + expect(target.length).toBeLessThanOrEqual(72); + expect(target).toContain('target.ts'); + }); + }); + + describe('shell commands', () => { + it('labels Bash with the command string', () => { + expect( + describeToolActivity('Bash', { command: 'npm test', description: 'Run tests' }) + ).toEqual({ verb: 'Ran', target: 'npm test' }); + }); + + it('joins an argv-array command (Codex/OpenCode shape)', () => { + expect(describeToolActivity('shell', { command: ['npm', 'run', 'lint'] })).toEqual({ + verb: 'Ran', + target: 'npm run lint', + }); + }); + + it('collapses a multi-line command onto one line', () => { + const { target } = describeToolActivity('Bash', { command: 'cd /tmp\nls -la' }); + expect(target).not.toContain('\n'); + }); + + it('labels BashOutput and KillShell with no target', () => { + expect(describeToolActivity('BashOutput', { bash_id: 'x' })).toEqual({ + verb: 'Checked background output', + target: '', + }); + expect(describeToolActivity('KillShell', { shell_id: 'x' })).toEqual({ + verb: 'Stopped a background command', + target: '', + }); + }); + }); + + describe('edits and writes', () => { + it('labels Edit and MultiEdit as Edited', () => { + expect(describeToolActivity('Edit', { file_path: 'a.ts' }).verb).toBe('Edited'); + expect(describeToolActivity('MultiEdit', { file_path: 'a.ts' }).verb).toBe('Edited'); + }); + + it('labels Copilot write_to_file as Wrote', () => { + expect(describeToolActivity('write_to_file', { path: 'out.txt' })).toEqual({ + verb: 'Wrote', + target: 'out.txt', + }); + }); + + it('falls back to the patch body when apply_patch sends a bare string', () => { + // Codex delivers apply_patch as one raw diff string with no path field; + // iterating it as an object would emit character-by-character garbage. + const { verb, target } = describeToolActivity('apply_patch', '*** Update File: src/a.ts'); + expect(verb).toBe('Edited'); + expect(target).toContain('src/a.ts'); + }); + + it('labels NotebookEdit distinctly', () => { + expect(describeToolActivity('NotebookEdit', { notebook_path: 'nb.ipynb' })).toEqual({ + verb: 'Edited notebook', + target: 'nb.ipynb', + }); + }); + }); + + describe('search and web', () => { + it('labels Grep with its pattern', () => { + expect(describeToolActivity('Grep', { pattern: 'TODO' })).toEqual({ + verb: 'Searched for', + target: 'TODO', + }); + }); + + it('labels Glob distinctly from Grep', () => { + expect(describeToolActivity('Glob', { pattern: '**/*.ts' })).toEqual({ + verb: 'Looked for files matching', + target: '**/*.ts', + }); + }); + + it('labels WebFetch with the URL and WebSearch with the query', () => { + expect(describeToolActivity('WebFetch', { url: 'https://example.com' })).toEqual({ + verb: 'Fetched', + target: 'https://example.com', + }); + expect(describeToolActivity('WebSearch', { query: 'electron ipc' })).toEqual({ + verb: 'Searched the web for', + target: 'electron ipc', + }); + }); + }); + + describe('planning and delegation', () => { + it('summarizes TodoWrite as the in-progress task plus a progress count', () => { + expect( + describeToolActivity('TodoWrite', { + todos: [ + { content: 'one', status: 'completed' }, + { content: 'two', activeForm: 'Doing two', status: 'in_progress' }, + { content: 'three', status: 'pending' }, + ], + }) + ).toEqual({ verb: 'Updated the task list', target: 'Doing two (1/3)' }); + }); + + it('handles Codex update_plan, which uses `plan` instead of `todos`', () => { + expect( + describeToolActivity('update_plan', { + plan: [{ step: 'Investigate', status: 'in_progress' }], + }) + ).toEqual({ verb: 'Updated the task list', target: 'Investigate (0/1)' }); + }); + + it('labels Task with its description', () => { + expect( + describeToolActivity('Task', { description: 'Audit the parsers', prompt: 'long prompt' }) + ).toEqual({ verb: 'Delegated to a subagent', target: 'Audit the parsers' }); + }); + }); + + describe('MCP and unknown tools', () => { + it('splits an MCP tool name into server and tool', () => { + expect(describeToolActivity('mcp__linear__create_issue', {})).toEqual({ + verb: 'Called linear', + target: 'create issue', + }); + }); + + it('handles an MCP server name containing underscores', () => { + expect(describeToolActivity('mcp__my_server__do_thing', {}).verb).toBe('Called my_server'); + }); + + it('still produces a line for an unrecognized tool', () => { + expect(describeToolActivity('SomeNewTool', { file_path: 'x.ts' })).toEqual({ + verb: 'Used SomeNewTool', + target: 'x.ts', + }); + }); + + it('never throws on a missing name or a null input', () => { + expect(describeToolActivity('', null)).toEqual({ verb: 'Used a tool', target: '' }); + expect(describeToolActivity('Read', undefined)).toEqual({ verb: 'Read', target: '' }); + expect(describeToolActivity('Bash', [1, 2, 3])).toEqual({ verb: 'Ran', target: '' }); + }); + }); +}); diff --git a/src/renderer/components/AutoRun/AutoRun.tsx b/src/renderer/components/AutoRun/AutoRun.tsx index b704d7a4f1..6bb2550d88 100644 --- a/src/renderer/components/AutoRun/AutoRun.tsx +++ b/src/renderer/components/AutoRun/AutoRun.tsx @@ -54,7 +54,7 @@ import { AutoRunHumanStepBanner } from './AutoRunHumanStepBanner'; import { AutoRunBottomPanel } from './AutoRunBottomPanel'; import { NoFolderState, EmptyFolderState } from './AutoRunEmptyStates'; import { useBatchStore } from '../../stores/batchStore'; -import { useThoughtStreamStore, selectThoughtCount } from '../../stores/thoughtStreamStore'; +import { useThoughtStreamStore, selectActivityCount } from '../../stores/thoughtStreamStore'; import { AutoRunAttachmentsPanel } from './AutoRunAttachmentsPanel'; import { useTemplateAutocomplete, useAutoRunUndo, useAutoRunImageHandling } from '../../hooks'; import { TemplateAutocompleteDropdown } from '../TemplateAutocompleteDropdown'; @@ -147,7 +147,7 @@ const AutoRunInner = forwardRef(function AutoRunInn // point here for as long as there is something buffered to read. const thoughtStreamSessionId = useThoughtStreamStore((s) => s.panelSessionId); const openThoughtStream = useThoughtStreamStore((s) => s.openPanel); - const bufferedThoughts = useThoughtStreamStore(selectThoughtCount(sessionId)); + const bufferedThoughts = useThoughtStreamStore(selectActivityCount(sessionId)); const showOpenThoughtStream = !isAutoRunActive && bufferedThoughts > 0 && thoughtStreamSessionId !== sessionId; // Error state (Phase 5.10) diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 4f615e4e22..0118208b2a 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -34,7 +34,7 @@ import { useUIStore } from '../stores/uiStore'; import { useSettingsStore } from '../stores/settingsStore'; import { useFileExplorerStore } from '../stores/fileExplorerStore'; import { useBatchStore } from '../stores/batchStore'; -import { useThoughtStreamStore, selectThoughtCount } from '../stores/thoughtStreamStore'; +import { useThoughtStreamStore, selectActivityCount } from '../stores/thoughtStreamStore'; import { useSessionStore, selectActiveSession } from '../stores/sessionStore'; import { useWindowOwnsSession } from '../contexts/WindowContext'; import type { FileNode } from '../types/fileTree'; @@ -186,7 +186,7 @@ export const RightPanel = memo( // buffered and waiting to be read - clicking opens (or re-expands) the // panel on that history. There is no separate floating pill. const openThoughtStream = useThoughtStreamStore((s) => s.openPanel); - const bufferedThoughts = useThoughtStreamStore(selectThoughtCount(sessionId)); + const bufferedThoughts = useThoughtStreamStore(selectActivityCount(sessionId)); // === Props (domain-hook handlers + theme + batch state + refs) === const { diff --git a/src/renderer/components/ThoughtStreamPanel.tsx b/src/renderer/components/ThoughtStreamPanel.tsx index 18f74e442b..71169b01ea 100644 --- a/src/renderer/components/ThoughtStreamPanel.tsx +++ b/src/renderer/components/ThoughtStreamPanel.tsx @@ -21,15 +21,17 @@ */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Brain, Search, Trash2, X } from 'lucide-react'; +import { AlertTriangle, Brain, Check, Loader2, Search, Trash2, X } from 'lucide-react'; import type { Theme } from '../types'; import { useThoughtStreamStore, - groupThoughtsIntoBlocks, + buildActivityFeed, isThoughtStreamLive, + isToolEvent, THOUGHT_LIVE_WINDOW_MS, - type ThoughtEntry, - type ThoughtBlock, + type ActivityFeedItem, + type StreamEvent, + type ToolActivityEntry, } from '../stores/thoughtStreamStore'; import { useSessionStore } from '../stores/sessionStore'; import { useSettingsStore } from '../stores/settingsStore'; @@ -52,6 +54,90 @@ function formatThoughtTime(ts: number): string { }); } +/** The full one-line rendering of a tool call ("Ran npm test"). */ +function toolActivityText(activity: ToolActivityEntry): string { + const { verb, target } = activity.tool.label; + return target ? `${verb} ${target}` : verb; +} + +/** + * One tool call as a single scannable line: status glyph, verb, target. + * + * The text is rendered as PLAIN TEXT, not markdown. A shell command or a glob + * pattern is full of characters markdown claims (`*`, `_`, backticks), so + * running it through the renderer mangles exactly the lines a user is trying to + * read. Search highlighting is therefore done here rather than delegated. + */ +function ToolActivityRow({ + activity, + theme, + query, +}: { + activity: ToolActivityEntry; + theme: Theme; + query: string; +}) { + const { status } = activity.tool; + const text = toolActivityText(activity); + const color = + status === 'failed' + ? theme.colors.error + : status === 'running' + ? theme.colors.accent + : theme.colors.textDim; + + return ( +
+ + {formatThoughtTime(activity.timestamp)} + + + {status === 'running' ? ( + + ) : status === 'failed' ? ( + + ) : ( + + )} + + + {highlightQuery(text, query, theme)} + +
+ ); +} + +/** + * Split `text` on the search query and wrap the matches. Case-insensitive and + * literal (the query is user text, never a pattern). + */ +function highlightQuery(text: string, query: string, theme: Theme) { + const q = query.trim(); + if (!q) return text; + const lower = text.toLowerCase(); + const needle = q.toLowerCase(); + const parts: React.ReactNode[] = []; + let from = 0; + for (;;) { + const at = lower.indexOf(needle, from); + if (at === -1) break; + if (at > from) parts.push(text.slice(from, at)); + parts.push( + + {text.slice(at, at + needle.length)} + + ); + from = at + needle.length; + } + if (parts.length === 0) return text; + if (from < text.length) parts.push(text.slice(from)); + return parts; +} + export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { const panelSessionId = useThoughtStreamStore((s) => s.panelSessionId); const buffer = useThoughtStreamStore((s) => @@ -76,7 +162,7 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { // pinned to the TOP of the scroll area, not the bottom. const stickToTopRef = useRef(true); - const entries: ThoughtEntry[] = useMemo(() => buffer?.entries ?? [], [buffer]); + const entries: StreamEvent[] = useMemo(() => buffer?.entries ?? [], [buffer]); const trimmed = buffer?.trimmed ?? false; const lastAppendAt = buffer?.lastAppendAt ?? 0; @@ -94,10 +180,18 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { return () => clearTimeout(timer); }, [lastAppendAt]); - // Group the granular per-flush entries into timestamped blocks, then show - // newest-first (the live block sits at the top and grows; older blocks scroll - // down into history). - const blocks: ThoughtBlock[] = useMemo(() => groupThoughtsIntoBlocks(entries), [entries]); + // One walk of the session's timeline produces the whole feed: granular + // thinking flushes coalesced into timestamped blocks, tool calls sitting + // between the reasoning they interrupted. Displayed newest-first (the live + // row sits at the top; older rows scroll down into history). + const feed: ActivityFeedItem[] = useMemo(() => buildActivityFeed(entries), [entries]); + + // Header counts. Tool calls and reasoning are counted separately because + // they answer different questions: "is it still thinking" vs "is it actually + // doing anything", and a run stuck in a loop shows a climbing action count + // against flat reasoning. + const thoughtCount = useMemo(() => feed.filter((i) => i.kind === 'thought').length, [feed]); + const actionCount = useMemo(() => entries.filter(isToolEvent).length, [entries]); const searching = query.trim().length > 0; @@ -108,12 +202,21 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { [theme] ); - const visibleBlocks = useMemo(() => { + const visibleFeed = useMemo(() => { const q = query.trim().toLowerCase(); - const matched = q ? blocks.filter((b) => b.text.toLowerCase().includes(q)) : blocks; + const matched = q + ? feed.filter((item) => + item.kind === 'thought' + ? item.block.text.toLowerCase().includes(q) + : // Match the rendered line AND the raw provider tool name, so + // searching "Bash" finds calls the feed renders as "Ran ...". + toolActivityText(item.activity).toLowerCase().includes(q) || + item.activity.tool.name.toLowerCase().includes(q) + ) + : feed; // Reverse a copy for newest-on-top display without mutating the memoized list. return [...matched].reverse(); - }, [blocks, query]); + }, [feed, query]); // Escape closes the panel. Nothing is lost by that now: the buffer outlives // the panel, so Escape is a "put it away", not a discard. @@ -132,11 +235,10 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { if (!stickToTopRef.current) return; const el = scrollRef.current; if (el) el.scrollTop = 0; - }, [visibleBlocks, searching]); + }, [visibleFeed, searching]); if (!panelSessionId) return null; - const totalCount = entries.length; const label = sessionName || `${panelSessionId.slice(0, 8)}`; // The Thought Stream lives inside the Right Panel, so it folds away with it: @@ -187,7 +289,8 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { style={{ color: theme.colors.textDim }} title={label} > - {label} · {totalCount} thought{totalCount === 1 ? '' : 's'} + {label} · {thoughtCount} thought{thoughtCount === 1 ? '' : 's'} · {actionCount} action + {actionCount === 1 ? '' : 's'} {trimmed ? ' (trimmed)' : ''} {live ? ' · live' : ''} @@ -219,7 +322,7 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { type="text" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search thoughts..." + placeholder="Search activity..." className="flex-1 bg-transparent border-none outline-none text-xs" style={{ color: theme.colors.textMain }} /> @@ -246,39 +349,48 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { style={{ color: theme.colors.textMain }} > - {visibleBlocks.length === 0 ? ( + {visibleFeed.length === 0 ? (

{searching - ? 'No thoughts match your search.' - : 'Nothing captured yet. Thoughts are buffered as the agent thinks, so this fills in on its own.'} + ? 'Nothing matches your search.' + : 'Nothing captured yet. Thinking and tool calls are buffered as the agent works, so this fills in on its own.'}

) : (
- {visibleBlocks.map((block) => ( -
-
- {formatThoughtTime(block.startTimestamp)} -
-
- + {visibleFeed.map((item) => + item.kind === 'tool' ? ( + + ) : ( +
+
+ {formatThoughtTime(item.block.startTimestamp)} +
+
+ +
-
- ))} + ) + )}
)}
@@ -288,7 +400,7 @@ export function ThoughtStreamPanel({ theme }: ThoughtStreamPanelProps) { className="px-3 py-1.5 border-t text-[10px] shrink-0" style={{ borderColor: theme.colors.border, color: theme.colors.textDim }} > - {visibleBlocks.length} of {blocks.length} block{blocks.length === 1 ? '' : 's'} match + {visibleFeed.length} of {feed.length} entr{feed.length === 1 ? 'y' : 'ies'} match )} diff --git a/src/renderer/hooks/agent/internal/useThoughtStreamToolListener.ts b/src/renderer/hooks/agent/internal/useThoughtStreamToolListener.ts new file mode 100644 index 0000000000..7785f11922 --- /dev/null +++ b/src/renderer/hooks/agent/internal/useThoughtStreamToolListener.ts @@ -0,0 +1,111 @@ +/** + * useThoughtStreamToolListener - feeds the ACTION half of the Thought Stream. + * + * Subscribes to the same raw `process:tool-execution` IPC stream as + * `useAgentToolExecutionListener`, but routes events into `thoughtStreamStore` + * rather than into a tab's logs. Two things make that a separate listener + * rather than a second consumer of the existing one: + * + * 1. The in-chat listener resolves the streaming id with `REGEX_AI_TAB` alone, + * so an Auto Run - which spawns as `{sessionId}-batch-{timestamp}` - has + * every tool call dropped. That is less a bug in that listener than a + * consequence of what it does: it writes into `aiTabs[tabId].logs`, and a + * batch spawn has no tab to write into. An Auto Run therefore has no + * transcript that could carry its tool calls, which is exactly the gap this + * fills. + * 2. The transcript only RENDERS tool entries when the tab's tool-call + * visibility is on. The Thought Stream is explicitly the surface for + * watching a run you are not otherwise watching, so it must not inherit a + * display setting. + * + * Scope matches the thinking listener exactly - `AUTO_RUN_SESSION_TYPES`, i.e. + * Auto Run and nothing else - and imports that set rather than restating it. + * The two listeners feed ONE timeline, so any divergence in what they admit + * would interleave a run's actions with some other stream's reasoning. It also + * keeps the panel from filling with ordinary conversation, which is the + * over-capture that set is there to prevent. + * + * Capture is ambient, matching the thinking listener: a user opens this panel + * BECAUSE a run has been burning tokens for ten minutes, and a feed that only + * started recording at open time would hand them an empty list at exactly the + * moment the history is the answer. + * + * There is no rAF/timer coalescing here, unlike the thinking stream. Thinking + * arrives as a token-rate chunk firehose; tool calls arrive once per agent + * action, which is orders of magnitude slower and already the granularity the + * feed displays. + */ + +import { useEffect } from 'react'; +import { useThoughtStreamStore, type ToolActivityStatus } from '../../../stores/thoughtStreamStore'; +import { parseSessionId } from '../../../utils/sessionIdParser'; +import { describeToolActivity } from '../../../utils/toolActivityLabel'; +import { AUTO_RUN_SESSION_TYPES } from './useThoughtStreamCaptureListener'; +import { useOwnedSessionGate } from './useOwnedSessionGate'; + +/** The tool lifecycle payload, as providers deliver it over IPC. */ +interface ToolEventState { + status?: string; + input?: unknown; + output?: unknown; +} + +/** + * Normalize provider status wording onto the three states the feed renders. + * `error` and `failed` are the same outcome spelled two ways; anything absent + * or unrecognized means the call is still in flight, which is the reading that + * cannot mislead - it resolves itself the moment a completion arrives. + */ +function normalizeStatus(status: string | undefined): ToolActivityStatus { + if (status === 'completed') return 'completed'; + if (status === 'failed' || status === 'error') return 'failed'; + return 'running'; +} + +export function useThoughtStreamToolListener(): void { + const ownedGate = useOwnedSessionGate(); + + useEffect(() => { + const unsubscribe = window.maestro.process.onToolExecution?.( + ( + sessionId: string, + toolEvent: { + toolName: string; + state?: unknown; + timestamp: number; + toolCallId?: string; + } + ) => { + // Window scoping: ignore agents this window doesn't own (broadcast events). + if (!ownedGate.current?.(sessionId)) return; + + const parsed = parseSessionId(sessionId); + // Auto Run only - see AUTO_RUN_SESSION_TYPES. Adding a new Auto Run + // spawn shape means adding its type there, once, for both listeners. + if (!AUTO_RUN_SESSION_TYPES.has(parsed.type)) return; + + // Interactive tabs carry a real tabId; batch/synopsis spawns don't, so + // fall back to the full streaming id to keep parallel spawns distinct. + // This mirrors the thinking listener exactly, so a tool call and the + // reasoning around it land on the SAME timeline under the same tab key + // - which is what lets the feed interleave them. + const tabId = parsed.tabId ?? parsed.actualSessionId; + + const state = (toolEvent.state ?? undefined) as ToolEventState | undefined; + useThoughtStreamStore.getState().appendToolActivity(parsed.baseSessionId, tabId, { + toolName: toolEvent.toolName, + label: describeToolActivity(toolEvent.toolName, state?.input), + status: normalizeStatus(state?.status), + toolCallId: toolEvent.toolCallId, + // Use the provider's own timestamp so a call's position on the + // timeline reflects when it happened, not when we processed it. + timestamp: toolEvent.timestamp, + }); + } + ); + + return () => { + unsubscribe?.(); + }; + }, [ownedGate]); +} diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index 3d485c4d42..e5079b2a12 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -24,6 +24,7 @@ import { useAgentUsageListener } from './internal/useAgentUsageListener'; import { useAgentSessionIdListener } from './internal/useAgentSessionIdListener'; import { useAgentThinkingListener } from './internal/useAgentThinkingListener'; import { useThoughtStreamCaptureListener } from './internal/useThoughtStreamCaptureListener'; +import { useThoughtStreamToolListener } from './internal/useThoughtStreamToolListener'; import { useAgentSshRemoteListener } from './internal/useAgentSshRemoteListener'; import { useAgentClaudeModeResolvedListener } from './internal/useAgentClaudeModeResolvedListener'; import { useAgentToolExecutionListener } from './internal/useAgentToolExecutionListener'; @@ -98,6 +99,10 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { useAgentSshRemoteListener(); useAgentClaudeModeResolvedListener(); useAgentToolExecutionListener(); + // Second subscriber to the same tool stream. The transcript listener above + // writes into a tab's logs and so can only serve tab-shaped spawns; this one + // feeds the Thought Stream's action feed for Auto Run batch spawns too. + useThoughtStreamToolListener(); // Coordinator-level cleanup: clear the shared ref Map on unmount so any // orphan tool entries are released for GC. diff --git a/src/renderer/stores/thoughtStreamStore.ts b/src/renderer/stores/thoughtStreamStore.ts index 32e3140de1..5cf9b8a015 100644 --- a/src/renderer/stores/thoughtStreamStore.ts +++ b/src/renderer/stores/thoughtStreamStore.ts @@ -26,6 +26,15 @@ * that already, and a second dismiss control that behaves almost identically to * the first is just a choice the user has to think about. * + * The buffer is ONE chronological event list, not a thought list with a tool + * list beside it. Reasoning and tool calls are captured into the same array in + * arrival order, which is what lets the feed render a tool call BETWEEN the two + * halves of the reasoning that produced it. Keeping two sequences and merging + * them at display time cannot express that: a block is one timestamp, so a tool + * call that happened mid-block has nowhere to go and surfaces after reasoning + * that actually followed it. For a feature whose whole value is "watch what the + * agent is doing, in order", that ordering is the product. + * * Capture is in-memory only - buffers do not survive an app restart. Memory is * bounded on three axes so an all-day fleet of agents cannot grow without * limit: entries per session, characters per session, and how many sessions @@ -35,6 +44,7 @@ import { create } from 'zustand'; import { generateId } from '../utils/ids'; +import type { ToolActivityLabel } from '../utils/toolActivityLabel'; /** A single captured unit of thinking (one coalesced stream flush). */ export interface ThoughtEntry { @@ -45,9 +55,50 @@ export interface ThoughtEntry { text: string; } +/** + * How a tool call ended. `running` is also what we show when a provider sends + * no status at all - an unfinished call is the honest reading of "we saw it + * start and never saw it end". + */ +export type ToolActivityStatus = 'running' | 'completed' | 'failed'; + +/** + * A single tool call on the timeline, already reduced to one plain-language + * line. Discriminated from a ThoughtEntry by the presence of `tool`, so + * ThoughtEntry needs no marker field and every existing consumer of it still + * type-checks unchanged. + */ +export interface ToolActivityEntry { + id: string; + /** When the call STARTED. Preserved across the completion merge. */ + timestamp: number; + /** AI tab (or batch stream id) the call came from. */ + tabId: string; + tool: { + /** Raw provider tool name, kept so search can match it. */ + name: string; + /** The one-line rendering (verb + target). */ + label: ToolActivityLabel; + status: ToolActivityStatus; + /** Provider call id, when it sends one. Drives exact merge. */ + toolCallId?: string; + /** When the call finished, once it has. */ + endedAt?: number; + }; +} + +/** One event on a session's timeline: a unit of reasoning, or a tool call. */ +export type StreamEvent = ThoughtEntry | ToolActivityEntry; + +/** Narrow a timeline event to a tool call. */ +export function isToolEvent(event: StreamEvent): event is ToolActivityEntry { + return 'tool' in event; +} + /** Per-session capture buffer. */ export interface ThoughtBuffer { - entries: ThoughtEntry[]; + /** The session's single chronological timeline (thoughts AND tool calls). */ + entries: StreamEvent[]; /** True once a cap forced us to drop the oldest thoughts. */ trimmed: boolean; /** Running character total, maintained incrementally to keep trimming O(dropped). */ @@ -84,29 +135,72 @@ export interface ThoughtBlock { */ export const THOUGHT_BLOCK_GAP_MS = 3000; +/** One row of the rendered feed: a block of reasoning, or a tool call. */ +export type ActivityFeedItem = + | { kind: 'thought'; block: ThoughtBlock } + | { kind: 'tool'; activity: ToolActivityEntry }; + +/** + * Walk a session's timeline ONCE and produce the rendered feed (oldest-first; + * the caller reverses for newest-on-top display). + * + * Consecutive thinking coalesces into a block, exactly as before. What is new + * is that a tool call CLOSES the open block, so reasoning that arrived after an + * action starts a fresh block below it. That is the whole ordering guarantee: + * because both event kinds come off one array in arrival order, a tool call + * physically sits between the reasoning before it and the reasoning after it, + * and no sort can put it anywhere else. + * + * Closing on ANY tool call (not just the same tab's) matches the rule already + * in force for thoughts, where a chunk from a different tab also starts a new + * block. One rule, applied to every event. + * + * Pure - safe to memoize on the entries array. + */ +export function buildActivityFeed( + events: StreamEvent[], + gapMs: number = THOUGHT_BLOCK_GAP_MS +): ActivityFeedItem[] { + const feed: ActivityFeedItem[] = []; + for (const event of events) { + if (isToolEvent(event)) { + feed.push({ kind: 'tool', activity: event }); + continue; + } + const last = feed[feed.length - 1]; + // Only a thought block that is still the newest row can be extended. + const open = last && last.kind === 'thought' ? last.block : null; + if (open && open.tabId === event.tabId && event.timestamp - open.endTimestamp <= gapMs) { + open.text += event.text; + open.endTimestamp = event.timestamp; + } else { + feed.push({ + kind: 'thought', + block: { + id: event.id, + startTimestamp: event.timestamp, + endTimestamp: event.timestamp, + tabId: event.tabId, + text: event.text, + }, + }); + } + } + return feed; +} + /** - * Group a chronological entry list into display blocks (oldest-first). The - * caller reverses for newest-on-top display. Pure - safe to memoize on entries. + * Reasoning blocks only, for callers that render thinking without the actions. + * A thin projection of `buildActivityFeed` rather than a second grouping loop, + * so the two can never disagree about where a block starts. */ export function groupThoughtsIntoBlocks( - entries: ThoughtEntry[], + entries: StreamEvent[], gapMs: number = THOUGHT_BLOCK_GAP_MS ): ThoughtBlock[] { const blocks: ThoughtBlock[] = []; - for (const entry of entries) { - const last = blocks[blocks.length - 1]; - if (last && last.tabId === entry.tabId && entry.timestamp - last.endTimestamp <= gapMs) { - last.text += entry.text; - last.endTimestamp = entry.timestamp; - } else { - blocks.push({ - id: entry.id, - startTimestamp: entry.timestamp, - endTimestamp: entry.timestamp, - tabId: entry.tabId, - text: entry.text, - }); - } + for (const item of buildActivityFeed(entries, gapMs)) { + if (item.kind === 'thought') blocks.push(item.block); } return blocks; } @@ -139,6 +233,17 @@ export const MAX_CAPTURED_SESSIONS = 12; */ export const THOUGHT_LIVE_WINDOW_MS = 5000; +/** + * Memory a timeline event costs against the per-session character budget. A + * tool call is charged for its rendered line, so a run that is all actions and + * no reasoning is still bounded by the same budget. + */ +function eventCharCost(event: StreamEvent): number { + return isToolEvent(event) + ? event.tool.label.verb.length + event.tool.label.target.length + : event.text.length; +} + /** An empty buffer, used when a session thinks for the first time. */ function emptyBuffer(): ThoughtBuffer { return { entries: [], trimmed: false, chars: 0, lastAppendAt: 0 }; @@ -169,6 +274,73 @@ function evictColdSessions( return next; } +/** + * Append one event to a buffer and enforce both caps. Shared by the thinking + * and tool-call paths so a timeline cannot be trimmed by two different rules. + */ +function pushEvent(prev: ThoughtBuffer, event: StreamEvent): ThoughtBuffer { + let entries = prev.entries.concat(event); + let chars = prev.chars + eventCharCost(event); + let trimmed = prev.trimmed; + + if (entries.length > MAX_THOUGHTS_PER_SESSION) { + const dropCount = entries.length - MAX_THOUGHTS_PER_SESSION; + for (let i = 0; i < dropCount; i++) chars -= eventCharCost(entries[i]); + entries = entries.slice(dropCount); + trimmed = true; + } + // Drop whole oldest events until the character budget is met. The newest + // event always survives, even if it alone exceeds the budget. + let dropped = 0; + while (chars > MAX_THOUGHT_CHARS_PER_SESSION && dropped < entries.length - 1) { + chars -= eventCharCost(entries[dropped]); + dropped++; + } + if (dropped > 0) { + entries = entries.slice(dropped); + trimmed = true; + } + + return { entries, trimmed, chars, lastAppendAt: event.timestamp }; +} + +/** + * Find the timeline slot a finalizing tool event belongs to, mirroring the + * rules the in-chat transcript already uses: + * 1. an exact `toolCallId` match, for providers that send one; + * 2. otherwise the newest still-running call with the same tool name in the + * same tab (Codex and friends send no call id). + * Returns -1 when this is a call we have not seen start. + */ +function findMergeTarget( + entries: StreamEvent[], + tabId: string, + toolName: string, + toolCallId: string | undefined, + finalizing: boolean +): number { + if (toolCallId) { + for (let i = entries.length - 1; i >= 0; i--) { + const event = entries[i]; + if (isToolEvent(event) && event.tool.toolCallId === toolCallId) return i; + } + return -1; + } + if (!finalizing) return -1; + for (let i = entries.length - 1; i >= 0; i--) { + const event = entries[i]; + if ( + isToolEvent(event) && + event.tabId === tabId && + event.tool.name === toolName && + event.tool.status === 'running' + ) { + return i; + } + } + return -1; +} + interface ThoughtStreamState { /** Session whose panel is currently focused/visible (null = panel hidden). */ panelSessionId: string | null; @@ -181,6 +353,22 @@ interface ThoughtStreamState { closePanel: () => void; /** Append a coalesced thinking flush to a session's buffer. */ appendThought: (sessionId: string, tabId: string, text: string) => void; + /** + * Record a tool call on the same timeline as the reasoning. A completion is + * merged into the entry its start created, so the feed lists ACTIONS rather + * than state transitions. + */ + appendToolActivity: ( + sessionId: string, + tabId: string, + activity: { + toolName: string; + label: ToolActivityLabel; + status: ToolActivityStatus; + toolCallId?: string; + timestamp?: number; + } + ) => void; /** Discard a session's buffered thoughts (explicit user action). */ clearBuffer: (sessionId: string) => void; } @@ -205,35 +393,70 @@ export const useThoughtStreamStore = create((set) => ({ set((state) => { if (!text) return state; const prev = state.buffers[sessionId] ?? emptyBuffer(); - const timestamp = Date.now(); - const entry: ThoughtEntry = { id: generateId(), timestamp, tabId, text }; - - let entries = prev.entries.concat(entry); - let chars = prev.chars + text.length; - let trimmed = prev.trimmed; - - if (entries.length > MAX_THOUGHTS_PER_SESSION) { - const dropCount = entries.length - MAX_THOUGHTS_PER_SESSION; - for (let i = 0; i < dropCount; i++) chars -= entries[i].text.length; - entries = entries.slice(dropCount); - trimmed = true; - } - // Drop whole oldest entries until the character budget is met. The - // newest entry always survives, even if it alone exceeds the budget. - let dropped = 0; - while (chars > MAX_THOUGHT_CHARS_PER_SESSION && dropped < entries.length - 1) { - chars -= entries[dropped].text.length; - dropped++; - } - if (dropped > 0) { - entries = entries.slice(dropped); - trimmed = true; + const entry: ThoughtEntry = { id: generateId(), timestamp: Date.now(), tabId, text }; + const buffers = { ...state.buffers, [sessionId]: pushEvent(prev, entry) }; + return { buffers: evictColdSessions(buffers, [sessionId, state.panelSessionId]) }; + }), + + appendToolActivity: (sessionId, tabId, activity) => + set((state) => { + const toolName = (activity.toolName || '').trim(); + if (!toolName) return state; + const prev = state.buffers[sessionId] ?? emptyBuffer(); + const timestamp = activity.timestamp ?? Date.now(); + const finalizing = activity.status !== 'running'; + + const targetIdx = findMergeTarget( + prev.entries, + tabId, + toolName, + activity.toolCallId, + finalizing + ); + + if (targetIdx >= 0) { + const existing = prev.entries[targetIdx] as ToolActivityEntry; + // Merge IN PLACE: the entry keeps both its slot on the timeline and + // its START timestamp, so a call that finishes does not jump to the + // top of the feed or reorder the reasoning around it. + const merged: ToolActivityEntry = { + ...existing, + tool: { + ...existing.tool, + status: activity.status, + // A completion event often carries no input, so the running + // event's label is usually the descriptive one. Only take the + // new label when it actually says more. + label: + !existing.tool.label.target && activity.label.target + ? activity.label + : existing.tool.label, + toolCallId: existing.tool.toolCallId ?? activity.toolCallId, + ...(finalizing ? { endedAt: timestamp } : {}), + }, + }; + const entries = prev.entries.slice(); + entries[targetIdx] = merged; + const buffers = { + ...state.buffers, + [sessionId]: { ...prev, entries, lastAppendAt: timestamp }, + }; + return { buffers: evictColdSessions(buffers, [sessionId, state.panelSessionId]) }; } - const buffers = { - ...state.buffers, - [sessionId]: { entries, trimmed, chars, lastAppendAt: timestamp }, + const entry: ToolActivityEntry = { + id: generateId(), + timestamp, + tabId, + tool: { + name: toolName, + label: activity.label, + status: activity.status, + ...(activity.toolCallId ? { toolCallId: activity.toolCallId } : {}), + ...(finalizing ? { endedAt: timestamp } : {}), + }, }; + const buffers = { ...state.buffers, [sessionId]: pushEvent(prev, entry) }; return { buffers: evictColdSessions(buffers, [sessionId, state.panelSessionId]) }; }), @@ -243,8 +466,13 @@ export const useThoughtStreamStore = create((set) => ({ })), })); -/** Selector: how many thoughts are buffered for a session. */ -export function selectThoughtCount(sessionId: string | undefined | null) { +/** + * Selector: how many timeline events are buffered for a session (reasoning and + * tool calls together). This is what the "is there anything to look at" entry + * points gate on - an agent that only ran tools and never narrated still has a + * feed worth opening. + */ +export function selectActivityCount(sessionId: string | undefined | null) { return (state: ThoughtStreamState): number => sessionId ? (state.buffers[sessionId]?.entries.length ?? 0) : 0; } diff --git a/src/renderer/utils/toolActivityLabel.ts b/src/renderer/utils/toolActivityLabel.ts new file mode 100644 index 0000000000..d9f6dd5b77 --- /dev/null +++ b/src/renderer/utils/toolActivityLabel.ts @@ -0,0 +1,238 @@ +/** + * toolActivityLabel - turn a raw agent tool call into ONE short line of plain + * English ("Read src/App.tsx", "Ran npm test", "Edited themes.ts"). + * + * This is deliberately NOT `summarizeToolInput` + * (`components/TerminalOutput/utils/toolSummaries.ts`). That helper builds the + * verbose in-chat tool cell: every input key dumped as `key=value`, full + * untruncated command text, plus a separate output preview. It is the right + * thing for the chat transcript and the wrong thing for a live activity feed, + * where the whole point is that a user glancing at the panel can tell at a + * glance whether the agent is making progress or spinning in a loop. + * + * Tool names vary per provider (Claude Code `Read`/`Bash`/`Edit`, OpenCode + * lowercase `read`/`bash`, Codex `shell`/`apply_patch`/`update_plan`, Copilot + * `write_to_file`, MCP `mcp__server__tool`), so matching is done on a normalized + * name and every unknown tool still gets a usable "Used " line rather than + * being dropped. + */ + +import { truncateCommand, truncatePath } from '../../shared/formatters'; + +/** A tool call rendered as one short, human-readable line. */ +export interface ToolActivityLabel { + /** Past/present-tense verb phrase, e.g. `Read`, `Ran`, `Edited`. */ + verb: string; + /** What it acted on: a path, command, pattern, or URL. May be empty. */ + target: string; +} + +/** Max characters of `target` shown on the single line. */ +const MAX_TARGET_LENGTH = 72; + +/** + * Normalize a provider tool name for matching: lowercase and strip separators so + * `write_to_file`, `writeToFile`, and `WriteToFile` all collapse to one key. + */ +function normalizeToolName(toolName: string): string { + return toolName.toLowerCase().replace(/[-_\s]/g, ''); +} + +/** First string-valued key present on the input record, else undefined. */ +function firstString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +/** + * Coerce a command value to a string. Codex and OpenCode deliver `command` as an + * argv array (`['npm', 'test']`); Claude Code sends a single string. + */ +function commandString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === 'string')) { + return value.join(' '); + } + return undefined; +} + +/** + * Summarize a TodoWrite/update_plan payload as " (done/total)". + * Mirrors the in-chat summary so the two surfaces agree on wording. + */ +function todoSummary(value: unknown): string | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined; + const todos = value as Array<{ + content?: string; + status?: string; + activeForm?: string; + step?: string; + }>; + const completed = todos.filter((t) => t.status === 'completed').length; + const current = todos.find((t) => t.status === 'in_progress'); + const label = + current?.activeForm || current?.content || current?.step || todos[0]?.content || todos[0]?.step; + if (!label) return `${todos.length} tasks`; + return `${label} (${completed}/${todos.length})`; +} + +/** + * `mcp__linear__create_issue` -> `linear: create issue`. MCP tool names are + * server-namespaced and unreadable raw, but the two segments are meaningful. + */ +function mcpLabel(toolName: string): ToolActivityLabel | null { + const match = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(toolName); + if (!match) return null; + const [, server, tool] = match; + return { verb: `Called ${server}`, target: tool.replace(/_/g, ' ') }; +} + +/** + * Describe a tool call as a single plain-language line. + * + * @param toolName - Raw provider tool name (e.g. `Bash`, `apply_patch`). + * @param input - The tool's input payload. Some providers (Codex `apply_patch`, + * Copilot) send a raw string instead of an object; both are handled. + */ +export function describeToolActivity(toolName: string, input: unknown): ToolActivityLabel { + const name = (toolName || '').trim(); + const record: Record = + input && typeof input === 'object' && !Array.isArray(input) + ? (input as Record) + : {}; + // A raw-string input is the payload itself (a patch body, a command), so it + // stands in for whatever key the object form would have used. + const rawInput = typeof input === 'string' ? input.trim() : undefined; + + const filePath = firstString(record, [ + 'file_path', + 'filePath', + 'notebook_path', + 'notebookPath', + 'path', + 'file', + 'target_file', + ]); + const command = commandString(record.command ?? record.cmd ?? record.script) ?? rawInput; + const pattern = firstString(record, ['pattern', 'regex', 'query', 'search']); + const url = firstString(record, ['url', 'uri']); + + const shorten = (value: string | undefined, isPath: boolean): string => { + if (!value) return ''; + return isPath + ? truncatePath(value, MAX_TARGET_LENGTH) + : truncateCommand(value, MAX_TARGET_LENGTH); + }; + + const mcp = mcpLabel(name); + if (mcp) return mcp; + + switch (normalizeToolName(name)) { + case 'read': + case 'view': + case 'readfile': + case 'viewfile': + case 'catfile': + return { verb: 'Read', target: shorten(filePath, true) }; + + case 'write': + case 'writefile': + case 'writetofile': + case 'createfile': + case 'create': + return { verb: 'Wrote', target: shorten(filePath, true) }; + + case 'edit': + case 'multiedit': + case 'strreplace': + case 'strreplaceeditor': + case 'strreplacebasededittool': + case 'applypatch': + case 'patch': + case 'editfile': + // Codex sends apply_patch as one raw diff string with no path field; + // fall back to the patch body so the line is not left bare. + return { verb: 'Edited', target: shorten(filePath ?? rawInput, !!filePath) }; + + case 'notebookedit': + return { verb: 'Edited notebook', target: shorten(filePath, true) }; + + case 'bash': + case 'shell': + case 'sh': + case 'execcommand': + case 'localshell': + case 'runcommand': + case 'terminal': + case 'runterminalcmd': + return { verb: 'Ran', target: shorten(command, false) }; + + case 'bashoutput': + return { verb: 'Checked background output', target: '' }; + + case 'killshell': + case 'killbash': + return { verb: 'Stopped a background command', target: '' }; + + case 'grep': + case 'search': + case 'ripgrep': + case 'searchfiles': + case 'grepsearch': + return { verb: 'Searched for', target: shorten(pattern, false) }; + + case 'glob': + case 'find': + case 'fileglob': + case 'globfilesearch': + return { verb: 'Looked for files matching', target: shorten(pattern, false) }; + + case 'ls': + case 'list': + case 'listdirectory': + case 'listdir': + return { verb: 'Listed', target: shorten(filePath, true) }; + + case 'webfetch': + case 'fetch': + return { verb: 'Fetched', target: shorten(url, false) }; + + case 'websearch': + return { verb: 'Searched the web for', target: shorten(pattern, false) }; + + case 'task': + case 'agent': + case 'dispatchagent': + return { + verb: 'Delegated to a subagent', + target: shorten(firstString(record, ['description', 'prompt', 'subagent_type']), false), + }; + + case 'todowrite': + case 'updateplan': + case 'todoread': + return { + verb: 'Updated the task list', + target: shorten(todoSummary(record.todos ?? record.plan ?? record.steps), false), + }; + + case 'askuserquestion': + return { verb: 'Asked you a question', target: '' }; + + case 'exitplanmode': + return { verb: 'Presented a plan', target: '' }; + + default: { + // Unknown tool: still emit a line. Prefer whichever recognizable field + // the payload carried so the user sees more than a bare tool name. + const fallback = filePath ?? command ?? pattern ?? url; + return { + verb: `Used ${name || 'a tool'}`, + target: shorten(fallback, fallback === filePath), + }; + } + } +}