Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions desktop/src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']])
})
})
37 changes: 30 additions & 7 deletions desktop/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
type GoalEvent = Extract<UIMessage, { type: 'goal_event' }>
type BackgroundTaskEvent = Extract<UIMessage, { type: 'background_task' }>
type CompactSummaryEvent = Extract<UIMessage, { type: 'compact_summary' }>
type ThinkingMessage = Extract<UIMessage, { type: 'thinking' }>
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 = {
Expand All @@ -50,6 +52,7 @@ type RenderModel = {

type ToolGroupResults = {
toolCalls: ToolCall[]
steps: ActivityStep[]
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
agentTaskNotifications: Record<string, AgentTaskNotification>
Expand Down Expand Up @@ -468,25 +471,39 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo
const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map<string, number>()
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) {
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[]) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1894,6 +1914,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
{item.kind === 'tool_group' ? (
<ToolCallGroup
{...toolGroupResults.get(item.id)!}
activeThinkingId={
activeThinkingId && item.steps.some((step) => step.id === activeThinkingId) ? activeThinkingId : null
}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
Expand Down
27 changes: 25 additions & 2 deletions desktop/src/components/chat/ToolCallGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ToolCallGroup
toolCalls={toolCalls}
steps={[toolCalls[0]!, thinking, toolCalls[1]!]}
activeThinkingId="think-1"
resultMap={resultMap}
childToolCallsByParent={new Map()}
agentTaskNotifications={{}}
/>,
)

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()
})
})
60 changes: 43 additions & 17 deletions desktop/src/components/chat/ToolCallGroup.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,6 +13,8 @@ import { getCuaToolDisplay } from '../../lib/cuaToolDisplay'

type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type ThinkingMessage = Extract<UIMessage, { type: 'thinking' }>
type ActivityStep = ToolCall | ThinkingMessage
type MemoryToolAction = 'saved' | 'referenced'

type MemoryToolFile = {
Expand All @@ -32,6 +35,8 @@ type Props = {
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
agentTaskNotifications: Record<string, AgentTaskNotification>
steps?: ActivityStep[]
activeThinkingId?: string | null
isStreaming?: boolean
}

Expand All @@ -47,7 +52,11 @@ const TOOL_VERBS: Record<string, (count: number, t: (key: TranslationKey, params
WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }),
}

function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
function generateSummary(
toolCalls: ToolCall[],
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
thinkingCount = 0,
): string {
const counts = new Map<string, number>()
for (const tc of toolCalls) {
counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1)
Expand All @@ -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(', ')
}
Expand Down Expand Up @@ -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 (
<div className="mb-2 space-y-2">
<MemoryToolActivityGroup
Expand All @@ -134,6 +149,8 @@ export const ToolCallGroup = memo(function ToolCallGroup({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
steps={regularSteps}
activeThinkingId={activeThinkingId}
isStreaming={isStreaming}
/>
</div>
Expand All @@ -156,6 +173,8 @@ export const ToolCallGroup = memo(function ToolCallGroup({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
steps={steps}
activeThinkingId={activeThinkingId}
isStreaming={isStreaming}
/>
)
Expand All @@ -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 (
Expand All @@ -182,7 +203,7 @@ function ToolCallGroupContent({
)
}

if (toolCalls.length === 1) {
if (toolCalls.length === 1 && steps.length === 1) {
const tc = toolCalls[0]!
return (
<ToolCallTree
Expand All @@ -199,6 +220,8 @@ function ToolCallGroupContent({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
steps={steps}
activeThinkingId={activeThinkingId}
isStreaming={isStreaming}
/>
)
Expand Down Expand Up @@ -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 (
<div className="mb-2">
Expand All @@ -431,17 +457,17 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt

{expanded && (
<div className="mb-1 ml-[6px] mt-1.5 space-y-1 border-l border-[var(--color-border)] pl-[18px]">
{toolCalls.map((tc) => {
return (
{steps.map((step) => step.type === 'thinking'
? <ThinkingBlock key={step.id} content={step.content} isActive={step.id === activeThinkingId} />
: (
<ToolCallTree
key={tc.id}
toolCall={tc}
key={step.id}
toolCall={step}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
/>
)
})}
))}
</div>
)}
</div>
Expand Down
Loading
Loading