From f4900f215ec9a7ec34b45297a5dbdabf99de75e1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 23:58:49 -0700 Subject: [PATCH 1/2] improvement(chat): keep the previous step's label while the next call's title streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live activity header flipped to a generic "Working…" whenever the next call had not streamed enough arguments to name its action (a sim_cli call before its command parses, a run_code call before its arguments resolve). Every step of a multi-call activity flashed that placeholder. The header now keeps describing the call it described before, label and icon alike, still shimmering, until the new call's title is known. getActivityHeaderTool picks that call with the existing status-call rule among the calls that already have a title, and the main tool group and subagent lane headers share it. The integration gateway before its description streams ("Calling integration") counts as untitled too, since it showed the same kind of placeholder in the header. Only an executing call counts as untitled, and a call waiting on the user is never held, so a permission card or terminal handoff still owns the header as before. The first call of a group has no earlier call to hold, so it reads as the activity's present-tense intent when the model gave one, else as its own in-progress title. The "Working…" placeholder is gone. Rows, completed summaries, and shimmer ownership are unchanged. --- .../agent-group/agent-group-view.tsx | 25 ++-- .../agent-group/agent-group.test.ts | 139 +++++++++++++++++- .../agent-group/tool-activity-group.test.ts | 71 ++++++++- .../agent-group/tool-activity-group.tsx | 87 ++++++++--- 4 files changed, 292 insertions(+), 30 deletions(-) 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 10468b3a0a6..a6339072e4b 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 @@ -24,6 +24,7 @@ import { } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' import { + getActivityHeaderTool, getActivityStatusTool, getCompletedActivityLabel, getInProgressActivityLabel, @@ -119,13 +120,14 @@ export function AgentGroupView({ const isMainAgent = agentName === 'mothership' const tools = isMainAgent ? [] : collectGroupTools(items) const statusTool = getActivityStatusTool(tools) - const statusTitle = useToolCallTitle( - statusTool && { - ...statusTool, - toolCallId: statusTool.id, + const headerTool = statusTool && getActivityHeaderTool(tools, statusTool) + const headerTitle = useToolCallTitle( + headerTool && { + ...headerTool, + toolCallId: headerTool.id, displayTitle: - statusTool.displayTitle || - getToolDisplayTitle(String(statusTool.toolName ?? ''), undefined), + headerTool.displayTitle || + getToolDisplayTitle(String(headerTool.toolName ?? ''), undefined), } ) const activityDescriptor = @@ -283,8 +285,13 @@ export function AgentGroupView({ const headerText = error ? agentLabel : inProgress - ? statusTool && statusTitle - ? getInProgressActivityLabel(statusTitle.activeLabel, statusTool, tools) + ? headerTool && headerTitle + ? getInProgressActivityLabel( + headerTitle.activeLabel, + headerTool, + tools, + activityDescriptor?.title + ) : 'Thinking' : tools.length > 0 ? getCompletedActivityLabel(tools, activityDescriptor) @@ -307,7 +314,7 @@ export function AgentGroupView({ ) : ( { }) it.each(['sim_cli', 'run_code'])( - 'shows %s argument preparation before its concrete call', + 'names a first %s call by its activity intent, else its own title, while its arguments stream', (toolName) => { vi.useFakeTimers() const render = ( @@ -342,16 +342,107 @@ describe('AgentGroup inline main activity', () => { ) ) act(() => vi.advanceTimersByTime(1000)) + expect(container.textContent).not.toContain('Working') return container.textContent } - expect(render({}, 'executing')).toBe('Working…') - const params = { code: '1', activity: { id: 'check', completedTitle: 'Checked inputs' } } + expect(render({}, 'executing')).toBe('Running checks') + expect(render({ activity: { id: 'check', title: 'Checking inputs' } }, 'executing')).toBe( + 'Checking inputs' + ) + const params = { code: '1', activity: { id: 'check', title: 'Checking inputs' } } expect(render(params, 'executing')).toBe('Running checks') expect(render(params, 'success')).toBe('Running checks') expect(render(params, 'success', false)).toBe('Ran checks') } ) + it.each([ + [ + 'sim_cli', + { toolName: 'sim_cli', displayTitle: 'Running CLI command', params: {} }, + { + toolName: 'cli_workflows_list', + displayTitle: 'Listing workflows', + params: { args: ['workflows', 'list'] }, + }, + ], + [ + 'run_code', + { toolName: 'run_code', displayTitle: 'Running code', params: { activity: { id: 'a' } } }, + { + toolName: 'run_code', + displayTitle: 'Summing invoices', + params: { activity: { id: 'a' }, title: 'Summing invoices', code: '1' }, + }, + ], + [ + 'call_integration_tool', + { toolName: 'call_integration_tool', displayTitle: 'Calling integration', params: {} }, + { + toolName: 'call_integration_tool', + displayTitle: 'Sending the report', + params: {}, + streamingArgs: '{"description":"Sending the report",', + }, + ], + ])( + 'keeps the previous call in the live header while a %s call has no title', + (_name, pending, titled) => { + vi.useFakeTimers() + const read = { + id: 'read', + toolName: 'read', + displayTitle: 'Reading notes.md', + status: 'success' as const, + } + const render = (next: Partial) => { + act(() => + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + activity: { id: 'a', title: 'Reconciling accounts' }, + isStreaming: true, + isLaneOpen: true, + items: [ + { type: 'tool', data: read }, + { + type: 'tool', + data: { id: 'next', status: 'executing', ...next } as ToolCallData, + }, + ], + }) + ) + ) + act(() => vi.advanceTimersByTime(1000)) + const header = container.querySelector('[role="status"]')! + expect(header.querySelector('[class*="shimmer"]')).not.toBeNull() + expect(container.textContent).not.toContain('Working') + return { text: header.textContent, icon: header.querySelector('svg')?.outerHTML } + } + const iconOf = (data: Partial) => { + act(() => + root.render( + createElement(AgentGroup, { + agentName: 'mothership', + agentLabel: 'Sim', + items: [ + { type: 'tool', data: { id: 'solo', status: 'success', ...data } as ToolCallData }, + ], + }) + ) + ) + return container.querySelector('svg')?.outerHTML + } + const readIcon = iconOf(read) + const titledIcon = iconOf(titled) + expect(readIcon).not.toBe(titledIcon) + + expect(render(pending)).toEqual({ text: 'Reading notes.md', icon: readIcon }) + expect(render(titled)).toEqual({ text: titled.displayTitle, icon: titledIcon }) + } + ) + it('keeps an activity unfinished while a standalone approval is pending', () => { act(() => root.render( @@ -1175,6 +1266,48 @@ describe('AgentGroup nested status line', () => { expect(header).toContain('Deploying Invoice Sync as API') }) + it('keeps the previous call in a subagent header while the next call has no title', () => { + vi.useFakeTimers() + const header = (items: AgentGroupItem[]) => { + render(items) + act(() => vi.advanceTimersByTime(1000)) + const status = container.querySelector('[role="status"]')! + expect(status.querySelector('[class*="shimmer"]')).not.toBeNull() + expect(container.textContent).not.toContain('Working') + return status.textContent + } + const cli = (data: Partial): AgentGroupItem => ({ + type: 'tool', + data: { id: 'cli', status: 'executing', startedAt: 2, ...data } as ToolCallData, + }) + const pending = cli({ toolName: 'sim_cli', displayTitle: 'Running CLI command', params: {} }) + const titled = cli({ + toolName: 'cli_workflows_list', + displayTitle: 'Listing workflows', + params: { args: ['workflows', 'list'] }, + }) + try { + expect(header([namedTool('Reading workflow', 'success' as ToolCallStatus, 1), pending])).toBe( + 'Reading workflow' + ) + expect(header([namedTool('Reading workflow', 'success' as ToolCallStatus, 1), titled])).toBe( + 'Listing workflows' + ) + expect(header([pending])).toBe('Running CLI command') + expect( + header([ + cli({ + toolName: 'sim_cli', + displayTitle: 'Running CLI command', + params: { activity: { id: 'a', title: 'Checking inputs' } }, + }), + ]) + ).toBe('Checking inputs') + } finally { + vi.useRealTimers() + } + }) + it('falls back to the last tool at any depth when nothing is running', () => { const header = render([ namedTool('Reading workflow', 'success' as ToolCallStatus, 1), 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 1aa75cca0c9..f693fd73a4d 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 @@ -1,6 +1,10 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getCompletedActivityLabel } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' +import { + getActivityHeaderTool, + getCompletedActivityLabel, + getInProgressActivityLabel, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' /** A finished activity without a model title is labelled by its action summary. */ @@ -134,3 +138,68 @@ describe('interrupted activity summaries', () => { ).toBe('Ran invoice workflow') }) }) + +describe('in-progress activity header', () => { + const read = tool('read', 'Reading notes.md') + const pendingCli = tool('sim_cli', 'Running CLI command', 'executing', { + activity: { id: 'a' }, + }) + const pendingGateway = tool('call_integration_tool', 'Calling integration', 'executing') + + it.each([ + ['a CLI call before its command parses', pendingCli], + ['a code call before its arguments resolve', tool('run_code', 'Running code', 'executing')], + ['a gateway call before its description streams', pendingGateway], + ])('holds the previous call for %s', (_case, pending) => { + const tools = [read, pending] + expect(getActivityHeaderTool(tools, pending)).toBe(read) + expect(getInProgressActivityLabel('Reading notes.md', read, tools, 'Reconciling')).toBe( + 'Reading notes.md' + ) + }) + + it('holds the call the header described before, skipping untitled and failed calls', () => { + const failed = tool('grep', 'Searching', 'error') + const tools = [read, failed, pendingGateway, pendingCli] + expect(getActivityHeaderTool(tools, pendingCli)).toBe(read) + }) + + it('keeps a call waiting on approval as the header, never holding past or onto it', () => { + const gatedGateway = { + ...pendingGateway, + id: 'gateway-gated', + status: 'awaiting_approval' as const, + } + expect(getActivityHeaderTool([read, gatedGateway], gatedGateway)).toBe(gatedGateway) + const gatedRead = { ...read, id: 'read-gated', status: 'awaiting_approval' as const } + expect(getActivityHeaderTool([read, gatedRead, pendingCli], pendingCli)).toBe(read) + }) + + it.each([ + ['a parsed CLI command', tool('cli_workflows_list', 'Listing workflows', 'executing')], + [ + 'resolved code arguments', + tool('run_code', 'Summing', 'executing', { activity: { id: 'a' }, code: '1' }), + ], + [ + 'a streamed gateway description', + { + ...pendingGateway, + id: 'gateway-described', + streamingArgs: '{"description":"Sending the report",', + }, + ], + ])('describes the new call once it has %s', (_case, titled) => { + expect(getActivityHeaderTool([read, titled], titled)).toBe(titled) + }) + + it('reads a first untitled call as the activity intent, else its own title', () => { + expect(getActivityHeaderTool([pendingCli], pendingCli)).toBe(pendingCli) + expect( + getInProgressActivityLabel('Running CLI command', pendingCli, [pendingCli], 'Reconciling') + ).toBe('Reconciling') + expect( + getInProgressActivityLabel('Running CLI command', pendingCli, [pendingCli], undefined) + ).toBe('Running CLI command') + }) +}) 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 777d2fa81b4..6e85b3a23aa 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 @@ -3,6 +3,8 @@ import { type ComponentType, Fragment, useState } from 'react' import { ActivityStatus } from '@/components/ui/activity-status' import type { ToolActivity } from '@/lib/mothership/generated/protocol' +import { CallIntegrationTool, RunCode } from '@/lib/mothership/generated/tool-catalog-v1' +import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' import { getToolActivitySummaryActions, readToolActivity, @@ -11,7 +13,10 @@ import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' import { getNewestRunningTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' 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 { + getActivityAttentionKey, + needsToolInput, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' import { isToolDone } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' @@ -66,21 +71,63 @@ function getToolActivityInterruptions(tools: ToolCallData[]): string[] { } /** - * The title of an activity in progress, shared by tool group and subagent - * headers: "Working…" while a `sim_cli` or `run_code` call still generates its - * arguments, else the status call's in-progress title with earlier stops and - * skips kept visible. + * An executing call whose streamed arguments do not name its action yet, so its + * title is only its tool's placeholder: a `sim_cli` call before its command + * parses ("Running CLI command") or a `run_code` call before its arguments + * resolve, while their parameters hold at most the activity, and an + * integration gateway call before its description streams ("Calling integration"). + * A call awaiting approval is never untitled, so its permission card keeps the header. + */ +function isAwaitingTitle(tool: ToolCallData): boolean { + if (tool.status !== ToolCallStatus.executing) return false + if (tool.toolName === CallIntegrationTool.id) { + const description = + tool.params?.description ?? extractStreamingStringArgument(tool.streamingArgs, 'description') + return !(typeof description === 'string' && description.trim()) + } + return ( + (tool.toolName === 'sim_cli' || tool.toolName === RunCode.id) && + Object.keys(tool.params ?? {}).every((key) => key === 'activity') + ) +} + +/** + * The call an in-progress header describes, label and icon alike, shared by + * tool group and subagent headers so a live header never flips to placeholder + * text between steps: the status call, unless it is still awaiting its title, + * in which case the call the header described before it keeps the header + * until the new title arrives. A call waiting on the user is never held, since + * its permission card or handoff would replace the header. With no earlier + * titled call, the status call. + */ +export function getActivityHeaderTool( + tools: ToolCallData[], + statusTool: ToolCallData +): ToolCallData { + if (!isAwaitingTitle(statusTool)) return statusTool + return ( + getActivityStatusTool( + tools.filter( + (tool) => tool.id !== statusTool.id && !isAwaitingTitle(tool) && !needsToolInput(tool) + ) + ) ?? statusTool + ) +} + +/** + * The title of an activity in progress for its header call, with earlier + * stops and skips kept visible. A header call still awaiting its title has no + * earlier call to hold, so it reads as the activity's intent when the model + * gave one, else as its own in-progress title. */ export function getInProgressActivityLabel( activeLabel: string, - statusTool: ToolCallData, - tools: ToolCallData[] + headerTool: ToolCallData, + tools: ToolCallData[], + activityTitle: string | undefined ): string { - const isGenerating = - !isToolDone(statusTool.status) && - (statusTool.toolName === 'sim_cli' || statusTool.toolName === 'run_code') && - Object.keys(statusTool.params ?? {}).every((key) => key === 'activity') - return isGenerating ? 'Working…' : getActiveToolActivityTitle(activeLabel, statusTool, tools) + const label = (isAwaitingTitle(headerTool) && activityTitle) || activeLabel + return getActiveToolActivityTitle(label, headerTool, tools) } /** Keep earlier interruptions visible while the latest action continues. */ @@ -141,6 +188,7 @@ export function ToolActivityGroup({ const [expanded, setExpanded] = useState(false) const statusTool = getActivityStatusTool(tools) if (!statusTool) return null + const headerTool = getActivityHeaderTool(tools, statusTool) const groupedActivity = activity ?? tools @@ -153,20 +201,25 @@ export function ToolActivityGroup({ return ( ( 1 ? groupedActivity?.title : undefined} collapsible={tools.length > 1} @@ -177,7 +230,7 @@ export function ToolActivityGroup({
{tools.map((tool) => ( - {tool.id === statusTool.id ? ( + {tool.id === headerTool.id ? ( ) : ( From 302cd6dfd50ee5bffc7c1b514ae55b3f2ba6b240 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 00:12:51 -0700 Subject: [PATCH 2/2] fix(chat): never hold a failed call and treat a described call as titled --- .../agent-group/tool-activity-group.test.ts | 17 +++++++++++++++++ .../agent-group/tool-activity-group.tsx | 16 +++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) 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 f693fd73a4d..872bedbc10f 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 @@ -164,6 +164,19 @@ describe('in-progress activity header', () => { expect(getActivityHeaderTool(tools, pendingCli)).toBe(read) }) + it('never holds a failed call, falling back to the new call itself', () => { + const failed = tool('grep', 'Searching', 'error') + expect(getActivityHeaderTool([failed, pendingCli], pendingCli)).toBe(pendingCli) + expect( + getInProgressActivityLabel( + 'Running CLI command', + pendingCli, + [failed, pendingCli], + 'Reconciling' + ) + ).toBe('Reconciling') + }) + it('keeps a call waiting on approval as the header, never holding past or onto it', () => { const gatedGateway = { ...pendingGateway, @@ -189,6 +202,10 @@ describe('in-progress activity header', () => { streamingArgs: '{"description":"Sending the report",', }, ], + [ + 'a model activity description', + { ...pendingCli, id: 'cli-described', activityDescription: 'Listing the workflows' }, + ], ])('describes the new call once it has %s', (_case, titled) => { expect(getActivityHeaderTool([read, titled], titled)).toBe(titled) }) 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 6e85b3a23aa..f5036e77f18 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 @@ -76,10 +76,11 @@ function getToolActivityInterruptions(tools: ToolCallData[]): string[] { * parses ("Running CLI command") or a `run_code` call before its arguments * resolve, while their parameters hold at most the activity, and an * integration gateway call before its description streams ("Calling integration"). - * A call awaiting approval is never untitled, so its permission card keeps the header. + * A call awaiting approval is never untitled, so its permission card keeps the + * header, and neither is one the model already described. */ function isAwaitingTitle(tool: ToolCallData): boolean { - if (tool.status !== ToolCallStatus.executing) return false + if (tool.status !== ToolCallStatus.executing || tool.activityDescription) return false if (tool.toolName === CallIntegrationTool.id) { const description = tool.params?.description ?? extractStreamingStringArgument(tool.streamingArgs, 'description') @@ -97,8 +98,9 @@ function isAwaitingTitle(tool: ToolCallData): boolean { * text between steps: the status call, unless it is still awaiting its title, * in which case the call the header described before it keeps the header * until the new title arrives. A call waiting on the user is never held, since - * its permission card or handoff would replace the header. With no earlier - * titled call, the status call. + * its permission card or handoff would replace the header, and neither is a + * failed call, which must not read as live. With no such earlier call, the + * status call. */ export function getActivityHeaderTool( tools: ToolCallData[], @@ -108,7 +110,11 @@ export function getActivityHeaderTool( return ( getActivityStatusTool( tools.filter( - (tool) => tool.id !== statusTool.id && !isAwaitingTitle(tool) && !needsToolInput(tool) + (tool) => + tool.id !== statusTool.id && + !isAwaitingTitle(tool) && + !needsToolInput(tool) && + !isFailedTool(tool) ) ) ?? statusTool )