diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx
index f32b4385062..068a27599da 100644
--- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx
+++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx
@@ -2,7 +2,10 @@ import { Table } from '@sim/emcn/icons'
import { SlackIcon } from '@/components/icons'
import { ActivityStatus } from '@/components/ui/activity-status'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
-import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
+import type {
+ ToolActivityPresentation,
+ ToolCallItemProps,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
/** Demo fixtures have known brands, so the landing page never loads the block registry. */
@@ -20,12 +23,16 @@ export function HeroToolCallItem({
: toolCallId === 'hero-read-table'
? Table
: getToolIcon(toolName)
- const activity = (
- }
- />
- )
- return renderStatus ? renderStatus(activity) : activity
+ const activity: ToolActivityPresentation = {
+ label: getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription),
+ activeLabel: getToolStatusDisplayTitle(
+ displayTitle,
+ status === 'success' ? 'executing' : status,
+ toolName,
+ activityDescription
+ ),
+ isActive: status === 'executing',
+ icon: ,
+ }
+ return renderStatus ? renderStatus(activity) :
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx
index c25f9b774e3..724f22c1929 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx
@@ -1,15 +1,16 @@
'use client'
import { type ReactNode, useId } from 'react'
-import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
+import { ChevronDown, cn, Expandable, ExpandableContent, handleKeyboardActivation } from '@sim/emcn'
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'
-interface ActivityDisclosureProps {
+export interface ActivityDisclosureProps {
header: ReactNode
children: ReactNode
expanded: boolean
onToggle: () => void
isStreaming: boolean
+ collapsible?: boolean
unbounded?: boolean
}
@@ -21,39 +22,51 @@ export function ActivityDisclosure({
onToggle,
isStreaming,
unbounded = false,
+ collapsible = true,
}: ActivityDisclosureProps) {
const contentId = useId()
const headerId = useId()
return (
-
-
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx
new file mode 100644
index 00000000000..9566b47c9fb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx
@@ -0,0 +1,175 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
+import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
+import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
+
+function tool(id: string, status: ToolCallStatus = 'executing'): ToolCallData {
+ return { id, toolName: 'read', displayTitle: `Reading ${id}`, status }
+}
+
+function items(tools: ToolCallData[]): AgentGroupItem[] {
+ return tools.map((data) => ({ type: 'tool', data }))
+}
+
+describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (agentName) => {
+ let root: Root
+ let container: HTMLDivElement
+ beforeEach(() => {
+ vi.useFakeTimers()
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.useRealTimers()
+ })
+ const render = (tools: ToolCallData[], active = true) =>
+ act(() =>
+ root.render(
+
+ )
+ )
+ const header = () => container.querySelector('[role="status"]')
+ const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms))
+
+ it('shows the first action immediately and coalesces bursts without replaying a backlog', () => {
+ render([])
+ advance(100)
+ render([tool('first')])
+ expect(header()?.textContent).toBe('Reading first')
+ const row = header()
+ const shimmer = container.querySelector('[class*="shimmer"]')
+ advance(100)
+ render([tool('first', 'success')])
+ expect(header()?.textContent).toBe('Reading first')
+ expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
+ render([tool('first', 'success'), tool('second')])
+ expect(header()).toBe(row)
+ expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
+ expect(header()?.textContent).toBe('Reading first')
+ advance(600)
+ render([tool('first', 'success'), tool('second', 'success'), tool('third')])
+ advance(299)
+ expect(header()?.textContent).toBe('Reading first')
+ advance(1)
+ expect(header()?.textContent).toBe('Reading third')
+ expect(container.textContent).not.toContain('Agent prefix')
+ advance(1000)
+ expect(header()?.textContent).toBe('Reading third')
+ })
+
+ it('keeps live history complete under a stable expanded header with keyboard disclosure', () => {
+ render([tool('first', 'success'), tool('second')])
+ const trigger = container.querySelector('[role="button"]')!
+ const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
+ act(() => trigger.dispatchEvent(event))
+ expect(event.defaultPrevented).toBe(true)
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
+ expect(header()?.textContent).toBe('Tool activity')
+ render([tool('first', 'success'), tool('second', 'success'), tool('third')])
+ expect(header()?.textContent).toBe('Tool activity')
+ expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
+ 'Read firstRead secondReading third'
+ )
+ act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
+ expect(header()?.textContent).toBe('Reading third')
+ })
+
+ it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
+ 'shows %s immediately and cancels a pending cosmetic update',
+ (status) => {
+ render([tool('first')])
+ advance(100)
+ render([tool('first', 'success'), tool('second')])
+ render([tool('first', 'success'), tool('second', status)])
+ const prefix =
+ status === 'error' || status === 'rejected'
+ ? 'Failed'
+ : status === 'skipped'
+ ? 'Skipped'
+ : 'Stopped'
+ expect(header()?.textContent).toBe(`${prefix} reading second`)
+ expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ advance(1500)
+ expect(header()?.textContent).toBe(`${prefix} reading second`)
+ }
+ )
+
+ it('shows final completion immediately and never replays the held action', () => {
+ render([tool('first')])
+ advance(100)
+ render([tool('first', 'success'), tool('second')])
+ render([tool('first', 'success'), tool('second', 'success')], false)
+ expect(header()?.textContent).toBe('Read files')
+ expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ advance(2000)
+ expect(header()?.textContent).toBe('Read files')
+ })
+
+ it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
+ 'keeps an earlier parallel call active when the latest one becomes %s',
+ (status) => {
+ render([tool('first'), tool('second')])
+ advance(100)
+ render([tool('first'), tool('second', status)])
+ const outcome =
+ status === 'error' || status === 'rejected'
+ ? 'failed'
+ : status === 'skipped'
+ ? 'skipped'
+ : 'stopped'
+ expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
+ expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
+ advance(1000)
+ expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
+ }
+ )
+
+ it('surfaces an earlier parallel failure while the latest call keeps working', () => {
+ render([tool('first'), tool('second')])
+ advance(100)
+ render([tool('first', 'error'), tool('second')])
+ expect(header()?.textContent).toBe('Reading second · 1 failed')
+ })
+
+ it('keeps narration from prematurely completing an open lane', () => {
+ act(() =>
+ root.render(
+
+ )
+ )
+ const rows = container.querySelectorAll('[role="status"]')
+ if (agentName === 'mothership') {
+ expect(rows[0].textContent).toBe('Read first')
+ expect(rows[0].querySelector('[class*="shimmer"]')).toBeNull()
+ }
+ const liveRow = rows[rows.length - 1]
+ expect(liveRow.textContent).toBe('Reading second')
+ expect(liveRow.querySelector('[class*="shimmer"]')).not.toBeNull()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx
new file mode 100644
index 00000000000..df8539e20c2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { ActivityStatus, type ActivityStatusProps } from '@/components/ui/activity-status'
+import {
+ ActivityDisclosure,
+ type ActivityDisclosureProps,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
+
+const ACTIVITY_UPDATE_INTERVAL_MS = 1000
+
+interface ActivityStreamProps extends Omit {
+ activity: ActivityStatusProps
+ activityKey?: string
+ attentionKey: string
+ collapsible: boolean
+}
+
+/** Pace only the header; history and interactive tool state continue updating immediately. */
+export function ActivityStream({
+ activity,
+ activityKey,
+ attentionKey,
+ expanded,
+ collapsible,
+ children,
+ onToggle,
+ isStreaming,
+ unbounded,
+}: ActivityStreamProps) {
+ const isExpanded = collapsible && expanded
+ const key = `${activityKey}:${activity.label}`
+ const resetKey = `${activity.isActive}:${isExpanded}:${Boolean(activityKey)}:${attentionKey}`
+ const [visible, setVisible] = useState(() => ({
+ activity,
+ key,
+ resetKey,
+ shownAt: Date.now(),
+ }))
+
+ /** Completion, attention, and disclosure changes bypass the cosmetic delay. */
+ if (visible.resetKey !== resetKey) {
+ setVisible({ activity, key, resetKey, shownAt: Date.now() })
+ }
+
+ useEffect(() => {
+ if (!activity.isActive || isExpanded || key === visible.key) return
+ const remaining = Math.max(0, ACTIVITY_UPDATE_INTERVAL_MS - (Date.now() - visible.shownAt))
+ const flush = () => setVisible({ activity, key, resetKey, shownAt: Date.now() })
+ if (remaining === 0) {
+ flush()
+ return
+ }
+ const timer = setTimeout(flush, remaining)
+ return () => clearTimeout(timer)
+ }, [activity, key, resetKey, isExpanded, visible.key, visible.shownAt])
+
+ const displayed =
+ !activity.isActive || isExpanded || key === visible.key ? activity : visible.activity
+ const header = (
+
+ )
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx
index 32183aec931..fc41c6ae98b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx
@@ -1,17 +1,23 @@
'use client'
-import { type ComponentType, type ReactNode, useMemo, useState } from 'react'
-import { ActivityStatus } from '@/components/ui/activity-status'
+import { type ComponentType, type ReactNode, useState } from 'react'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
-import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
+import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon'
import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown'
import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
-import { getToolActivitySummary } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
+import {
+ getActiveToolActivityTitle,
+ getActivityStatusTool,
+ getToolActivitySummary,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
-import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
+import {
+ getActivityAttentionKey,
+ needsToolInput,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
import {
getAgentIcon,
isToolDone,
@@ -44,7 +50,7 @@ export interface AgentGroupProps {
items: AgentGroupItem[]
isDelegating?: boolean
isStreaming?: boolean
- /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */
+ /** This lane can receive work; main lanes close when a later transcript segment begins. */
isLaneOpen?: boolean
/** Opens a subagent group on first render. */
defaultExpanded?: boolean
@@ -52,10 +58,10 @@ export interface AgentGroupProps {
autoScrollActivity?: boolean
}
-function toolStatusTitle(tool: ToolCallData): string {
+function activeToolTitle(tool: ToolCallData): string {
return getToolStatusDisplayTitle(
tool.displayTitle || String(tool.toolName ?? ''),
- tool.status,
+ tool.status === ToolCallStatus.success ? ToolCallStatus.executing : tool.status,
tool.toolName,
tool.activityDescription
)
@@ -146,7 +152,6 @@ interface AgentGroupViewProps extends AgentGroupProps {
export function AgentGroupView({
agentName,
- agentLabel,
items,
isDelegating = false,
isStreaming = false,
@@ -164,28 +169,8 @@ export function AgentGroupView({
)
const isMainAgent = agentName === 'mothership'
- /** Open lanes surface their latest work, including work delegated to nested agents. */
- const status = useMemo(() => {
- if (isMainAgent || !isLaneOpen) return undefined
- const tools = collectGroupTools(items)
- const running = tools.filter((tool) => tool.status === ToolCallStatus.executing)
- if (running.length > 0) {
- const latest = running.reduce((newest, tool) =>
- (tool.startedAt ?? 0) >= (newest.startedAt ?? 0) ? tool : newest
- )
- const title = toolStatusTitle(latest)
- return running.length > 1 ? `${title} + ${running.length - 1}` : title
- }
- const last = tools.at(-1)
- return last ? toolStatusTitle(last) : undefined
- }, [isLaneOpen, isMainAgent, items])
- const completedTools = !isMainAgent && !isLaneOpen ? collectGroupTools(items) : []
- const headerText = status
- ? `${agentLabel} — ${status}`
- : completedTools.length > 0
- ? `${agentLabel} — ${getToolActivitySummary(completedTools)}`
- : agentLabel
- const hasItems = items.length > 0
+ const tools = isMainAgent ? [] : collectGroupTools(items)
+ const statusTool = getActivityStatusTool(tools)
const resolved = isAgentGroupResolved(items)
const browserAgentAvailable = isBrowserAgentAvailable()
const activeBrowserTakeover =
@@ -259,28 +244,49 @@ export function AgentGroupView({
ToolCallComponent={ToolCallComponent}
renderItem={renderItem}
autoScrollActivity={autoScrollActivity}
+ isActive={isStreaming && isLaneOpen}
/>
) : (
{items.map(renderItem)}
)
- const header =
+ const headerText = isWorking
+ ? statusTool
+ ? getActiveToolActivityTitle(activeToolTitle(statusTool), statusTool, tools)
+ : 'Thinking'
+ : tools.length > 0
+ ? getToolActivitySummary(tools)
+ : 'Tool activity'
+ const headerActive =
+ isWorking &&
+ (!statusTool ||
+ statusTool.status === ToolCallStatus.executing ||
+ statusTool.status === ToolCallStatus.success)
+ const collapsible =
+ items.length > 1 ||
+ items.some(
+ (item) =>
+ item.type !== 'tool' ||
+ needsToolInput(item.data) ||
+ item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID
+ )
return (
{isMainAgent ? (
activity
- ) : hasItems ? (
-
{activity}
-
- ) : (
- header
+
)}
{activeBrowserTakeover && (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
index 1e0b1923cdc..1b1075a60ce 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
@@ -118,10 +118,11 @@ describe('AgentGroup inline main activity', () => {
afterEach(() => {
act(() => root.unmount())
container.remove()
+ vi.useRealTimers()
})
it.each(['mothership', 'workflow', 'browser'])(
- 'uses the same model description in the %s live header and expanded row',
+ 'shows one model-described action without a redundant %s disclosure',
(agentName) => {
act(() =>
root.render(
@@ -148,7 +149,8 @@ describe('AgentGroup inline main activity', () => {
)
const statuses = [...container.querySelectorAll('[role="status"]')]
- expect(statuses).toHaveLength(agentName === 'mothership' ? 1 : 2)
+ expect(statuses).toHaveLength(1)
+ expect(container.querySelector
('[role="button"]')).toBeNull()
for (const status of statuses) {
expect(status.textContent).toContain('Checking the project timeline')
}
@@ -188,12 +190,13 @@ describe('AgentGroup inline main activity', () => {
)
expect(container.textContent).toBe(expected)
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1)
- expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[role="button"]')).toBeNull()
expect(container.querySelector('[data-state]')).toBeNull()
expect(Boolean(container.querySelector('[class*="shimmer"]'))).toBe(status === 'executing')
})
- it('replaces the active status in place and expands the full completed history', () => {
+ it('paces the active status in place and expands the full completed history', () => {
+ vi.useFakeTimers()
const first: AgentGroupItem = {
type: 'tool',
data: { id: 'first', toolName: 'grep', displayTitle: 'Searching files', status: 'executing' },
@@ -217,14 +220,18 @@ describe('AgentGroup inline main activity', () => {
render([first])
expect(container.textContent).toBe('Searching files')
- expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[role="button"]')).toBeNull()
const activity = container.firstElementChild
render([first, next])
expect(container.firstElementChild).toBe(activity)
+ expect(container.textContent).toBe('Searching files')
+ act(() => vi.advanceTimersByTime(1000))
expect(container.textContent).toBe('Reading notes')
expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
- expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
+ expect(
+ container.querySelector('[role="button"]')?.getAttribute('aria-expanded')
+ ).toBe('false')
expect(container.querySelector('svg')).not.toBeNull()
expect(container.textContent).not.toContain('Sim')
@@ -237,7 +244,7 @@ describe('AgentGroup inline main activity', () => {
)
expect(container.textContent).toBe('Searched files, read files')
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
- const header = container.querySelector('button')
+ const header = container.querySelector('[role="button"]')
act(() => header?.click())
expect(header?.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
@@ -274,7 +281,7 @@ describe('AgentGroup inline main activity', () => {
)
)
render([first, second])
- act(() => container.querySelector('button')?.click())
+ act(() => container.querySelector('[role="button"]')?.click())
render([
first,
second,
@@ -288,7 +295,9 @@ describe('AgentGroup inline main activity', () => {
},
},
])
- expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
+ expect(
+ container.querySelector('[role="button"]')?.getAttribute('aria-expanded')
+ ).toBe('true')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
'Read notesRead more notesRunning checks'
)
@@ -327,15 +336,15 @@ describe('AgentGroup inline main activity', () => {
render([wait])
act(() => vi.advanceTimersByTime(2000))
expect(container.textContent).toBe('Waiting 1s')
- expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[role="button"]')).toBeNull()
render([wait, read])
expect(container.textContent).toBe('Waiting 1s')
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
- const header = container.querySelector('button')
+ const header = container.querySelector('[role="button"]')
act(() => header?.click())
expect(header?.hasAttribute('aria-label')).toBe(false)
- expect(header?.textContent).toBe('Waiting 1s')
- expect(header).toHaveAccessibleName('Waiting 1s')
+ expect(header?.textContent).toBe('Tool activity')
+ expect(header).toHaveAccessibleName('Tool activity')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
'Waiting 1sRead notes'
)
@@ -351,8 +360,8 @@ describe('AgentGroup inline main activity', () => {
read,
{ ...wait, data: { ...wait.data, id: 'wait-second' } },
])
- expect(header?.textContent).toBe('Waiting 3s')
- expect(header).toHaveAccessibleName('Waiting 3s')
+ expect(header?.textContent).toBe('Tool activity')
+ expect(header).toHaveAccessibleName('Tool activity')
expect(container.querySelector('.overflow-y-auto')).toBe(viewport)
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
'WaitedRead notesWaiting 3s'
@@ -404,9 +413,9 @@ describe('AgentGroup inline main activity', () => {
})
)
)
- const header = container.querySelector('button')
- expect(header?.textContent).toBe('Agent — Read files, ran commands')
- expect(header).toHaveAccessibleName('Agent — Read files, ran commands')
+ const header = container.querySelector('[role="button"]')
+ expect(header?.textContent).toBe('Read files, ran commands')
+ expect(header).toHaveAccessibleName('Read files, ran commands')
expect(container.querySelectorAll('[data-tool-call-id]')).toHaveLength(0)
act(() => header?.click())
expect(
@@ -443,12 +452,19 @@ describe('AgentGroup inline main activity', () => {
],
ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => {
const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle)
- return renderStatus ? renderStatus(status) : status
+ return renderStatus
+ ? renderStatus({
+ label: displayTitle,
+ activeLabel: displayTitle,
+ isActive: true,
+ icon: createElement('svg', { 'data-tool-call-id': toolCallId }),
+ })
+ : status
},
})
)
)
- const headers = Array.from(container.querySelectorAll('button'))
+ const headers = Array.from(container.querySelectorAll('[role="button"]'))
expect(headers).toHaveLength(2)
expect(headers.every((header) => header.getAttribute('aria-expanded') === 'true')).toBe(true)
act(() => headers[0].click())
@@ -549,7 +565,14 @@ describe('AgentGroup inline main activity', () => {
isStreaming: true,
ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => {
const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle)
- return renderStatus ? renderStatus(status) : status
+ return renderStatus
+ ? renderStatus({
+ label: displayTitle,
+ activeLabel: displayTitle,
+ isActive: true,
+ icon: createElement('svg', { 'data-tool-call-id': toolCallId }),
+ })
+ : status
},
})
)
@@ -596,8 +619,8 @@ describe('AgentGroup browser takeover', () => {
expect(liftedQuestion).toBeDefined()
expect(collapsedLog?.contains(liftedQuestion ?? null)).toBe(false)
- const header = Array.from(container.querySelectorAll('button')).find((button) =>
- button.textContent?.includes('Browser Agent')
+ const header = Array.from(container.querySelectorAll('[role="button"]')).find(
+ (button) => button.hasAttribute('aria-expanded')
)
act(() => header?.click())
expect(container.querySelector('[data-state="open"]')).not.toBeNull()
@@ -685,7 +708,7 @@ describe('AgentGroup browser takeover', () => {
expect(container.querySelector('.animate-stream-fade-in')).toBeNull()
// Groups never auto-expand: the answered question lives inside the
// collapsed log until the user opens it manually.
- const headerToggle = container.querySelector('button[class*="group/agent"]')
+ const headerToggle = container.querySelector('[role="button"][class*="group/agent"]')
expect(headerToggle).not.toBeNull()
act(() => {
headerToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
@@ -750,10 +773,10 @@ describe('AgentGroup nested status line', () => {
namedTool('Reading workflow', 'success' as ToolCallStatus, 1),
group([namedTool('Deploying Invoice Sync as API', 'executing' as ToolCallStatus, 2)]),
])
- expect(header).toContain('Workflow Agent — Deploying Invoice Sync as API')
+ expect(header).toContain('Deploying Invoice Sync as API')
})
- it('counts running tools across depths with the + n suffix', () => {
+ it('selects the latest running tool across depths', () => {
const header = render([
namedTool('Reading workflow', 'executing' as ToolCallStatus, 1),
group([
@@ -761,8 +784,8 @@ describe('AgentGroup nested status line', () => {
namedTool('Checking deployment status', 'executing' as ToolCallStatus, 2),
]),
])
- // Latest start wins; the other two running become the overflow count.
- expect(header).toContain('Deploying Invoice Sync as API + 2')
+ /** The latest start wins across the subtree. */
+ expect(header).toContain('Deploying Invoice Sync as API')
})
it('falls back to the last tool at any depth when nothing is running', () => {
@@ -770,6 +793,6 @@ describe('AgentGroup nested status line', () => {
namedTool('Reading workflow', 'success' as ToolCallStatus, 1),
group([namedTool('Deploying Invoice Sync as API', 'success' as ToolCallStatus, 2)]),
])
- expect(header).toContain('Workflow Agent — Deployed Invoice Sync as API')
+ expect(header).toContain('Deploying Invoice Sync as API')
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx
index 70b38e2bf03..13ee89ed18f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx
@@ -4,6 +4,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import {
BrowserAgentIcon,
@@ -227,6 +228,58 @@ describe('BrowserAgentIcon', () => {
)
}
+ it('keeps a loaded favicon paired with the paced label when disclosure appears and the site changes', () => {
+ vi.useFakeTimers()
+ const first = tool({
+ id: 'first',
+ displayTitle: 'Opening first page',
+ result: undefined,
+ status: 'executing',
+ params: { url: 'https://example.com/' },
+ })
+ const second = tool({
+ id: 'second',
+ displayTitle: 'Opening second page',
+ result: undefined,
+ status: 'executing',
+ params: { url: 'https://example.org/' },
+ })
+ const renderGroup = (items: AgentGroupItem[]) =>
+ act(() =>
+ root.render(
+
+ )
+ )
+ try {
+ openPage('https://example.com/')
+ renderGroup([first])
+ const img = container.querySelector('img')!
+ act(() => img.dispatchEvent(new Event('load')))
+ act(() => vi.advanceTimersByTime(100))
+ openPage('https://example.org/')
+ renderGroup([first, second])
+ expect(container.querySelector('img')).toBe(img)
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Opening first page')
+ expect(container.querySelector('[role="button"]')).not.toBeNull()
+ act(() => vi.advanceTimersByTime(900))
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Opening second page')
+ const nextImage = container.querySelector('img')!
+ expect(nextImage).not.toBe(img)
+ expect(nextImage.src).toBe('https://example.org/favicon.ico')
+ act(() => nextImage.dispatchEvent(new Event('error')))
+ expect(container.querySelector('img')).toBeNull()
+ expect(container.querySelector('[role="status"] svg')).not.toBeNull()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('does not contact sites from history, other chats, or pending navigation', () => {
render('https://example.com/document')
expect(container.querySelector('img')).toBeNull()
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx
index 82e62b4e3a4..140ddea8e48 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx
@@ -11,6 +11,7 @@ interface MainAgentActivityProps {
ToolCallComponent: ComponentType
renderItem: (item: AgentGroupItem, index: number) => ReactNode
autoScrollActivity: boolean
+ isActive: boolean
}
/** Keep answers and interactions in the transcript, outside collapsible tool history. */
@@ -27,15 +28,17 @@ export function MainAgentActivity({
ToolCallComponent,
renderItem,
autoScrollActivity,
+ isActive,
}: MainAgentActivityProps) {
const activity: ReactNode[] = []
let tools: ToolCallData[] = []
- const flushTools = () => {
+ const flushTools = (active = false) => {
if (tools.length === 0) return
activity.push(
@@ -63,7 +66,7 @@ export function MainAgentActivity({
)
}
- flushTools()
+ flushTools(isActive)
return {activity}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
index e0b17ababbf..e1c23b42d1b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
@@ -24,7 +24,18 @@ describe('getToolActivitySummary', () => {
tool('browser_type'),
tool('browser_navigate'),
])
- ).toBe('Navigated pages, read pages +1 more')
+ ).toBe('Navigated, read pages +1 more')
+ })
+
+ it.each([
+ [['browser_navigate', 'browser_read_text'], 'Navigated, read pages'],
+ [['browser_read_text', 'browser_navigate'], 'Read, navigated pages'],
+ [['browser_navigate', 'browser_read_text', 'browser_scroll'], 'Navigated, read pages +1 more'],
+ [['browser_navigate', 'browser_type'], 'Navigated pages, entered text'],
+ [['browser_navigate', 'browser_navigate'], 'Navigated pages'],
+ [['read', 'browser_read_text'], 'Read files, read pages'],
+ ])('compacts only explicit shared objects: %j', (names, expected) => {
+ expect(getToolActivitySummary((names as string[]).map((name) => tool(name)))).toBe(expected)
})
it('does not describe unsuccessful work as completed actions', () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
index d86c2fbd3af..11b95a3833e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
@@ -2,10 +2,11 @@
import { type ComponentType, Fragment, useState } from 'react'
import { ActivityStatus } from '@/components/ui/activity-status'
-import { getToolActivityLabel } from '@/lib/copilot/tools/tool-activity'
+import { getToolActivitySummaryActions } from '@/lib/copilot/tools/tool-activity'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
-import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
+import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
+import { getActivityAttentionKey } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
@@ -22,92 +23,119 @@ export function getToolActivitySummary(tools: ToolCallData[]): string {
tool.activityDescription
)
}
- const labels = new Set()
+ const { labels, additionalActions } = getToolActivitySummaryActions(
+ tools.filter((tool) => tool.status === ToolCallStatus.success),
+ MAX_SUMMARY_ACTIONS
+ )
+ const summary = labels.join(', ')
+ const summaryLabel = summary ? summary[0].toUpperCase() + summary.slice(1) : 'Tool activity'
+ return [
+ additionalActions > 0 ? `${summaryLabel} +${additionalActions} more` : summaryLabel,
+ ...getToolActivityOutcomes(tools),
+ ].join(' · ')
+}
+
+function getToolActivityOutcomes(tools: ToolCallData[]): string[] {
let failed = 0
let stopped = 0
let skipped = 0
for (const tool of tools) {
- if (tool.status === ToolCallStatus.success) {
- labels.add(getToolActivityLabel(tool.toolName, tool.params))
- } else if (tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected)
- failed++
+ if (tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected) failed++
else if (tool.status === ToolCallStatus.cancelled || tool.status === ToolCallStatus.interrupted)
stopped++
else if (tool.status === ToolCallStatus.skipped) skipped++
}
- const summary = Array.from(labels).slice(0, MAX_SUMMARY_ACTIONS).join(', ')
- const summaryLabel = summary ? summary[0].toUpperCase() + summary.slice(1) : 'Tool activity'
- const additionalActions = Math.max(0, labels.size - MAX_SUMMARY_ACTIONS)
- const outcomes = [
- failed && `${failed} failed`,
- stopped && `${stopped} stopped`,
- skipped && `${skipped} skipped`,
- ].filter(Boolean)
return [
- additionalActions > 0 ? `${summaryLabel} +${additionalActions} more` : summaryLabel,
- ...outcomes,
- ].join(' · ')
+ ...(failed ? [`${failed} failed`] : []),
+ ...(stopped ? [`${stopped} stopped`] : []),
+ ...(skipped ? [`${skipped} skipped`] : []),
+ ]
+}
+
+/** Keep earlier parallel failures visible while the latest action continues. */
+export function getActiveToolActivityTitle(
+ label: string,
+ tool: ToolCallData,
+ tools: ToolCallData[]
+): string {
+ return tool.status === ToolCallStatus.executing || tool.status === ToolCallStatus.success
+ ? [label, ...getToolActivityOutcomes(tools)].join(' · ')
+ : label
+}
+
+/** Keep running work visible until every parallel call finishes. */
+export function getActivityStatusTool(tools: ToolCallData[]): ToolCallData | undefined {
+ return (
+ tools.reduce(
+ (newest, tool) =>
+ tool.status === ToolCallStatus.executing &&
+ (!newest || (tool.startedAt ?? 0) >= (newest.startedAt ?? 0))
+ ? tool
+ : newest,
+ undefined
+ ) ?? tools.at(-1)
+ )
}
interface ToolActivityGroupProps {
tools: ToolCallData[]
ToolCallComponent: ComponentType
autoScrollActivity?: boolean
+ isActive?: boolean
}
export function ToolActivityGroup({
tools,
ToolCallComponent,
autoScrollActivity = true,
+ isActive = false,
}: ToolActivityGroupProps) {
const [expanded, setExpanded] = useState(false)
- let activeTool: ToolCallData | undefined
- for (let index = tools.length - 1; index >= 0; index--) {
- if (tools[index].status === ToolCallStatus.executing) {
- activeTool = tools[index]
- break
- }
- }
- const statusTool = activeTool ?? tools[tools.length - 1]
+ const statusTool = getActivityStatusTool(tools)
+ if (!statusTool) return null
+ const working = isActive || tools.some((tool) => tool.status === ToolCallStatus.executing)
+ const headerActive =
+ working &&
+ (statusTool.status === ToolCallStatus.executing || statusTool.status === ToolCallStatus.success)
+ const attentionKey = getActivityAttentionKey(tools)
const SummaryIcon = getToolIcon(tools[0].toolName)
return (
{
- if (tools.length === 1) return status
- return (
- }
- />
- )
- }
- expanded={expanded}
- onToggle={() => setExpanded(!expanded)}
- isStreaming={Boolean(activeTool) && autoScrollActivity}
- >
-
- {tools.map((tool) => (
-
- {tool.id === statusTool.id ? (
- status
- ) : (
-
- )}
-
- ))}
-
-
- )
- }}
+ renderStatus={(status) => (
+ ,
+ }}
+ activityKey={statusTool.id}
+ attentionKey={attentionKey}
+ collapsible={tools.length > 1}
+ expanded={expanded}
+ onToggle={() => setExpanded(!expanded)}
+ isStreaming={working && autoScrollActivity}
+ >
+
+ {tools.map((tool) => (
+
+ {tool.id === statusTool.id ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ )}
/>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
index 52e9489811d..1692702faec 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
@@ -5,9 +5,11 @@ import { act, type ReactNode, type SVGProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
+import { ToolCallItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
+import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay'
import { getBlock, getBlockByToolName } from '@/blocks/registry'
-import { ToolCallItem } from './tool-call-item'
vi.mock('@/components/ui', () => ({
ShimmerText: ({ children }: { children: ReactNode }) => {children},
@@ -185,7 +187,8 @@ describe('ToolCallItem', () => {
/>
)
- expect(markup).toContain('