Skip to content

Commit b970af9

Browse files
committed
fix(chat): share activity disclosure and status lifecycle
1 parent 6ea4e8b commit b970af9

11 files changed

Lines changed: 284 additions & 155 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/messa
88
/** Demo fixtures have known brands, so the landing page never loads the block registry. */
99
export function HeroToolCallItem({
1010
toolCallId,
11+
renderStatus,
1112
toolName,
1213
displayTitle,
1314
status,
@@ -18,11 +19,12 @@ export function HeroToolCallItem({
1819
: toolCallId === 'hero-read-table'
1920
? Table
2021
: getToolIcon(toolName)
21-
return (
22+
const activity = (
2223
<ActivityStatus
2324
label={getToolStatusDisplayTitle(displayTitle, status, toolName)}
2425
isActive={status === 'executing'}
2526
icon={Icon && <Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />}
2627
/>
2728
)
29+
return renderStatus ? renderStatus(activity) : activity
2830
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
3+
import { type ReactNode, useId } from 'react'
4+
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
5+
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'
6+
7+
interface ActivityDisclosureProps {
8+
header: ReactNode
9+
children: ReactNode
10+
expanded: boolean
11+
onToggle: () => void
12+
isStreaming: boolean
13+
unbounded?: boolean
14+
}
15+
16+
/** Shared disclosure chrome; callers own expansion and blocking-interaction decisions. */
17+
export function ActivityDisclosure({
18+
header,
19+
children,
20+
expanded,
21+
onToggle,
22+
isStreaming,
23+
unbounded = false,
24+
}: ActivityDisclosureProps) {
25+
const contentId = useId()
26+
const headerId = useId()
27+
28+
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'
37+
>
38+
<span id={headerId} className='flex min-w-0'>
39+
{header}
40+
</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>
57+
</div>
58+
)
59+
}

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

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

3-
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'
4-
import { cn } from '@sim/emcn'
3+
import { type ReactNode, useEffect, useLayoutEffect, useRef } from 'react'
4+
import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
55

66
interface ActivityViewportProps {
77
children: ReactNode
@@ -21,7 +21,7 @@ export function ActivityViewport({
2121
const rafRef = useRef<number | null>(null)
2222
const stickToBottomRef = useRef(true)
2323
const prevScrollTopRef = useRef(0)
24-
const [hasOverflow, setHasOverflow] = useState(false)
24+
const edges = useScrollEdges(ref, { enabled: !unbounded })
2525

2626
useEffect(() => {
2727
if (unbounded) {
@@ -30,8 +30,7 @@ export function ActivityViewport({
3030
}
3131
const el = ref.current
3232
if (!el) return
33-
// Upward user input detaches auto-stick; a downward scroll reaching the
34-
// bottom re-attaches it (a small upward flick can't re-stick itself).
33+
/** Upward input detaches auto-stick; reaching the bottom while scrolling down resumes it. */
3534
const handleWheel = (e: WheelEvent) => {
3635
if (e.deltaY < 0) stickToBottomRef.current = false
3736
}
@@ -51,20 +50,11 @@ export function ActivityViewport({
5150
}, [unbounded])
5251

5352
useLayoutEffect(() => {
54-
const el = ref.current
5553
if (rafRef.current !== null) {
5654
window.cancelAnimationFrame(rafRef.current)
5755
rafRef.current = null
5856
}
59-
if (unbounded) {
60-
setHasOverflow(false)
61-
return
62-
}
63-
if (el) {
64-
const next = el.scrollHeight > el.clientHeight
65-
setHasOverflow((prev) => (prev === next ? prev : next))
66-
}
67-
if (!isStreaming) return
57+
if (unbounded || !isStreaming) return
6858
const tick = () => {
6959
const node = ref.current
7060
if (!node || !stickToBottomRef.current) {
@@ -90,23 +80,17 @@ export function ActivityViewport({
9080
})
9181

9282
return (
93-
<div className='relative'>
94-
<div
95-
ref={ref}
96-
className={cn(
97-
'pr-2',
98-
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
99-
hasOverflow && 'py-1'
100-
)}
101-
>
102-
{children}
103-
</div>
104-
{!unbounded && hasOverflow && (
105-
<>
106-
<div className='pointer-events-none absolute top-0 right-2 left-0 h-3 bg-linear-to-b from-[var(--bg)] to-transparent' />
107-
<div className='pointer-events-none absolute right-2 bottom-0 left-0 h-3 bg-linear-to-t from-[var(--bg)] to-transparent' />
108-
</>
83+
<div
84+
ref={ref}
85+
className={cn(
86+
'pr-2',
87+
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
88+
scrollFadeClass,
89+
(edges.top || edges.bottom) && 'py-1'
10990
)}
91+
{...scrollFadeAttributes(edges)}
92+
>
93+
{children}
11094
</div>
11195
)
11296
}

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

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
'use client'
22

3-
import { type ComponentType, type ReactNode, useId, useMemo, useState } from 'react'
4-
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
3+
import { type ComponentType, type ReactNode, useMemo, useState } from 'react'
54
import { ActivityStatus } from '@/components/ui/activity-status'
65
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
76
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
8-
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'
7+
import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
98
import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon'
109
import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown'
1110
import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
@@ -77,7 +76,6 @@ function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] {
7776
function hasPendingInteraction(items: AgentGroupItem[]): boolean {
7877
return items.some((item) => {
7978
if (item.type === 'tool') return needsToolInput(item.data)
80-
// Text rows carry no tool calls, so only nested groups need recursing into.
8179
return item.type === 'agent_group' ? hasPendingInteraction(item.group.items) : false
8280
})
8381
}
@@ -152,7 +150,6 @@ export function AgentGroupView({
152150
ToolCallComponent,
153151
renderBrowserTakeover,
154152
}: AgentGroupViewProps) {
155-
const contentId = useId()
156153
const AgentIcon = getAgentIcon(agentName)
157154
const agentIcon =
158155
agentName === 'browser' ? (
@@ -194,9 +191,7 @@ export function AgentGroupView({
194191
const [manualExpanded, setManualExpanded] = useState(defaultExpanded)
195192
const [expandedTakeoverId, setExpandedTakeoverId] = useState<string | null>(null)
196193
const pendingInteraction = hasPendingInteraction(items)
197-
// An outstanding permission prompt overrides a manual collapse: the turn
198-
// cannot proceed until it is answered, so hiding it would deadlock the chat
199-
// with nothing on screen to explain why.
194+
/** Blocking interactions override manual collapse so the user can resume the turn. */
200195
const expanded =
201196
pendingInteraction ||
202197
nestedBrowserTakeover ||
@@ -238,6 +233,7 @@ export function AgentGroupView({
238233
isDelegating={item.group.isDelegating}
239234
isStreaming={isStreaming}
240235
isLaneOpen={item.group.isOpen}
236+
autoScrollActivity={autoScrollActivity}
241237
/>
242238
)
243239
}
@@ -271,42 +267,22 @@ export function AgentGroupView({
271267
)
272268

273269
return (
274-
<div className='flex flex-col gap-1.5'>
275-
{isMainAgent ? null : hasItems ? (
276-
<button
277-
type='button'
278-
aria-label={headerText}
279-
aria-expanded={expanded}
280-
aria-controls={contentId}
281-
onClick={toggleExpanded}
282-
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
270+
<div className='flex min-w-0 flex-col gap-1.5'>
271+
{isMainAgent ? (
272+
activity
273+
) : hasItems ? (
274+
<ActivityDisclosure
275+
header={header}
276+
expanded={expanded}
277+
onToggle={toggleExpanded}
278+
isStreaming={isStreaming && autoScrollActivity}
279+
unbounded={pendingInteraction || nestedBrowserTakeover}
283280
>
284-
{header}
285-
<ChevronDown
286-
className={cn(
287-
'size-[14px] shrink-0 text-[var(--text-icon)] transition-[transform,opacity] duration-150',
288-
!expanded &&
289-
'-rotate-90 opacity-0 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100'
290-
)}
291-
/>
292-
</button>
281+
{activity}
282+
</ActivityDisclosure>
293283
) : (
294284
header
295285
)}
296-
{isMainAgent ? (
297-
activity
298-
) : hasItems ? (
299-
<Expandable expanded={expanded}>
300-
<ExpandableContent id={contentId}>
301-
<ActivityViewport
302-
isStreaming={isStreaming && autoScrollActivity}
303-
unbounded={pendingInteraction || nestedBrowserTakeover}
304-
>
305-
{activity}
306-
</ActivityViewport>
307-
</ExpandableContent>
308-
</Expandable>
309-
) : null}
310286
{activeBrowserTakeover && (
311287
<div key={activeBrowserTakeover.id} className='animate-stream-fade-in'>
312288
{renderBrowserTakeover?.(activeBrowserTakeover.reason)}

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

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,14 @@ describe('AgentGroup inline main activity', () => {
111111
beforeEach(() => {
112112
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
113113
container = document.createElement('div')
114+
document.body.appendChild(container)
114115
root = createRoot(container)
115116
})
116117

117-
afterEach(() => act(() => root.unmount()))
118+
afterEach(() => {
119+
act(() => root.unmount())
120+
container.remove()
121+
})
118122

119123
it('replaces the active status in place and expands the full completed history', () => {
120124
const first: AgentGroupItem = {
@@ -206,8 +210,10 @@ describe('AgentGroup inline main activity', () => {
206210
)
207211
})
208212

209-
it('starts a fresh countdown when the latest wait tool changes', () => {
213+
it('shares one countdown and preserves the viewport across active tool changes', () => {
210214
vi.useFakeTimers()
215+
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
216+
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
211217
try {
212218
const wait: AgentGroupItem = {
213219
type: 'tool',
@@ -233,12 +239,36 @@ describe('AgentGroup inline main activity', () => {
233239
render([wait])
234240
act(() => vi.advanceTimersByTime(2000))
235241
expect(container.textContent).toBe('Waiting 1s')
242+
const header = container.querySelector('button')
243+
act(() => header?.click())
244+
expect(header?.hasAttribute('aria-label')).toBe(false)
245+
expect(header?.textContent).toBe('Waiting 1s')
246+
expect(header).toHaveAccessibleName('Waiting 1s')
247+
expect(container.querySelector('[data-state="open"]')?.textContent).toBe('Waiting 1s')
248+
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
249+
act(() => header?.click())
250+
act(() => header?.click())
251+
expect(container.querySelector('[data-state="open"]')?.textContent).toBe('Waiting 1s')
252+
const viewport = container.querySelector('.overflow-y-auto')
236253
render([
237254
{ ...wait, data: { ...wait.data, status: 'success' } },
238255
{ ...wait, data: { ...wait.data, id: 'wait-second' } },
239256
])
240-
expect(container.textContent).toBe('Waiting 3s')
257+
expect(header?.textContent).toBe('Waiting 3s')
258+
expect(header).toHaveAccessibleName('Waiting 3s')
259+
expect(container.querySelector('.overflow-y-auto')).toBe(viewport)
260+
expect(container.querySelector('[data-state="open"]')?.textContent).toBe('WaitedWaiting 3s')
261+
expect(setIntervalSpy).toHaveBeenCalledTimes(2)
262+
render([
263+
{ ...wait, data: { ...wait.data, status: 'success' } },
264+
{ ...wait, data: { ...wait.data, id: 'wait-second', status: 'success' } },
265+
])
266+
expect(header?.textContent).toBe('Waited')
267+
expect(container.querySelector('.overflow-y-auto')).toBe(viewport)
268+
expect(clearIntervalSpy).toHaveBeenCalledTimes(2)
241269
} finally {
270+
setIntervalSpy.mockRestore()
271+
clearIntervalSpy.mockRestore()
242272
vi.clearAllTimers()
243273
vi.useRealTimers()
244274
}
@@ -256,9 +286,10 @@ describe('AgentGroup inline main activity', () => {
256286
type: 'tool',
257287
data: {
258288
id: 'run',
259-
toolName: 'terminal_run',
289+
toolName: 'terminal',
260290
displayTitle: 'Running checks',
261291
status: 'success',
292+
params: { operation: 'run' },
262293
},
263294
},
264295
]
@@ -274,7 +305,8 @@ describe('AgentGroup inline main activity', () => {
274305
)
275306
)
276307
const header = container.querySelector('button')
277-
expect(header?.getAttribute('aria-label')).toBe('Agent — Read files, ran commands')
308+
expect(header?.textContent).toBe('Agent — Read files, ran commands')
309+
expect(header).toHaveAccessibleName('Agent — Read files, ran commands')
278310
expect(container.querySelectorAll('[data-tool-call-id]')).toHaveLength(0)
279311
act(() => header?.click())
280312
expect(
@@ -309,8 +341,10 @@ describe('AgentGroup inline main activity', () => {
309341
},
310342
]),
311343
],
312-
ToolCallComponent: ({ toolCallId, displayTitle }: ToolCallItemProps) =>
313-
createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle),
344+
ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => {
345+
const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle)
346+
return renderStatus ? renderStatus(status) : status
347+
},
314348
})
315349
)
316350
)
@@ -413,8 +447,10 @@ describe('AgentGroup inline main activity', () => {
413447
agentLabel: 'Sim',
414448
items,
415449
isStreaming: true,
416-
ToolCallComponent: ({ toolCallId, displayTitle }: ToolCallItemProps) =>
417-
createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle),
450+
ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => {
451+
const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle)
452+
return renderStatus ? renderStatus(status) : status
453+
},
418454
})
419455
)
420456
})

0 commit comments

Comments
 (0)