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 ( -
- - - - - {children} - - - + {collapsible && ( + + )} +
+ {collapsible && ( + + +
+ + {children} + +
+
+
+ )} ) } 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(' { /> ) - expect(markup).toContain(' { + vi.useFakeTimers() + const container = document.createElement('div') + const root = createRoot(container) + const gmail = (props: SVGProps) => + const slack = (props: SVGProps) => + vi.mocked(getBlockByToolName).mockImplementation( + (name) => + ({ name, icon: name === 'gmail_read_v2' ? gmail : slack }) as ReturnType< + typeof getBlockByToolName + > + ) + const first: ToolCallData = { + id: 'mail', + toolName: 'gmail_read_v2', + displayTitle: 'Reading mail', + status: 'executing', + } + const next: ToolCallData = { + id: 'slack', + toolName: 'slack_message', + displayTitle: 'Reading messages', + status: 'executing', + } + const render = (tools: ToolCallData[], isActive = true) => + act(() => + root.render( + + ) + ) + const header = () => container.querySelector('[role="status"]')! + try { + render([first]) + const icon = header().querySelector('[data-testid="gmail-icon"]') + expect(icon).not.toBeNull() + act(() => vi.advanceTimersByTime(100)) + render([{ ...first, status: 'success' }, next]) + expect(header().textContent).toBe('Reading mail') + expect(header().querySelector('[data-testid="gmail-icon"]')).toBe(icon) + expect(header().querySelector('[data-testid="slack-icon"]')).toBeNull() + act(() => vi.advanceTimersByTime(900)) + expect(header().textContent).toBe('Reading messages') + expect(header().querySelector('[data-testid="slack-icon"]')).not.toBeNull() + render([{ ...next, status: 'success' }], false) + expect(header().querySelector('[data-testid="slack-icon"]')).not.toBeNull() + expect(container.querySelector('[role="button"]')).toBeNull() + } finally { + act(() => root.unmount()) + vi.mocked(getBlockByToolName).mockReset() + vi.useRealTimers() + } + }) + it('refreshes the read icon when custom blocks hydrate after mount', () => { vi.mocked(getBlock).mockReturnValue(undefined) const container = document.createElement('div') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 116d6723ddb..7d2bec15dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react' import { isPlainRecord } from '@sim/utils/object' -import { ActivityStatus } from '@/components/ui/activity-status' +import { ActivityStatus, type ActivityStatusProps } from '@/components/ui/activity-status' import { CallIntegrationTool, PrepareFileEdit, @@ -55,7 +55,12 @@ export interface ToolCallItemProps { /** When the call started, used to count down a running `wait`. */ startedAt?: number /** Projects one computed status into a header and history without duplicating tool state. */ - renderStatus?: (status: ReactNode) => ReactNode + renderStatus?: (status: ToolActivityPresentation) => ReactNode +} + +export interface ToolActivityPresentation extends ActivityStatusProps { + /** Keep the action in progress while its containing activity group remains open. */ + activeLabel: string } function stringParam(params: Record | undefined, key: string): string { @@ -247,18 +252,18 @@ export function ToolCallItem({ ) } - const activity = ( - - ) : ( - - ) - } - /> - ) - return renderStatus ? renderStatus(activity) : activity + const activity: ToolActivityPresentation = { + label: title, + activeLabel: + status === 'success' + ? getToolStatusDisplayTitle(liveTitle, 'executing', toolName, activityDescription) + : title, + isActive: isExecuting, + icon: BlockIcon ? ( + + ) : ( + + ), + } + return renderStatus ? renderStatus(activity) : } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts index 245c0f4a112..e4c5df1a29f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions.ts @@ -10,3 +10,15 @@ export function needsToolInput(tool: ToolCallData): boolean { tool.params?.operation === 'handoff') ) } + +/** Attention changes must never wait for the activity header's cosmetic cadence. */ +export function getActivityAttentionKey(tools: ToolCallData[]): string { + return tools + .filter( + (tool) => + (tool.status !== ToolCallStatus.executing && tool.status !== ToolCallStatus.success) || + needsToolInput(tool) + ) + .map((tool) => `${tool.id}:${tool.status}`) + .join('|') +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index ab66c806c66..4cf6dbe29fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -25,7 +25,7 @@ import { import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize' import type { ContentBlock } from '../../types' import { - assistantMessageHasVisibleExecutingTool, + assistantMessageHasVisibleActivity, deriveThinkingLabel, getOrchestratorMessageText, parseBlocks, @@ -813,7 +813,22 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { }) }) -describe('assistantMessageHasVisibleExecutingTool', () => { +describe('assistantMessageHasVisibleActivity', () => { + it('keeps the main tail active between calls but closes it when narration follows', () => { + const blocks = [mainToolCall('finished', 'read')] + expect(assistantMessageHasVisibleActivity(parseBlocks(blocks), true)).toBe(true) + expect(assistantMessageHasVisibleActivity(parseBlocks(blocks), false)).toBe(false) + expect( + assistantMessageHasVisibleActivity(parseBlocks([...blocks, mainText('Done.')]), true) + ).toBe(false) + }) + + it('lets an open subagent own its indicator before and between calls', () => { + const segments = parseBlocks([subagentStart('workflow', 'S1', 'main')]) + expect(assistantMessageHasVisibleActivity(segments, true)).toBe(true) + expect(assistantMessageHasVisibleActivity(segments, false)).toBe(false) + }) + it.each([undefined, 'main'])('retains an earlier running tool with spanId=%s', (spanId) => { const blocks: ContentBlock[] = [ { @@ -827,14 +842,12 @@ describe('assistantMessageHasVisibleExecutingTool', () => { ] const segments = parseBlocks(blocks) expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group']) - expect(assistantMessageHasVisibleExecutingTool(segments)).toBe(true) + expect(assistantMessageHasVisibleActivity(segments)).toBe(true) }) it('does not treat an open subagent lane as an executing tool row', () => { expect( - assistantMessageHasVisibleExecutingTool( - parseBlocks([subagentStart('workflow', 'S1', 'main')]) - ) + assistantMessageHasVisibleActivity(parseBlocks([subagentStart('workflow', 'S1', 'main')])) ).toBe(false) }) @@ -848,7 +861,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => { timestamp: 3, }, ] - expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(true) + expect(assistantMessageHasVisibleActivity(parseBlocks(blocks))).toBe(true) }) it('does not let open parallel lanes suppress the single turn-level indicator', () => { @@ -856,7 +869,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => { subagentStart('workflow', 'S1', 'main'), subagentStart('search', 'S2', 'main'), ] - expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false) + expect(assistantMessageHasVisibleActivity(parseBlocks(blocks))).toBe(false) }) it('ignores the executing dispatch tool represented by its subagent lane', () => { @@ -871,7 +884,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => { parentToolCallId: 'dispatch-1', }, ] - expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false) + expect(assistantMessageHasVisibleActivity(parseBlocks(blocks))).toBe(false) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 2f029348744..07a0318e4ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -15,6 +15,7 @@ import { PrepareFileEdit, Read as ReadTool } from '@/lib/copilot/generated/tool- import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { getToolDisplayTitle, getToolStatusDisplayTitle, @@ -766,8 +767,11 @@ export function assistantMessageHasRenderableContent( return segments.length > 0 } -/** True when the transcript is already rendering an executing tool row. */ -export function assistantMessageHasVisibleExecutingTool(segments: MessageSegment[]): boolean { +/** The transcript already owns an activity indicator, including gaps between calls. */ +export function assistantMessageHasVisibleActivity( + segments: MessageSegment[], + isStreaming = false +): boolean { const hasExecutingTool = (items: AgentGroupItem[]): boolean => items.some((item) => item.type === 'tool' @@ -775,9 +779,19 @@ export function assistantMessageHasVisibleExecutingTool(segments: MessageSegment : item.type === 'agent_group' && hasExecutingTool(item.group.items) ) - return segments.some( - (segment) => segment.type === 'agent_group' && hasExecutingTool(segment.items) - ) + return segments.some((segment, index) => { + if (segment.type !== 'agent_group') return false + if (hasExecutingTool(segment.items)) return true + if (!isStreaming) return false + if (segment.agentName !== 'mothership') return segment.isOpen || segment.isDelegating + const lastItem = segment.items.at(-1) + return ( + index === segments.length - 1 && + lastItem?.type === 'tool' && + lastItem.data.status === 'success' && + lastItem.data.toolName !== RETIRED_BROWSER_REQUEST_TAKEOVER_ID + ) + }) } export function shouldSmoothTextSegment({ @@ -957,18 +971,17 @@ function MessageContentInner({ if (segments.length === 0 && !isLast) return null - // A visible executing tool row already spins — the turn-level shimmer would - // double it. (A null label means a just-opened lane's shimmer owns the state.) + /** Open activity groups own the shimmer through gaps between tool calls. */ // A mid-stream special tag renders nothing until complete, so its bytes are a // wait, not output — the shimmer bridges it without the quiet-period delay. const thinkingLabel = deriveThinkingLabel(blocks) - const hasExecutingTool = assistantMessageHasVisibleExecutingTool(segments) + const hasActivityIndicator = assistantMessageHasVisibleActivity(segments, isStreaming) const showShimmer = thinkingExpanded && thinkingLabel !== null && (segments.length === 0 || trailingPendingTag || - (isStreamIdle && !trailingStreamActivity && !hasExecutingTool)) + (isStreamIdle && !trailingStreamActivity && !hasActivityIndicator)) const actionsRow = (
@@ -979,7 +992,7 @@ function MessageContentInner({ return (
-
+
{segments.map((segment, i) => { switch (segment.type) { case 'text': @@ -1015,6 +1028,7 @@ function MessageContentInner({ return (
) diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx index ae24197c9ba..4e603e84c75 100644 --- a/apps/sim/components/ui/activity-status.tsx +++ b/apps/sim/components/ui/activity-status.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { OverflowText } from '@sim/emcn' import { ShimmerText } from '@/components/ui/shimmer-text' -interface ActivityStatusProps { +export interface ActivityStatusProps { label: string isActive: boolean icon?: ReactNode diff --git a/apps/sim/lib/copilot/tools/tool-activity.test.ts b/apps/sim/lib/copilot/tools/tool-activity.test.ts index 8c2d90c8725..e0ad93fb872 100644 --- a/apps/sim/lib/copilot/tools/tool-activity.test.ts +++ b/apps/sim/lib/copilot/tools/tool-activity.test.ts @@ -34,6 +34,8 @@ describe('tool activity catalog coverage', () => { if (!isRecordLike(schema) || !Array.isArray(schema.enum)) continue expect(typeof activity, `${tool.id}.${parameter}`).toBe('object') if (typeof activity === 'string') continue + expect('parameter' in activity, `${tool.id}.${parameter}`).toBe(true) + if (!('parameter' in activity)) continue expect(activity.parameter).toBe(parameter) expect(Object.keys(activity.operations).sort()).toEqual([...schema.enum].sort()) if (typeof schema.default === 'string') { @@ -136,7 +138,7 @@ describe('getToolActivityLabel', () => { ])('keeps legacy combined table operations consistent with %s', (toolName) => { const activity = TOOL_ACTIVITIES[toolName] expect(typeof activity).toBe('object') - if (typeof activity === 'string') return + if (typeof activity === 'string' || !('parameter' in activity)) return for (const operation of Object.keys(activity.operations)) { expect(getToolActivityLabel('user_table', { operation })).toBe( getToolActivityLabel(toolName, { operation }) diff --git a/apps/sim/lib/copilot/tools/tool-activity.ts b/apps/sim/lib/copilot/tools/tool-activity.ts index 7f4b367b5ab..10765bac5b1 100644 --- a/apps/sim/lib/copilot/tools/tool-activity.ts +++ b/apps/sim/lib/copilot/tools/tool-activity.ts @@ -1,3 +1,18 @@ +interface SharedObjectActivity { + verb: string + object: string +} + +type ActivityPhrase = string | SharedObjectActivity + +const PAGE_NAVIGATION = { + verb: 'navigated', + object: 'pages', +} as const satisfies SharedObjectActivity +const PAGE_READING = { verb: 'read', object: 'pages' } as const satisfies SharedObjectActivity +const PAGE_SEARCHING = { verb: 'searched', object: 'pages' } as const satisfies SharedObjectActivity +const PAGE_SCROLLING = { verb: 'scrolled', object: 'pages' } as const satisfies SharedObjectActivity + interface OperationActivity { label: string parameter: 'operation' | 'action' @@ -61,34 +76,34 @@ const TABLE_ENRICHMENTS_OPERATIONS = { } as const /** Client-owned summaries; the executable tool registry stays outside the UI bundle. */ -export const TOOL_ACTIVITIES: Readonly> = { +export const TOOL_ACTIVITIES: Readonly> = { apply_file_edit: 'edited files', browser_click: 'clicked elements', browser_click_at: 'clicked elements', browser_close_tab: 'closed tabs', browser_drag: 'dragged elements', - browser_extract: 'read pages', + browser_extract: PAGE_READING, browser_fill_form: 'filled forms', - browser_find: 'searched pages', - browser_go_back: 'navigated pages', - browser_go_forward: 'navigated pages', + browser_find: PAGE_SEARCHING, + browser_go_back: PAGE_NAVIGATION, + browser_go_forward: PAGE_NAVIGATION, browser_hover: 'hovered over elements', browser_insert_text: 'entered text', browser_list_downloads: 'listed downloads', browser_list_sessions: 'checked signed-in sites', browser_list_tabs: 'listed tabs', - browser_navigate: 'navigated pages', + browser_navigate: PAGE_NAVIGATION, browser_open_tab: 'opened tabs', - browser_open_url: 'navigated pages', + browser_open_url: PAGE_NAVIGATION, browser_press_key: 'pressed keys', - browser_read_text: 'read pages', - browser_reload: 'navigated pages', + browser_read_text: PAGE_READING, + browser_reload: PAGE_NAVIGATION, browser_request_takeover: 'resumed browser control', browser_screenshot: 'captured screenshots', - browser_scroll: 'scrolled pages', + browser_scroll: PAGE_SCROLLING, browser_select_option: 'selected options', browser_set_checked: 'updated selections', - browser_snapshot: 'read pages', + browser_snapshot: PAGE_READING, browser_switch_tab: 'switched tabs', browser_type: 'entered text', browser_wait_for: 'waited', @@ -382,13 +397,43 @@ export const TOOL_ACTIVITIES: Readonly): string { +function resolveToolActivity(toolName: string, params?: Record): ActivityPhrase { const activity = Object.hasOwn(TOOL_ACTIVITIES, toolName) ? TOOL_ACTIVITIES[toolName] : undefined if (!activity) return toolName.startsWith('browser_') ? 'used the browser' : 'used tools' - if (typeof activity === 'string') return activity + if (typeof activity === 'string' || 'verb' in activity) return activity const suppliedOperation = params?.[activity.parameter] const operation = suppliedOperation === undefined ? activity.defaultOperation : suppliedOperation return typeof operation === 'string' && Object.hasOwn(activity.operations, operation) ? activity.operations[operation] : activity.label } + +function activityLabel(activity: ActivityPhrase): string { + return typeof activity === 'string' ? activity : `${activity.verb} ${activity.object}` +} + +export function getToolActivityLabel(toolName: string, params?: Record): string { + return activityLabel(resolveToolActivity(toolName, params)) +} + +/** Compact only explicitly related phrases, after capping, so the shared object remains visible. */ +export function getToolActivitySummaryActions( + tools: ReadonlyArray<{ toolName: string; params?: Record }>, + limit: number +): { labels: string[]; additionalActions: number } { + const unique = new Map() + for (const tool of tools) { + const activity = resolveToolActivity(tool.toolName, tool.params) + unique.set(activityLabel(activity), activity) + } + const visible = Array.from(unique.values()).slice(0, limit) + const labels = visible.map((activity, index) => { + const next = visible[index + 1] + return typeof activity !== 'string' && + typeof next !== 'string' && + next?.object === activity.object + ? activity.verb + : activityLabel(activity) + }) + return { labels, additionalActions: Math.max(0, unique.size - limit) } +}