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 index 9566b47c9fb..df297e5bdcb 100644 --- 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 @@ -16,11 +16,12 @@ function items(tools: ToolCallData[]): AgentGroupItem[] { return tools.map((data) => ({ type: 'tool', data })) } -describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (agentName) => { +describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (agentName) => { let root: Root let container: HTMLDivElement beforeEach(() => { vi.useFakeTimers() + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false })) ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) @@ -30,6 +31,7 @@ describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (age act(() => root.unmount()) container.remove() vi.useRealTimers() + vi.unstubAllGlobals() }) const render = (tools: ToolCallData[], active = true) => act(() => @@ -46,6 +48,34 @@ describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (age const header = () => container.querySelector('[role="status"]') const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms)) + it('leaves empty lanes to the turn indicator and shows the first action immediately', () => { + render([]) + expect(container.childElementCount).toBe(0) + advance(100) + render([tool('first')]) + expect(header()?.textContent).toBe('Reading first') + const row = header() + render([tool('first', 'success')], false) + expect(header()?.textContent).toBe('Read first') + expect(header()).toBe(row) + }) + + it('preserves a successful model description in the header and expanded history', () => { + const completed = { + ...tool('first', 'success'), + activityDescription: 'Read the latest inbox emails', + } + render([completed], false) + expect(header()?.textContent).toBe(completed.activityDescription) + render([completed, tool('second', 'success')], false) + const trigger = container.querySelector('[role="button"]')! + act(() => trigger.click()) + expect(container.querySelector('[data-state="open"]')?.textContent).toContain( + completed.activityDescription + ) + expect(container.textContent).not.toContain('Completed:') + }) + it('shows the first action immediately and coalesces bursts without replaying a backlog', () => { render([]) advance(100) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts new file mode 100644 index 00000000000..e9f93a653d5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts @@ -0,0 +1,44 @@ +import type { + AgentGroupItem, + NestedAgentGroup, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' + +/** Empty agent lanes share the turn's thinking indicator until they have output. */ +export function hasAgentGroupItemContent(item: AgentGroupItem): boolean { + switch (item.type) { + case 'tool': + return true + case 'text': + return item.content.trim().length > 0 + case 'agent_group': + return item.group.items.some(hasAgentGroupItemContent) + } +} + +/** Finds empty live lanes at any depth whose wait belongs to the turn indicator. */ +export function hasPendingAgentGroup( + group: Pick +): boolean { + return ( + ((group.isOpen || group.isDelegating) && !group.items.some(hasAgentGroupItemContent)) || + group.items.some((item) => item.type === 'agent_group' && hasPendingAgentGroup(item.group)) + ) +} + +/** + * Every tool in a group, in stream order, including those run by nested + * agents. A parent's status line speaks for the whole subtree it delegated, + * so a grandchild's work is what surfaces while the parent itself waits. + */ +export function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] { + const tools: ToolCallData[] = [] + const walk = (list: AgentGroupItem[]) => { + for (const item of list) { + if (item.type === 'tool') tools.push(item.data) + else if (item.type === 'agent_group') walk(item.group.items) + } + } + walk(items) + return tools +} 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 fc41c6ae98b..fd9b7a5797a 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,10 +1,15 @@ 'use client' import { type ComponentType, type ReactNode, useState } from 'react' +import { ThinkingLoader } from '@/components/ui/thinking-loader' 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 { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' +import { + collectGroupTools, + hasAgentGroupItemContent, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' 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' @@ -67,23 +72,6 @@ function activeToolTitle(tool: ToolCallData): string { ) } -/** - * Every tool in a group, in stream order, including those run by nested - * agents. A parent's status line speaks for the whole subtree it delegated, - * so a grandchild's work is what surfaces while the parent itself waits. - */ -function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] { - const tools: ToolCallData[] = [] - const walk = (list: AgentGroupItem[]) => { - for (const item of list) { - if (item.type === 'tool') tools.push(item.data) - else if (item.type === 'agent_group') walk(item.group.items) - } - } - walk(items) - return tools -} - /** Reveal blocking interactions even when a parent group was manually collapsed. */ function hasPendingInteraction(items: AgentGroupItem[]): boolean { return items.some((item) => { @@ -162,12 +150,6 @@ export function AgentGroupView({ renderBrowserTakeover, }: AgentGroupViewProps) { const AgentIcon = getAgentIcon(agentName) - const agentIcon = - agentName === 'browser' ? ( - - ) : ( - - ) const isMainAgent = agentName === 'mothership' const tools = isMainAgent ? [] : collectGroupTools(items) const statusTool = getActivityStatusTool(tools) @@ -178,6 +160,14 @@ export function AgentGroupView({ const nestedBrowserTakeover = browserAgentAvailable && hasNestedBrowserTakeover(items) const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) + const agentIcon = + isWorking && !statusTool ? ( + + ) : agentName === 'browser' ? ( + + ) : ( + + ) const [manualExpanded, setManualExpanded] = useState(defaultExpanded) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) @@ -188,6 +178,9 @@ export function AgentGroupView({ nestedBrowserTakeover || (activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded) + const meaningfulItems = items.filter(hasAgentGroupItemContent) + if (meaningfulItems.length === 0) return null + const toggleExpanded = () => { if (activeBrowserTakeover) { setExpandedTakeoverId(expanded ? null : activeBrowserTakeover.id) @@ -229,6 +222,7 @@ export function AgentGroupView({ /> ) } + if (!item.content.trim()) return null return ( 1 || - items.some( + meaningfulItems.length > 1 || + meaningfulItems.some( (item) => item.type !== 'tool' || needsToolInput(item.data) || diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx new file mode 100644 index 00000000000..e7d75c4f3f7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx @@ -0,0 +1,200 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MessageContent } from '@/app/workspace/[workspaceId]/home/components/message-content/message-content' +import type { ContentBlock, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + +function start(name: string, parentSpanId = 'main'): ContentBlock { + return { type: 'subagent', content: name, spanId: name, parentSpanId, timestamp: 1 } +} + +function tool(spanId: string, status: ToolCallStatus = 'executing'): ContentBlock { + return { + type: 'tool_call', + spanId, + toolCall: { + id: `${spanId}-read`, + name: 'read', + calledBy: spanId, + status, + activityDescription: `Reading ${spanId} notes`, + }, + timestamp: 2, + } +} + +describe('MessageContent shared thinking indicator', () => { + let queryClient: QueryClient + let root: Root + let container: HTMLDivElement + + beforeEach(() => { + queryClient = new QueryClient() + vi.useFakeTimers() + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false })) + ;(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() + queryClient.clear() + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + const render = (blocks: ContentBlock[], isStreaming = true) => + act(() => { + root.render( + + + + ) + }) + const thinking = () => container.querySelectorAll('[aria-hidden="false"] svg') + const groups = () => container.querySelectorAll('[data-agent-group]') + + it('shares one indicator across parallel and nested empty agents', () => { + render([start('workflow'), start('browser'), start('deploy', 'workflow')]) + expect(thinking()).toHaveLength(1) + expect(groups()).toHaveLength(0) + expect(container.querySelector('[role="status"]')).toBeNull() + }) + + it('hands off immediately to meaningful activity without remounting existing rows', () => { + const blocks = [start('workflow'), start('browser')] + render(blocks) + expect(thinking()).toHaveLength(1) + render([...blocks, tool('workflow')]) + expect(thinking()).toHaveLength(0) + expect(groups()).toHaveLength(1) + const firstRow = container.querySelector('[role="status"]') + expect(firstRow?.textContent).toBe('Reading workflow notes') + render([...blocks, tool('workflow'), tool('browser')]) + expect(groups()).toHaveLength(2) + expect(container.querySelector('[role="status"]')).toBe(firstRow) + expect(thinking()).toHaveLength(0) + }) + + it('shows the pending indicator after the only visible agent finishes', () => { + const blocks = [start('workflow'), start('browser'), tool('workflow', 'success')] + render(blocks) + expect(thinking()).toHaveLength(0) + render([...blocks, { type: 'subagent_end', spanId: 'workflow', timestamp: 3 }]) + expect(thinking()).toHaveLength(1) + expect(groups()).toHaveLength(1) + }) + + it('keeps a singleton action flat when its nested agent has no output', () => { + render([start('workflow'), tool('workflow'), start('deploy', 'workflow')]) + expect(groups()).toHaveLength(1) + expect(container.querySelectorAll('[role="status"]')).toHaveLength(1) + expect(container.querySelector('[role="button"]')).toBeNull() + expect(thinking()).toHaveLength(0) + }) + + it('retains nested activity while another child is empty', () => { + render([ + start('workflow'), + start('deploy', 'workflow'), + start('browser', 'workflow'), + tool('deploy'), + ]) + const trigger = container.querySelector('[role="button"]')! + act(() => trigger.click()) + expect(container.querySelector('[data-state="open"]')?.textContent).toContain( + 'Reading deploy notes' + ) + expect(container.querySelectorAll('[role="status"]')).toHaveLength(2) + expect(thinking()).toHaveLength(0) + }) + + it('preserves narration and skips whitespace-only output', () => { + const blocks: ContentBlock[] = [ + start('workflow'), + { type: 'subagent_text', spanId: 'workflow', content: ' ', timestamp: 2 }, + ] + render(blocks) + expect(groups()).toHaveLength(0) + expect(thinking()).toHaveLength(1) + render( + [ + ...blocks, + { type: 'subagent_text', spanId: 'workflow', content: 'Checking the setup.', timestamp: 3 }, + ], + false + ) + expect(groups()).toHaveLength(1) + act(() => container.querySelector('[role="button"]')!.click()) + expect(container.querySelector('[data-state="open"]')?.textContent).toContain( + 'Checking the setup.' + ) + }) + + it.each(['awaiting_approval', 'error', 'cancelled', 'rejected'] as const)( + 'keeps %s tool rows visible while another agent is pending', + (status) => { + render([start('workflow'), start('browser'), tool('workflow', status)]) + expect(groups()).toHaveLength(1) + expect(container.querySelector('[role="status"]')?.textContent).toContain('workflow notes') + expect(thinking()).toHaveLength(1) + } + ) + + it('keeps thinking hidden while prose streams and finishes revealing', () => { + const blocks: ContentBlock[] = [ + start('browser'), + { + type: 'text', + content: 'Here is the result of reviewing the project and checking its configuration.', + timestamp: 3, + }, + ] + render([start('browser'), { type: 'text', content: 'Here is', timestamp: 3 }]) + render(blocks) + act(() => vi.advanceTimersByTime(100)) + expect(thinking()).toHaveLength(0) + render(blocks, false) + expect(thinking()).toHaveLength(0) + }) + + it('waits for the normal quiet period between prose chunks while an agent is pending', () => { + const prose = 'Here is the result of reviewing the project and checking its configuration.' + const blocks: ContentBlock[] = [ + start('browser'), + { type: 'text', content: prose, timestamp: 3 }, + ] + render(blocks) + expect(thinking()).toHaveLength(0) + act(() => vi.advanceTimersByTime(1_499)) + expect(thinking()).toHaveLength(0) + act(() => vi.advanceTimersByTime(1)) + expect(thinking()).toHaveLength(1) + render([...blocks, { type: 'text', content: ' The configuration is valid.', timestamp: 4 }]) + expect(thinking()).toHaveLength(0) + }) + + it('removes thinking when a turn stops or finishes without agent output', () => { + const blocks = [start('workflow'), start('browser')] + render(blocks) + expect(thinking()).toHaveLength(1) + render([...blocks, { type: 'stopped', timestamp: 3 }], false) + expect(thinking()).toHaveLength(0) + expect(groups()).toHaveLength(0) + expect(container.textContent).toContain('Stopped') + render(blocks, false) + expect(thinking()).toHaveLength(0) + expect(groups()).toHaveLength(0) + }) +}) 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 4cf6dbe29fb..19543772721 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 @@ -823,9 +823,9 @@ describe('assistantMessageHasVisibleActivity', () => { ).toBe(false) }) - it('lets an open subagent own its indicator before and between calls', () => { + it('leaves an empty open subagent to the turn indicator', () => { const segments = parseBlocks([subagentStart('workflow', 'S1', 'main')]) - expect(assistantMessageHasVisibleActivity(segments, true)).toBe(true) + expect(assistantMessageHasVisibleActivity(segments, true)).toBe(false) expect(assistantMessageHasVisibleActivity(segments, false)).toBe(false) }) @@ -981,6 +981,6 @@ describe('deriveThinkingLabel', () => { expect(deriveThinkingLabel([mainToolCall('t1', 'workflow')])).toBe('Dispatching…') expect(deriveThinkingLabel([mainToolCall('t1', 'prepare_file_edit')])).toBe('Dispatching…') expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking…') - expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBeNull() + expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBe('Thinking…') }) }) 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 07a0318e4ed..da219a9ff30 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 @@ -23,6 +23,12 @@ import { normalizeToolActivityDescription, } from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import { + collectGroupTools, + hasAgentGroupItemContent, + hasPendingAgentGroup, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' +import { getActivityStatusTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations' @@ -764,7 +770,9 @@ export function assistantMessageHasRenderableContent( : fallbackContent.trim() ? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }] : [] - return segments.length > 0 + return segments.some( + (segment) => segment.type !== 'agent_group' || segment.items.some(hasAgentGroupItemContent) + ) } /** The transcript already owns an activity indicator, including gaps between calls. */ @@ -772,18 +780,19 @@ export function assistantMessageHasVisibleActivity( segments: MessageSegment[], isStreaming = false ): boolean { - const hasExecutingTool = (items: AgentGroupItem[]): boolean => - items.some((item) => - item.type === 'tool' - ? item.data.status === 'executing' - : item.type === 'agent_group' && hasExecutingTool(item.group.items) - ) - return segments.some((segment, index) => { - if (segment.type !== 'agent_group') return false - if (hasExecutingTool(segment.items)) return true + if (segment.type !== 'agent_group' || !segment.items.some(hasAgentGroupItemContent)) { + return false + } + const tools = collectGroupTools(segment.items) + if (tools.some((tool) => tool.status === 'executing')) return true if (!isStreaming) return false - if (segment.agentName !== 'mothership') return segment.isOpen || segment.isDelegating + if (segment.agentName !== 'mothership') { + const statusTool = getActivityStatusTool(tools) + return ( + (segment.isOpen || segment.isDelegating) && (!statusTool || statusTool.status === 'success') + ) + } const lastItem = segment.items.at(-1) return ( index === segments.length - 1 && @@ -814,15 +823,12 @@ const DISPATCH_TOOL_NAMES = new Set([...SUBAGENT_KEYS, ...Object.values(SUBAGENT * phrase describes the wait, not the output: a stall after streamed text is * the agent deciding what's next — Thinking — never "Generating" (while text * actually generates the shimmer is hidden). Dispatching covers only the - * dispatch call itself (whose tool row the parser absorbs, so nothing else - * shows); once the lane is open its own delegating shimmer owns the state and - * the turn-level one stays hidden (`null`). + * dispatch call itself (whose tool row the parser absorbs). Empty agent lanes + * share this indicator until a visible activity row takes over. */ -export function deriveThinkingLabel(blocks: ContentBlock[]): string | null { +export function deriveThinkingLabel(blocks: ContentBlock[]): string { const last = blocks[blocks.length - 1] switch (last?.type) { - case 'subagent': - return null case 'subagent_end': return 'Returning…' case 'tool_call': @@ -976,12 +982,16 @@ function MessageContentInner({ // wait, not output — the shimmer bridges it without the quiet-period delay. const thinkingLabel = deriveThinkingLabel(blocks) const hasActivityIndicator = assistantMessageHasVisibleActivity(segments, isStreaming) + const hasPendingAgents = + isStreaming && + segments.some((segment) => segment.type === 'agent_group' && hasPendingAgentGroup(segment)) const showShimmer = thinkingExpanded && - thinkingLabel !== null && (segments.length === 0 || trailingPendingTag || - (isStreamIdle && !trailingStreamActivity && !hasActivityIndicator)) + (((hasPendingAgents && revealTailIndex < 0) || isStreamIdle) && + !trailingStreamActivity && + !hasActivityIndicator)) const actionsRow = (
@@ -1025,6 +1035,7 @@ function MessageContentInner({ /> ) case 'agent_group': { + if (!segment.items.some(hasAgentGroupItemContent)) return null return (
- +
) : // The settled tail takes the slot's place in the SAME render and at the diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index e79e95bebff..39739a99d3e 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -847,10 +847,12 @@ describe('normalizeToolActivityDescription', () => { describe('model-authored activity outcomes', () => { it.each([ ['success', 'Checking invoices', 'Checked invoices'], - ['success', 'Check invoices', 'Completed: Check invoices'], - ['success', 'Reconciling invoices', 'Completed: Reconciling invoices'], - ['success', 'Revisando facturas', 'Completed: Revisando facturas'], - ['success', 'Stopped checking invoices', 'Completed checking invoices'], + ['success', 'Read the latest inbox emails', 'Read the latest inbox emails'], + ['success', 'Checked invoices', 'Checked invoices'], + ['success', 'Check invoices', 'Check invoices'], + ['success', 'Reconciling invoices', 'Reconciling invoices'], + ['success', 'Revisando facturas', 'Revisando facturas'], + ['success', 'Stopped checking invoices', 'Stopped checking invoices'], ['success', 'Completed: Check invoices', 'Completed: Check invoices'], ['error', 'Failed: Fetching invoices', 'Failed: Fetching invoices'], ['error', 'Stopped checking invoices', 'Failed checking invoices'], diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 3c716f61503..6e3dbd905d1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1444,7 +1444,7 @@ function statesTerminalOutcome(title: string): boolean { /** Apply one terminal outcome prefix while preserving already-resolved titles. */ function getToolOutcomeTitle( title: string, - outcome: 'Completed' | 'Failed' | 'Stopped' | 'Skipped', + outcome: 'Failed' | 'Stopped' | 'Skipped', preserveExistingOutcome: boolean ): string { if (preserveExistingOutcome && statesTerminalOutcome(title)) return title @@ -1463,10 +1463,9 @@ function getToolOutcomeTitle( } /** - * Resolve the final title for a tool status at a rendering boundary. Persisted - * and live snapshots intentionally keep the present-tense activity title so a - * RUNNING row remains truthful; terminal states project a tense that says the - * work is over — completed (past tense), failed, stopped, or skipped. + * Resolve a tool title at the rendering boundary. Successful calls use a known + * past-tense rewrite when available and otherwise preserve the wording. + * Failed, stopped, and skipped calls retain explicit outcome labels. */ export function getToolStatusDisplayTitle( title: string, @@ -1480,10 +1479,7 @@ export function getToolStatusDisplayTitle( return 'Resumed browser control' } if (status === 'success') { - return ( - getToolCompletedTitle(title) ?? - (description ? getToolOutcomeTitle(title, 'Completed', false) : title) - ) + return getToolCompletedTitle(title) ?? title } if (status === 'error' || status === 'rejected') { return getToolOutcomeTitle(title, 'Failed', !description)