diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 923e762..fd1f98a 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -3708,3 +3708,50 @@ describe('buildToolGroupResults', () => { expect(again.get('group-use-agent-1')!.childToolCallsByParent).toBe(group.childToolCallsByParent) }) }) + +describe('activity groups', () => { + const thinking = (id: string): UIMessage => ({ id, type: 'thinking', content: `thought ${id}`, timestamp: 1 }) + const tool = (id: string, toolName = 'Bash'): UIMessage => ({ + id: `use-${id}`, + type: 'tool_use', + toolName, + toolUseId: id, + input: {}, + timestamp: 1, + }) + const text = (id: string): UIMessage => ({ id, type: 'assistant_text', content: `answer ${id}`, timestamp: 2 }) + + it('folds thinking between tool calls into one group up to the next reply text', () => { + const { renderItems } = buildRenderModel([ + thinking('t1'), + tool('glob', 'Glob'), + thinking('t2'), + tool('ls'), + thinking('t3'), + tool('read', 'Read'), + thinking('t4'), + text('answer'), + ]) + + expect(renderItems).toHaveLength(2) + const group = renderItems[0]! + expect(group.kind).toBe('tool_group') + if (group.kind !== 'tool_group') return + expect(group.steps.map((step) => step.id)).toEqual(['t1', 'use-glob', 't2', 'use-ls', 't3', 'use-read', 't4']) + expect(group.toolCalls.map((call) => call.id)).toEqual(['use-glob', 'use-ls', 'use-read']) + expect(renderItems[1]).toMatchObject({ kind: 'message', message: { id: 'answer' } }) + }) + + it('keeps thinking without tool calls as its own message', () => { + const { renderItems } = buildRenderModel([thinking('t1'), text('answer')]) + + expect(renderItems.map((item) => item.kind)).toEqual(['message', 'message']) + }) + + it('keeps thinking out of agent groups', () => { + const { renderItems } = buildRenderModel([thinking('t1'), tool('a1', 'Agent'), thinking('t2'), tool('b', 'Bash')]) + + expect(renderItems.map((item) => (item.kind === 'tool_group' ? item.steps.map((step) => step.id) : item.message.id))) + .toEqual(['t1', ['use-a1'], ['t2', 'use-b']]) + }) +}) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 4bd4423..e9bb95e 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -37,9 +37,11 @@ type MemoryEvent = Extract type GoalEvent = Extract type BackgroundTaskEvent = Extract type CompactSummaryEvent = Extract +type ThinkingMessage = Extract +type ActivityStep = ToolCall | ThinkingMessage type RenderItem = - | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } + | { kind: 'tool_group'; toolCalls: ToolCall[]; steps: ActivityStep[]; id: string } | { kind: 'message'; message: UIMessage } type RenderModel = { @@ -50,6 +52,7 @@ type RenderModel = { type ToolGroupResults = { toolCalls: ToolCall[] + steps: ActivityStep[] resultMap: Map childToolCallsByParent: Map agentTaskNotifications: Record @@ -468,25 +471,39 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map() let lastUnresolvedAskUserQuestionIndex: number | null = null let pendingToolCalls: ToolCall[] = [] + let pendingSteps: ActivityStep[] = [] const flushGroup = () => { if (pendingToolCalls.length > 0) { items.push({ kind: 'tool_group', toolCalls: [...pendingToolCalls], - id: `group-${pendingToolCalls[0]!.id}`, + steps: [...pendingSteps], + id: `group-${pendingSteps[0]!.id}`, }) - pendingToolCalls = [] + } else { + for (const step of pendingSteps) items.push({ kind: 'message', message: step }) } + pendingToolCalls = [] + pendingSteps = [] } + const pendingIsAgentGroup = () => + pendingToolCalls.length > 0 && pendingToolCalls.every((pendingToolCall) => pendingToolCall.toolName === 'Agent') const appendRootToolCall = (toolCall: ToolCall) => { const nextIsAgent = toolCall.toolName === 'Agent' - const pendingIsAgentGroup = pendingToolCalls.every((pendingToolCall) => pendingToolCall.toolName === 'Agent') + const hasPendingThinking = pendingSteps.length > pendingToolCalls.length - if (pendingToolCalls.length > 0 && pendingIsAgentGroup !== nextIsAgent) { + if (pendingToolCalls.length > 0 && pendingIsAgentGroup() !== nextIsAgent) { + flushGroup() + } else if (nextIsAgent && hasPendingThinking) { flushGroup() } pendingToolCalls.push(toolCall) + pendingSteps.push(toolCall) + } + const appendThinking = (message: ThinkingMessage) => { + if (pendingIsAgentGroup()) flushGroup() + pendingSteps.push(message) } for (const msg of messages) { @@ -555,6 +572,8 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo } else { appendRootToolCall(msg) } + } else if (msg.type === 'thinking') { + appendThinking(msg) } else { flushGroup() items.push({ kind: 'message', message: msg }) @@ -613,6 +632,7 @@ export function buildToolGroupResults( const prior = previous.get(item.id) groups.set(item.id, { toolCalls: prior && sameItems(prior.toolCalls, item.toolCalls) ? prior.toolCalls : item.toolCalls, + steps: prior && sameItems(prior.steps, item.steps) ? prior.steps : item.steps, resultMap: prior && sameMapEntries(prior.resultMap, resultMap, Object.is) ? prior.resultMap : resultMap, childToolCallsByParent: prior && sameMapEntries(prior.childToolCallsByParent, childMap, sameItems) ? prior.childToolCallsByParent @@ -1030,7 +1050,7 @@ function getMessageContentWeight(message: UIMessage): number { function getRenderItemContentWeight(item: RenderItem): number { if (item.kind === 'message') return getMessageContentWeight(item.message) - return item.toolCalls.reduce((total, toolCall) => total + getMessageContentWeight(toolCall), 0) + return item.steps.reduce((total, step) => total + getMessageContentWeight(step), 0) } function shouldVirtualizeRenderItems(metrics: VirtualRenderItemMetric[]) { @@ -1129,7 +1149,7 @@ function getMessageMetricSignature(message: UIMessage): string { function getRenderItemMetricSignature(item: RenderItem): string { if (item.kind === 'message') return getMessageMetricSignature(item.message) - return item.toolCalls.map(getMessageMetricSignature).join('|') + return item.steps.map(getMessageMetricSignature).join('|') } function findVirtualStartIndex(offsets: number[], target: number) { @@ -1894,6 +1914,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { {item.kind === 'tool_group' ? ( step.id === activeThinkingId) ? activeThinkingId : null + } isStreaming={ chatState === 'tool_executing' && item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId)) diff --git a/desktop/src/components/chat/ToolCallGroup.test.tsx b/desktop/src/components/chat/ToolCallGroup.test.tsx index 5c3505d..2f51855 100644 --- a/desktop/src/components/chat/ToolCallGroup.test.tsx +++ b/desktop/src/components/chat/ToolCallGroup.test.tsx @@ -74,9 +74,32 @@ describe('ToolCallGroup', () => { expect(screen.getByRole('button', { name: /Read 3 files/ })).toHaveAttribute('aria-expanded', 'false') }) - it('opens a group with a failed call', () => { + it('keeps a group with a failed call collapsed and marks it as failed', () => { renderGroup([resultFor('a'), resultFor('b', true), resultFor('c')]) - expect(screen.getByRole('button', { name: /Read 3 files/ })).toHaveAttribute('aria-expanded', 'true') + const header = screen.getByRole('button', { name: /Read 3 files/ }) + expect(header).toHaveAttribute('aria-expanded', 'false') + expect(header.querySelector('.text-\\[var\\(--color-error\\)\\]')).not.toBeNull() + }) + + it('folds thinking into the group summary and shows it as running while it streams', () => { + const toolCalls = ['a', 'b'].map(readCall) + const thinking = { id: 'think-1', type: 'thinking', content: 'Checking the second file', timestamp: 1 } as const + const resultMap = new Map(['a', 'b'].map((id) => [id, resultFor(id)])) + render( + , + ) + + const header = screen.getByRole('button', { name: /Read 2 files, thought once/ }) + expect(header.querySelector('.animate-pulse-dot')).not.toBeNull() + fireEvent.click(header) + expect(screen.getByText('Checking the second file')).toBeInTheDocument() }) }) diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index df23b77..b165353 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -1,6 +1,7 @@ import { memo, useEffect, useState } from 'react' import { BookMarked, Check, ChevronDown, ChevronRight, CircleAlert, CircleDot, Settings, Square } from 'lucide-react' import { ToolCallBlock } from './ToolCallBlock' +import { ThinkingBlock } from './ThinkingBlock' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { Modal } from '../shared/Modal' import { useTranslation } from '../../i18n' @@ -12,6 +13,8 @@ import { getCuaToolDisplay } from '../../lib/cuaToolDisplay' type ToolCall = Extract type ToolResult = Extract +type ThinkingMessage = Extract +type ActivityStep = ToolCall | ThinkingMessage type MemoryToolAction = 'saved' | 'referenced' type MemoryToolFile = { @@ -32,6 +35,8 @@ type Props = { resultMap: Map childToolCallsByParent: Map agentTaskNotifications: Record + steps?: ActivityStep[] + activeThinkingId?: string | null isStreaming?: boolean } @@ -47,7 +52,11 @@ const TOOL_VERBS: Record n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }), } -function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?: Record) => string): string { +function generateSummary( + toolCalls: ToolCall[], + t: (key: TranslationKey, params?: Record) => string, + thinkingCount = 0, +): string { const counts = new Map() for (const tc of toolCalls) { counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1) @@ -59,6 +68,9 @@ function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params? const label = getCuaToolDisplay(name, undefined, t)?.title ?? name parts.push(verbFn ? verbFn(count, t) : `${label} (${count})`) } + if (thinkingCount > 0) { + parts.push(thinkingCount === 1 ? t('toolGroup.thoughtOne') : t('toolGroup.thoughtMany', { count: thinkingCount })) + } return parts.join(', ') } @@ -113,13 +125,16 @@ export const ToolCallGroup = memo(function ToolCallGroup({ resultMap, childToolCallsByParent, agentTaskNotifications, + steps = toolCalls, + activeThinkingId = null, isStreaming, }: Props) { const memoryActivity = getMemoryToolActivity(toolCalls, resultMap) if (memoryActivity) { const memoryToolCalls = toolCalls.filter(isMemoryToolCall) const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall)) - if (regularToolCalls.length > 0) { + const regularSteps = steps.filter((step) => step.type === 'thinking' || !isMemoryToolCall(step)) + if (regularSteps.length > 0) { return (
@@ -156,6 +173,8 @@ export const ToolCallGroup = memo(function ToolCallGroup({ resultMap={resultMap} childToolCallsByParent={childToolCallsByParent} agentTaskNotifications={agentTaskNotifications} + steps={steps} + activeThinkingId={activeThinkingId} isStreaming={isStreaming} /> ) @@ -166,9 +185,11 @@ function ToolCallGroupContent({ resultMap, childToolCallsByParent, agentTaskNotifications, + steps = toolCalls, + activeThinkingId = null, isStreaming, }: Props) { - const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent') + const allAgents = toolCalls.length > 0 && toolCalls.every((toolCall) => toolCall.toolName === 'Agent') if (allAgents) { return ( @@ -182,7 +203,7 @@ function ToolCallGroupContent({ ) } - if (toolCalls.length === 1) { + if (toolCalls.length === 1 && steps.length === 1) { const tc = toolCalls[0]! return ( ) @@ -402,17 +425,20 @@ function AgentToolGroup({ ) } -function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) { +function ToolCallGroupMulti({ + toolCalls, + resultMap, + childToolCallsByParent, + steps = toolCalls, + activeThinkingId = null, + isStreaming, +}: Props) { const [expanded, setExpanded] = useState(false) const t = useTranslation() - const summary = generateSummary(toolCalls, t) + const summary = generateSummary(toolCalls, t, steps.length - toolCalls.length) const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent) const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent) - const isRunning = !!isStreaming || hasUnresolvedTools - - useEffect(() => { - if (errorPresent) setExpanded(true) - }, [errorPresent]) + const isRunning = !!isStreaming || hasUnresolvedTools || activeThinkingId !== null return (
@@ -431,17 +457,17 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt {expanded && (
- {toolCalls.map((tc) => { - return ( + {steps.map((step) => step.type === 'thinking' + ? + : ( - ) - })} + ))}
)}
diff --git a/desktop/src/components/chat/streamingRenderCounts.test.tsx b/desktop/src/components/chat/streamingRenderCounts.test.tsx index bc50207..cb4c169 100644 --- a/desktop/src/components/chat/streamingRenderCounts.test.tsx +++ b/desktop/src/components/chat/streamingRenderCounts.test.tsx @@ -6,7 +6,11 @@ import '@testing-library/jest-dom' const counts = vi.hoisted(() => ({ commits: {} as Record, toolGroups: {} as Record, - toolGroupProps: {} as Record }>, + toolGroupProps: {} as Record + steps?: Array<{ id: string; content?: unknown }> + activeThinkingId?: string | null + }>, })) vi.mock('../../api/sessions', () => ({ @@ -264,6 +268,35 @@ describe('components still update on the fields they use', () => { expect(result.current).toEqual({ providerId: 'p1', modelId: 'm2', effort: 'high' }) }) + it('only the group holding the streaming thinking re-renders', () => { + const session = useChatStore.getState().sessions[SCENARIO_SESSION_ID]! + const liveTurn: PerSessionState['messages'] = [ + ...session.messages, + { id: 'u2', type: 'user_text', content: 'Run the tests', timestamp: 1 }, + { id: LIVE_GROUP_ID, type: 'tool_use', toolName: 'Bash', toolUseId: 'tu-live', input: { command: 'ls' }, timestamp: 2 }, + { id: 'r-live', type: 'tool_result', toolUseId: 'tu-live', content: 'files', isError: false, timestamp: 3 }, + ] + patchSession({ messages: liveTurn, chatState: 'thinking' }) + render() + resetCounts() + + let thought = '' + for (let i = 0; i < 3; i++) { + thought += `step ${i} ` + patchSession({ + messages: [...liveTurn, { id: 'think-live', type: 'thinking', content: thought, timestamp: 4 }], + activeThinkingId: 'think-live', + }) + } + + expect(counts.toolGroups[HISTORY_GROUP_ID]).toBeUndefined() + expect(counts.toolGroups[LIVE_GROUP_ID]).toBe(3) + const liveProps = counts.toolGroupProps[LIVE_GROUP_ID]! + expect(liveProps.steps?.map((step) => step.id)).toEqual([LIVE_GROUP_ID, 'think-live']) + expect(liveProps.steps?.[1]?.content).toBe(thought) + expect(liveProps.activeThinkingId).toBe('think-live') + }) + it('a tool group re-renders when its own result arrives', () => { render() const session = useChatStore.getState().sessions[SCENARIO_SESSION_ID]! diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index c58f868..017955a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1175,6 +1175,8 @@ export const en = { 'toolGroup.searchedMany': 'searched {count} patterns', 'toolGroup.agentOne': 'dispatched an agent', 'toolGroup.agentMany': 'dispatched {count} agents', + 'toolGroup.thoughtOne': 'thought once', + 'toolGroup.thoughtMany': 'thought {count} times', 'agentStatus.starting': 'Starting', 'agentStatus.running': 'Running', 'agentStatus.done': 'Done', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 2c15ce2..663fe6c 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1177,6 +1177,8 @@ export const zh: Record = { 'toolGroup.searchedMany': '搜索了 {count} 个模式', 'toolGroup.agentOne': '派遣了一个代理', 'toolGroup.agentMany': '派遣了 {count} 个代理', + 'toolGroup.thoughtOne': '思考了 1 次', + 'toolGroup.thoughtMany': '思考了 {count} 次', 'agentStatus.starting': '开始中', 'agentStatus.running': '进行中', 'agentStatus.done': '完成',