Skip to content

Commit 09c4cfe

Browse files
committed
fix(chat): collapse main tool groups into action summaries
1 parent 1042272 commit 09c4cfe

4 files changed

Lines changed: 147 additions & 54 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
* @vitest-environment jsdom
33
*/
44
import { act, createElement } from 'react'
5-
import { createRoot } from 'react-dom/client'
6-
import { describe, expect, it, vi } from 'vitest'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import type { ToolCallData, ToolCallStatus } from '../../../../types'
88
import type { AgentGroupItem } from './agent-group'
99
import { AgentGroup, isAgentGroupResolved } from './agent-group'
@@ -12,6 +12,10 @@ vi.mock('@/lib/browser-agent/transport', () => ({
1212
isBrowserAgentAvailable: () => true,
1313
}))
1414

15+
vi.mock('./tool-permission-card', () => ({
16+
ToolPermissionCard: () => createElement('div', { 'data-permission-card': true }, 'Allow tool'),
17+
}))
18+
1519
vi.mock('../special-tags', () => ({
1620
CredentialDisplay: ({ data }: { data: Array<{ name?: string }> }) => data[0]?.name ?? '',
1721
BrowserTakeoverQuestion: ({ reason, answer }: { reason?: string; answer?: string }) =>
@@ -110,7 +114,6 @@ describe('AgentGroup browser takeover', () => {
110114
agentLabel: 'Browser Agent',
111115
items: [tool('success'), browserTakeover(reason)],
112116
isStreaming: true,
113-
isCurrentSection: true,
114117
isLaneOpen: true,
115118
})
116119
)
@@ -184,7 +187,6 @@ describe('AgentGroup browser takeover', () => {
184187
agentLabel: 'Browser Agent',
185188
items: [takeover],
186189
isStreaming: true,
187-
isCurrentSection: true,
188190
isLaneOpen: true,
189191
})
190192
)
@@ -206,7 +208,6 @@ describe('AgentGroup browser takeover', () => {
206208
agentLabel: 'Browser Agent',
207209
items: [completedTakeover],
208210
isStreaming: true,
209-
isCurrentSection: true,
210211
isLaneOpen: true,
211212
})
212213
)
@@ -303,3 +304,91 @@ describe('AgentGroup nested status line', () => {
303304
expect(header).toContain('Workflow Agent — Deploying Invoice Sync as API')
304305
})
305306
})
307+
308+
describe('AgentGroup main tool summary', () => {
309+
let container: HTMLDivElement
310+
let root: Root
311+
312+
beforeEach(() => {
313+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
314+
container = document.createElement('div')
315+
root = createRoot(container)
316+
})
317+
318+
afterEach(() => act(() => root.unmount()))
319+
320+
const render = (items: AgentGroupItem[], isStreaming = true) => {
321+
act(() => {
322+
root.render(
323+
createElement(AgentGroup, {
324+
agentName: 'mothership',
325+
agentLabel: 'Sim',
326+
items,
327+
isStreaming,
328+
isLaneOpen: isStreaming,
329+
})
330+
)
331+
})
332+
return container.querySelector<HTMLButtonElement>('button[aria-expanded]')!
333+
}
334+
335+
it('starts collapsed while streaming, shows the tool and count, and preserves manual expansion', () => {
336+
const items = [tool('success'), tool('executing')]
337+
const header = render(items)
338+
expect(header.textContent).toBe('Searching + 1')
339+
expect(header.textContent).not.toContain('Sim')
340+
expect(header.getAttribute('aria-expanded')).toBe('false')
341+
act(() => header.click())
342+
expect(header.getAttribute('aria-expanded')).toBe('true')
343+
render([...items, tool('executing')])
344+
expect(header.textContent).toBe('Searching + 2')
345+
expect(header.getAttribute('aria-expanded')).toBe('true')
346+
act(() => header.click())
347+
render([...items, tool('executing')])
348+
expect(header.getAttribute('aria-expanded')).toBe('false')
349+
})
350+
351+
it.each(['success', 'error', 'cancelled'] as const)(
352+
'shows an honest terminal label for %s without losing the collapsed history count',
353+
(status) => {
354+
const header = render([tool('success'), tool(status)], false)
355+
expect(header.textContent).toBe(
356+
{
357+
success: 'Searched + 1',
358+
error: 'Failed searching + 1',
359+
cancelled: 'Stopped searching + 1',
360+
}[status]
361+
)
362+
expect(header.getAttribute('aria-expanded')).toBe('false')
363+
}
364+
)
365+
366+
it('keeps nested permission decisions visible even after a manual collapse', () => {
367+
const header = render([tool('success'), group([tool('awaiting_approval')])])
368+
expect(header.getAttribute('aria-expanded')).toBe('true')
369+
expect(container.querySelector('[data-permission-card]')).not.toBeNull()
370+
act(() => header.click())
371+
expect(header.getAttribute('aria-expanded')).toBe('true')
372+
})
373+
374+
it('opens a terminal handoff so the user can unblock it', () => {
375+
const handoff: AgentGroupItem = {
376+
type: 'tool',
377+
data: {
378+
id: 'terminal-handoff',
379+
toolName: 'terminal',
380+
displayTitle: 'Waiting for terminal input',
381+
status: 'executing',
382+
params: {
383+
operation: 'handoff',
384+
args: { terminalId: 'terminal-1', reason: 'Finish login' },
385+
},
386+
},
387+
}
388+
const header = render([handoff])
389+
expect(header.getAttribute('aria-expanded')).toBe('true')
390+
expect(container.textContent).toContain('Finish login')
391+
render([tool('success')])
392+
expect(header.getAttribute('aria-expanded')).toBe('false')
393+
})
394+
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use client'
22

33
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4-
import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn'
4+
import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText, Wrench } from '@sim/emcn'
55
import { ShimmerText } from '@/components/ui'
66
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
7+
import { Terminal as TerminalTool } from '@/lib/mothership/generated/tool-catalog-v1'
78
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
8-
import { getToolDisplayTitle } from '@/lib/mothership/tools/tool-display'
9+
import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display'
910
import { useSmoothText } from '@/hooks/use-smooth-text'
1011
import { type ToolCallData, ToolCallStatus } from '../../../../types'
1112
import { getAgentIcon, isToolDone } from '../../utils'
@@ -40,16 +41,14 @@ interface AgentGroupProps {
4041
items: AgentGroupItem[]
4142
isDelegating?: boolean
4243
isStreaming?: boolean
43-
/** This group is the latest section in its parent sequence (drives collapse). */
44-
isCurrentSection?: boolean
4544
/** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */
4645
isLaneOpen?: boolean
4746
}
4847

4948
function toolStatusTitle(tool: ToolCallData): string {
5049
// Raw tool names must never surface — derive a human title when no display
5150
// title was resolved upstream.
52-
return tool.displayTitle || getToolDisplayTitle(String(tool.toolName ?? ''), undefined)
51+
return tool.displayTitle || getToolDisplayTitle(String(tool.toolName ?? ''), tool.params)
5352
}
5453

5554
/**
@@ -69,12 +68,18 @@ function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] {
6968
return tools
7069
}
7170

72-
/** True when any row in this group (or a nested one) is waiting on a permission decision. */
73-
function hasAwaitingApproval(items: AgentGroupItem[]): boolean {
71+
/** Blocking decisions must stay visible even when the surrounding log is collapsed. */
72+
function hasBlockingInteraction(items: AgentGroupItem[]): boolean {
7473
return items.some((item) => {
75-
if (item.type === 'tool') return item.data.status === ToolCallStatus.awaiting_approval
76-
// Text rows carry no tool calls, so only nested groups need recursing into.
77-
return item.type === 'agent_group' ? hasAwaitingApproval(item.group.items) : false
74+
if (item.type === 'tool') {
75+
return (
76+
item.data.status === ToolCallStatus.awaiting_approval ||
77+
(item.data.toolName === TerminalTool.id &&
78+
item.data.status === ToolCallStatus.executing &&
79+
item.data.params?.operation === 'handoff')
80+
)
81+
}
82+
return item.type === 'agent_group' ? hasBlockingInteraction(item.group.items) : false
7883
})
7984
}
8085

@@ -136,39 +141,41 @@ export function AgentGroup({
136141
items,
137142
isDelegating = false,
138143
isStreaming = false,
139-
isCurrentSection = false,
140144
isLaneOpen = false,
141145
error,
142146
}: AgentGroupProps) {
143-
const AgentIcon = getAgentIcon(agentName)
144147
const isMainAgent = agentName === 'mothership'
145-
// Collapsed status line: the latest tool call, always in its RUNNING
146-
// phrasing — it never flips to the completed rewrite (that lives in the
147-
// expanded log). Work delegated further down bubbles up, so a group whose
148-
// own turn is idle still narrates what its nested agent is doing rather
149-
// than freezing on its last own tool. With several tools running at any
150-
// depth, the most recently started wins and the rest become "+ n"; between
151-
// rounds the last tool's title stays frozen; a closed lane shows the bare
152-
// name.
148+
/** Main groups summarize their tool log; subagents retain their named live status. */
153149
const status = useMemo(() => {
154-
if (isMainAgent || !isLaneOpen) return undefined
150+
if (!isMainAgent && !isLaneOpen) return undefined
155151
const tools = collectGroupTools(items)
156152
const running = tools.filter((tool) => tool.status === ToolCallStatus.executing)
157-
if (running.length > 0) {
158-
const latest = running.reduce((newest, tool) =>
159-
(tool.startedAt ?? 0) >= (newest.startedAt ?? 0) ? tool : newest
160-
)
161-
const title = toolStatusTitle(latest)
162-
return running.length > 1 ? `${title} + ${running.length - 1}` : title
153+
const latest = running.length
154+
? running.reduce((newest, tool) =>
155+
(tool.startedAt ?? 0) >= (newest.startedAt ?? 0) ? tool : newest
156+
)
157+
: tools.at(-1)
158+
if (!latest) return undefined
159+
return {
160+
toolName: latest.toolName,
161+
title: isMainAgent
162+
? getToolStatusDisplayTitle(toolStatusTitle(latest), latest.status, latest.toolName)
163+
: toolStatusTitle(latest),
164+
additionalCount: isMainAgent ? tools.length - 1 : Math.max(0, running.length - 1),
163165
}
164-
const last = tools.at(-1)
165-
return last ? toolStatusTitle(last) : undefined
166166
}, [isLaneOpen, isMainAgent, items])
167+
const AgentIcon = isMainAgent
168+
? getAgentIcon(status?.toolName ?? '', Wrench)
169+
: getAgentIcon(agentName)
167170
const headerText = error
168-
? `${agentLabel} — Failed`
169-
: status
170-
? `${agentLabel}${status}`
171-
: agentLabel
171+
? isMainAgent
172+
? 'Tool call failed'
173+
: `${agentLabel} — Failed`
174+
: isMainAgent
175+
? (status?.title ?? 'Working')
176+
: status
177+
? `${agentLabel}${status.title}`
178+
: agentLabel
172179
const hasItems = items.length > 0
173180
const resolved = isAgentGroupResolved(items)
174181
const browserAgentAvailable = isBrowserAgentAvailable()
@@ -178,24 +185,16 @@ export function AgentGroup({
178185
const isWorking =
179186
!activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen))
180187

181-
// SUBAGENT groups never auto-expand: the collapsed row IS the live view —
182-
// label plus latest running tool title. Expanding is a deliberate user
183-
// action; only a pending permission prompt or a browser hand-back forces
184-
// one open. The MAIN lane ("Sim") is not a delegation card: its narration
185-
// and tool calls are the turn itself, so it keeps the original live-expand
186-
// behavior (open while streaming/current, settles when superseded).
187-
const autoExpanded = isMainAgent && isStreaming && (isCurrentSection || isLaneOpen || !resolved)
188-
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
188+
/** Keep every log collapsed until opened, except for blocking user interactions. */
189+
const [manualExpanded, setManualExpanded] = useState(false)
189190
const [expandedTakeoverId, setExpandedTakeoverId] = useState<string | null>(null)
190191
// An outstanding permission prompt overrides a manual collapse: the turn
191192
// cannot proceed until it is answered, so hiding it would deadlock the chat
192193
// with nothing on screen to explain why.
193194
const expanded =
194-
hasAwaitingApproval(items) ||
195+
hasBlockingInteraction(items) ||
195196
nestedBrowserTakeover ||
196-
(activeBrowserTakeover
197-
? expandedTakeoverId === activeBrowserTakeover.id
198-
: (manualExpanded ?? autoExpanded))
197+
(activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded)
199198

200199
const toggleExpanded = () => {
201200
if (activeBrowserTakeover) {
@@ -211,6 +210,7 @@ export function AgentGroup({
211210
<button
212211
type='button'
213212
onClick={toggleExpanded}
213+
aria-expanded={expanded}
214214
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
215215
>
216216
<div className='flex size-[16px] shrink-0 items-center justify-center'>
@@ -221,6 +221,12 @@ export function AgentGroup({
221221
) : (
222222
<OverflowText label={headerText} className='text-[var(--text-body)] text-sm' />
223223
)}
224+
{status && status.additionalCount > 0 && (
225+
<span className='shrink-0 text-[var(--text-secondary)] text-sm'>
226+
{' + '}
227+
{status.additionalCount}
228+
</span>
229+
)}
224230
<ChevronDown
225231
className={cn(
226232
'size-[14px] shrink-0 text-[var(--text-icon)] opacity-0 transition-[transform,opacity] duration-150 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100',
@@ -271,7 +277,6 @@ export function AgentGroup({
271277
items={item.group.items}
272278
isDelegating={item.group.isDelegating}
273279
isStreaming={isStreaming}
274-
isCurrentSection={idx === items.length - 1}
275280
isLaneOpen={item.group.isOpen}
276281
error={item.group.error}
277282
/>

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1068,7 +1068,6 @@ function MessageContentInner({
10681068
items={segment.items}
10691069
isDelegating={segment.isDelegating}
10701070
isStreaming={isStreaming}
1071-
isCurrentSection={i === segments.length - 1}
10721071
isLaneOpen={segment.isOpen}
10731072
error={segment.error}
10741073
/>

apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ const TOOL_ICONS: Record<string, IconComponent> = {
131131
wait: Clock,
132132
}
133133

134-
export function getAgentIcon(name: string): IconComponent {
135-
return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp
134+
export function getAgentIcon(name: string, fallback: IconComponent = Blimp): IconComponent {
135+
return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? fallback
136136
}
137137

138138
export type MessagePhase = 'streaming' | 'revealing' | 'settled'

0 commit comments

Comments
 (0)