Skip to content

Commit 85a5938

Browse files
authored
fix(chat): stabilize inline tool activity updates (#7806)
* fix(chat): stabilize inline tool activity updates * fix(chat): keep collapsed activity spacing stable * fix(chat): retain parallel activity and consistent group spacing
1 parent 5e7d17a commit 85a5938

18 files changed

Lines changed: 768 additions & 218 deletions

apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { Table } from '@sim/emcn/icons'
22
import { SlackIcon } from '@/components/icons'
33
import { ActivityStatus } from '@/components/ui/activity-status'
44
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
5-
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
5+
import type {
6+
ToolActivityPresentation,
7+
ToolCallItemProps,
8+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
69
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
710

811
/** Demo fixtures have known brands, so the landing page never loads the block registry. */
@@ -20,12 +23,16 @@ export function HeroToolCallItem({
2023
: toolCallId === 'hero-read-table'
2124
? Table
2225
: getToolIcon(toolName)
23-
const activity = (
24-
<ActivityStatus
25-
label={getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription)}
26-
isActive={status === 'executing'}
27-
icon={<Icon className='size-full' />}
28-
/>
29-
)
30-
return renderStatus ? renderStatus(activity) : activity
26+
const activity: ToolActivityPresentation = {
27+
label: getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription),
28+
activeLabel: getToolStatusDisplayTitle(
29+
displayTitle,
30+
status === 'success' ? 'executing' : status,
31+
toolName,
32+
activityDescription
33+
),
34+
isActive: status === 'executing',
35+
icon: <Icon className='size-full' />,
36+
}
37+
return renderStatus ? renderStatus(activity) : <ActivityStatus {...activity} />
3138
}
Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
'use client'
22

33
import { type ReactNode, useId } from 'react'
4-
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
4+
import { ChevronDown, cn, Expandable, ExpandableContent, handleKeyboardActivation } from '@sim/emcn'
55
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'
66

7-
interface ActivityDisclosureProps {
7+
export interface ActivityDisclosureProps {
88
header: ReactNode
99
children: ReactNode
1010
expanded: boolean
1111
onToggle: () => void
1212
isStreaming: boolean
13+
collapsible?: boolean
1314
unbounded?: boolean
1415
}
1516

@@ -21,39 +22,51 @@ export function ActivityDisclosure({
2122
onToggle,
2223
isStreaming,
2324
unbounded = false,
25+
collapsible = true,
2426
}: ActivityDisclosureProps) {
2527
const contentId = useId()
2628
const headerId = useId()
2729

2830
return (
29-
<div className='flex min-w-0 flex-col gap-1.5'>
30-
<button
31-
type='button'
32-
aria-expanded={expanded}
33-
aria-controls={contentId}
34-
aria-labelledby={headerId}
35-
onClick={onToggle}
36-
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
31+
<div className='flex min-w-0 flex-col'>
32+
<div
33+
role={collapsible ? 'button' : undefined}
34+
tabIndex={collapsible ? 0 : undefined}
35+
aria-expanded={collapsible ? expanded : undefined}
36+
aria-controls={collapsible ? contentId : undefined}
37+
aria-labelledby={collapsible ? headerId : undefined}
38+
onClick={collapsible ? onToggle : undefined}
39+
onKeyDown={collapsible ? (event) => handleKeyboardActivation(event, onToggle) : undefined}
40+
className={cn(
41+
'flex w-full min-w-0 items-center gap-2 text-left',
42+
collapsible && 'group/agent cursor-pointer'
43+
)}
3744
>
3845
<span id={headerId} className='flex min-w-0'>
3946
{header}
4047
</span>
41-
<ChevronDown
42-
aria-hidden
43-
className={cn(
44-
'size-[14px] shrink-0 text-[var(--text-icon)] transition-[transform,opacity] duration-150',
45-
!expanded &&
46-
'-rotate-90 opacity-0 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100'
47-
)}
48-
/>
49-
</button>
50-
<Expandable expanded={expanded}>
51-
<ExpandableContent id={contentId}>
52-
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
53-
{children}
54-
</ActivityViewport>
55-
</ExpandableContent>
56-
</Expandable>
48+
{collapsible && (
49+
<ChevronDown
50+
aria-hidden
51+
className={cn(
52+
'size-[14px] shrink-0 text-[var(--text-icon)] transition-[transform,opacity] duration-150',
53+
!expanded &&
54+
'-rotate-90 opacity-0 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100'
55+
)}
56+
/>
57+
)}
58+
</div>
59+
{collapsible && (
60+
<Expandable expanded={expanded}>
61+
<ExpandableContent id={contentId}>
62+
<div className='pt-1.5'>
63+
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
64+
{children}
65+
</ActivityViewport>
66+
</div>
67+
</ExpandableContent>
68+
</Expandable>
69+
)}
5770
</div>
5871
)
5972
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
8+
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
9+
import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
10+
11+
function tool(id: string, status: ToolCallStatus = 'executing'): ToolCallData {
12+
return { id, toolName: 'read', displayTitle: `Reading ${id}`, status }
13+
}
14+
15+
function items(tools: ToolCallData[]): AgentGroupItem[] {
16+
return tools.map((data) => ({ type: 'tool', data }))
17+
}
18+
19+
describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (agentName) => {
20+
let root: Root
21+
let container: HTMLDivElement
22+
beforeEach(() => {
23+
vi.useFakeTimers()
24+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
25+
container = document.createElement('div')
26+
document.body.appendChild(container)
27+
root = createRoot(container)
28+
})
29+
afterEach(() => {
30+
act(() => root.unmount())
31+
container.remove()
32+
vi.useRealTimers()
33+
})
34+
const render = (tools: ToolCallData[], active = true) =>
35+
act(() =>
36+
root.render(
37+
<AgentGroup
38+
agentName={agentName}
39+
agentLabel='Agent prefix'
40+
items={items(tools)}
41+
isStreaming={active}
42+
isLaneOpen={active}
43+
/>
44+
)
45+
)
46+
const header = () => container.querySelector('[role="status"]')
47+
const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms))
48+
49+
it('shows the first action immediately and coalesces bursts without replaying a backlog', () => {
50+
render([])
51+
advance(100)
52+
render([tool('first')])
53+
expect(header()?.textContent).toBe('Reading first')
54+
const row = header()
55+
const shimmer = container.querySelector('[class*="shimmer"]')
56+
advance(100)
57+
render([tool('first', 'success')])
58+
expect(header()?.textContent).toBe('Reading first')
59+
expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
60+
render([tool('first', 'success'), tool('second')])
61+
expect(header()).toBe(row)
62+
expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
63+
expect(header()?.textContent).toBe('Reading first')
64+
advance(600)
65+
render([tool('first', 'success'), tool('second', 'success'), tool('third')])
66+
advance(299)
67+
expect(header()?.textContent).toBe('Reading first')
68+
advance(1)
69+
expect(header()?.textContent).toBe('Reading third')
70+
expect(container.textContent).not.toContain('Agent prefix')
71+
advance(1000)
72+
expect(header()?.textContent).toBe('Reading third')
73+
})
74+
75+
it('keeps live history complete under a stable expanded header with keyboard disclosure', () => {
76+
render([tool('first', 'success'), tool('second')])
77+
const trigger = container.querySelector<HTMLElement>('[role="button"]')!
78+
const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
79+
act(() => trigger.dispatchEvent(event))
80+
expect(event.defaultPrevented).toBe(true)
81+
expect(trigger.getAttribute('aria-expanded')).toBe('true')
82+
expect(header()?.textContent).toBe('Tool activity')
83+
render([tool('first', 'success'), tool('second', 'success'), tool('third')])
84+
expect(header()?.textContent).toBe('Tool activity')
85+
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
86+
'Read firstRead secondReading third'
87+
)
88+
act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
89+
expect(trigger.getAttribute('aria-expanded')).toBe('false')
90+
expect(header()?.textContent).toBe('Reading third')
91+
})
92+
93+
it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
94+
'shows %s immediately and cancels a pending cosmetic update',
95+
(status) => {
96+
render([tool('first')])
97+
advance(100)
98+
render([tool('first', 'success'), tool('second')])
99+
render([tool('first', 'success'), tool('second', status)])
100+
const prefix =
101+
status === 'error' || status === 'rejected'
102+
? 'Failed'
103+
: status === 'skipped'
104+
? 'Skipped'
105+
: 'Stopped'
106+
expect(header()?.textContent).toBe(`${prefix} reading second`)
107+
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
108+
advance(1500)
109+
expect(header()?.textContent).toBe(`${prefix} reading second`)
110+
}
111+
)
112+
113+
it('shows final completion immediately and never replays the held action', () => {
114+
render([tool('first')])
115+
advance(100)
116+
render([tool('first', 'success'), tool('second')])
117+
render([tool('first', 'success'), tool('second', 'success')], false)
118+
expect(header()?.textContent).toBe('Read files')
119+
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
120+
advance(2000)
121+
expect(header()?.textContent).toBe('Read files')
122+
})
123+
124+
it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
125+
'keeps an earlier parallel call active when the latest one becomes %s',
126+
(status) => {
127+
render([tool('first'), tool('second')])
128+
advance(100)
129+
render([tool('first'), tool('second', status)])
130+
const outcome =
131+
status === 'error' || status === 'rejected'
132+
? 'failed'
133+
: status === 'skipped'
134+
? 'skipped'
135+
: 'stopped'
136+
expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
137+
expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
138+
advance(1000)
139+
expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
140+
}
141+
)
142+
143+
it('surfaces an earlier parallel failure while the latest call keeps working', () => {
144+
render([tool('first'), tool('second')])
145+
advance(100)
146+
render([tool('first', 'error'), tool('second')])
147+
expect(header()?.textContent).toBe('Reading second · 1 failed')
148+
})
149+
150+
it('keeps narration from prematurely completing an open lane', () => {
151+
act(() =>
152+
root.render(
153+
<AgentGroup
154+
agentName={agentName}
155+
agentLabel='Sim'
156+
isStreaming
157+
isLaneOpen
158+
items={[
159+
...items([tool('first', 'success')]),
160+
{ type: 'text', content: 'Checking another source.' },
161+
...items([tool('second', 'success')]),
162+
]}
163+
/>
164+
)
165+
)
166+
const rows = container.querySelectorAll('[role="status"]')
167+
if (agentName === 'mothership') {
168+
expect(rows[0].textContent).toBe('Read first')
169+
expect(rows[0].querySelector('[class*="shimmer"]')).toBeNull()
170+
}
171+
const liveRow = rows[rows.length - 1]
172+
expect(liveRow.textContent).toBe('Reading second')
173+
expect(liveRow.querySelector('[class*="shimmer"]')).not.toBeNull()
174+
})
175+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { ActivityStatus, type ActivityStatusProps } from '@/components/ui/activity-status'
5+
import {
6+
ActivityDisclosure,
7+
type ActivityDisclosureProps,
8+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
9+
10+
const ACTIVITY_UPDATE_INTERVAL_MS = 1000
11+
12+
interface ActivityStreamProps extends Omit<ActivityDisclosureProps, 'header'> {
13+
activity: ActivityStatusProps
14+
activityKey?: string
15+
attentionKey: string
16+
collapsible: boolean
17+
}
18+
19+
/** Pace only the header; history and interactive tool state continue updating immediately. */
20+
export function ActivityStream({
21+
activity,
22+
activityKey,
23+
attentionKey,
24+
expanded,
25+
collapsible,
26+
children,
27+
onToggle,
28+
isStreaming,
29+
unbounded,
30+
}: ActivityStreamProps) {
31+
const isExpanded = collapsible && expanded
32+
const key = `${activityKey}:${activity.label}`
33+
const resetKey = `${activity.isActive}:${isExpanded}:${Boolean(activityKey)}:${attentionKey}`
34+
const [visible, setVisible] = useState(() => ({
35+
activity,
36+
key,
37+
resetKey,
38+
shownAt: Date.now(),
39+
}))
40+
41+
/** Completion, attention, and disclosure changes bypass the cosmetic delay. */
42+
if (visible.resetKey !== resetKey) {
43+
setVisible({ activity, key, resetKey, shownAt: Date.now() })
44+
}
45+
46+
useEffect(() => {
47+
if (!activity.isActive || isExpanded || key === visible.key) return
48+
const remaining = Math.max(0, ACTIVITY_UPDATE_INTERVAL_MS - (Date.now() - visible.shownAt))
49+
const flush = () => setVisible({ activity, key, resetKey, shownAt: Date.now() })
50+
if (remaining === 0) {
51+
flush()
52+
return
53+
}
54+
const timer = setTimeout(flush, remaining)
55+
return () => clearTimeout(timer)
56+
}, [activity, key, resetKey, isExpanded, visible.key, visible.shownAt])
57+
58+
const displayed =
59+
!activity.isActive || isExpanded || key === visible.key ? activity : visible.activity
60+
const header = (
61+
<ActivityStatus
62+
{...displayed}
63+
label={isExpanded && activity.isActive ? 'Tool activity' : displayed.label}
64+
isActive={activity.isActive}
65+
/>
66+
)
67+
return (
68+
<ActivityDisclosure
69+
header={header}
70+
collapsible={collapsible}
71+
expanded={expanded}
72+
onToggle={onToggle}
73+
isStreaming={isStreaming}
74+
unbounded={unbounded}
75+
>
76+
{children}
77+
</ActivityDisclosure>
78+
)
79+
}

0 commit comments

Comments
 (0)