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..57c104f3677 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx @@ -0,0 +1,83 @@ +/** + * @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 type { ActivityStatusProps } from '@/components/ui/activity-status' +import { usePacedActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' + +describe('usePacedActivity', () => { + let root: Root + let shown: ActivityStatusProps | undefined + + function Harness({ + label, + isActive, + callId, + placeholder, + }: { + label: string + isActive: boolean + callId: string + placeholder: boolean + }) { + shown = usePacedActivity({ label, isActive, icon: null }, callId, 'reset', false, placeholder) + return null + } + + function show(label: string, isActive: boolean, callId: string, placeholder = false) { + act(() => + root.render( + + ) + ) + return shown?.label + } + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.useFakeTimers() + root = createRoot(document.createElement('div')) + }) + + afterEach(() => { + act(() => root.unmount()) + vi.useRealTimers() + }) + + it("never shows a finished call's title between a run's calls", () => { + expect(show('Listing workflows', true, 'call-1')).toBe('Listing workflows') + act(() => vi.advanceTimersByTime(300)) + expect(show('Listed workflows', false, 'call-1')).toBe('Listing workflows') + expect(shown?.isActive).toBe(false) + act(() => vi.advanceTimersByTime(300)) + expect(show('Reading run logs', true, 'call-2')).toBe('Listing workflows') + act(() => vi.advanceTimersByTime(400)) + expect(shown?.label).toBe('Reading run logs') + }) + + it('settles on the finished title once the run stays idle', () => { + show('Listing workflows', true, 'call-1') + act(() => vi.advanceTimersByTime(300)) + show('Listed workflows', false, 'call-1') + act(() => vi.advanceTimersByTime(700)) + expect(shown?.label).toBe('Listed workflows') + }) + + it("replaces a placeholder with the call's title as soon as it streams", () => { + expect(show('Thinking', true, 'call-1', true)).toBe('Thinking') + act(() => vi.advanceTimersByTime(100)) + expect(show('Looking up the failed run', true, 'call-1')).toBe('Looking up the failed run') + }) + + it('holds a title that matched its placeholder text once the call names itself', () => { + show('Reading Gauge', true, 'call-1', true) + act(() => vi.advanceTimersByTime(100)) + expect(show('Reading Gauge', true, 'call-1')).toBe('Reading Gauge') + act(() => vi.advanceTimersByTime(300)) + expect(show('Read Gauge', false, 'call-1')).toBe('Reading Gauge') + act(() => vi.advanceTimersByTime(700)) + expect(shown?.label).toBe('Read Gauge') + }) +}) 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 index 4da8091784b..1aad0aa40bb 100644 --- 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 @@ -15,55 +15,88 @@ interface ActivityStreamProps extends Omit { expandedLabel?: string attentionKey: string collapsible: boolean + /** The label only stands in until the header's call names itself (see `usePacedActivity`). */ + placeholder?: boolean } -/** Pace only the header; history and interactive tool state continue updating immediately. */ -export function ActivityStream({ - activity, - activityKey, - expandedLabel, - attentionKey, - expanded, - collapsible, - children, - onToggle, - isStreaming, - unbounded, -}: ActivityStreamProps) { - const isExpanded = collapsible && expanded +/** + * The header activity to show: a new label waits until the current one has shown for the pacing + * interval, and a burst of changes inside it settles on the latest. The label is paced across + * liveness too: between a run's calls the lane briefly loses its live indicator, and an unpaced + * label would flash the finished call's title before the next call's. Liveness itself is never + * held. A change of `resetKey` (attention, disclosure) and an expanded group show at once, and a + * placeholder (a call still awaiting its own title) never holds the header, so the title replaces + * it as soon as it streams. + */ +export function usePacedActivity( + activity: ActivityStatusProps, + activityKey: string | undefined, + resetKey: string, + immediate: boolean, + placeholder = false +): ActivityStatusProps { const key = `${activityKey}:${activity.label}` - const resetKey = `${activity.isActive}:${isExpanded}:${Boolean(activityKey)}:${attentionKey}` const [visible, setVisible] = useState(() => ({ activity, key, resetKey, + placeholder, shownAt: Date.now(), })) - /** Completion, attention, and disclosure changes bypass the cosmetic delay. */ if (visible.resetKey !== resetKey) { - setVisible({ activity, key, resetKey, shownAt: Date.now() }) + setVisible({ activity, key, resetKey, placeholder, shownAt: Date.now() }) + } else if (key === visible.key && placeholder !== visible.placeholder) { + // The same text now names the call itself (an activity title its description matches), so + // it holds the header from here. + setVisible({ ...visible, activity, placeholder, 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 (immediate || key === visible.key) return + const remaining = visible.placeholder + ? 0 + : Math.max(0, ACTIVITY_UPDATE_INTERVAL_MS - (Date.now() - visible.shownAt)) + const flush = () => setVisible({ activity, key, resetKey, placeholder, shownAt: Date.now() }) if (remaining === 0) { flush() return } const timer = setTimeout(flush, remaining) return () => clearTimeout(timer) - }, [activity, key, resetKey, isExpanded, visible.key, visible.shownAt]) + }, [activity, key, resetKey, immediate, placeholder, visible]) + + return immediate || key === visible.key || visible.placeholder + ? activity + : { ...visible.activity, isActive: activity.isActive } +} - const displayed = - !activity.isActive || isExpanded || key === visible.key ? activity : visible.activity +/** Pace only the header; history and interactive tool state continue updating immediately. */ +export function ActivityStream({ + activity, + activityKey, + expandedLabel, + attentionKey, + expanded, + collapsible, + children, + onToggle, + isStreaming, + unbounded, + placeholder, +}: ActivityStreamProps) { + const isExpanded = collapsible && expanded + const displayed = usePacedActivity( + activity, + activityKey, + `${isExpanded}:${Boolean(activityKey)}:${attentionKey}`, + isExpanded, + placeholder + ) const header = ( ) return ( 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 5b584a432df..57d616c2faf 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 @@ -28,6 +28,8 @@ import { getActivityStatusTool, getCompletedActivityLabel, getInProgressActivityLabel, + isAwaitingTitle, + THINKING_LABEL, } 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 { useToolCallTitle } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title' @@ -294,7 +296,7 @@ export function AgentGroupView({ tools, activityDescriptor?.title ) - : 'Thinking' + : THINKING_LABEL : tools.length > 0 ? getCompletedActivityLabel(tools, activityDescriptor) : agentLabel @@ -317,6 +319,7 @@ export function AgentGroupView({ { expect(container.textContent).not.toContain('No results') }) }) + +describe('in-progress activity header', () => { + const streamingCli: ToolCallData = { + id: 'cli-1', + toolName: 'sim_cli', + displayTitle: 'Running CLI command', + status: 'executing', + } + + function headerLabel(tools: ToolCallData[]) { + const statusTool = tools[tools.length - 1] + const headerTool = getActivityHeaderTool(tools, statusTool) + return getInProgressActivityLabel(headerTool.displayTitle ?? '', headerTool, tools, undefined) + } + + it('keeps the wait wording while a first CLI call has no title yet', () => { + expect(headerLabel([streamingCli])).toBe('Thinking') + }) + + it('uses the streamed description as soon as it arrives', () => { + const described = { + ...streamingCli, + activityDescription: 'Looking up the failed run', + displayTitle: 'Looking up the failed run', + } + expect(headerLabel([described])).toBe('Looking up the failed run') + }) + + it('holds the earlier call while a refined CLI call waits for its description', () => { + const earlier: ToolCallData = { + id: 'cli-0', + toolName: 'cli_workflows_list', + displayTitle: 'Listing workflows', + status: 'success', + activityDescription: 'Listing workflows', + params: { args: ['workflows', 'list'] }, + } + const refined = { ...streamingCli, toolName: 'cli_logs_get', displayTitle: 'Reading run logs' } + expect(getActivityHeaderTool([earlier, refined], refined)).toBe(earlier) + }) +}) 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 39b5c6bb993..8d20da32c81 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 @@ -95,14 +95,14 @@ function getToolActivityInterruptions(tools: ToolCallData[]): string[] { /** * An executing call whose streamed arguments do not name its action yet, so its * title is only its tool's placeholder: a search before its arguments arrive, - * a `sim_cli` call before its command parses ("Running CLI command"), - * a `run_code` call before its arguments + * a CLI call before its final frame (the verb parsed mid-stream would only flash + * ahead of the model's description), 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, and neither is one the model already described. */ -function isAwaitingTitle(tool: ToolCallData): boolean { +export function isAwaitingTitle(tool: ToolCallData): boolean { if (tool.status !== ToolCallStatus.executing || tool.activityDescription) return false if (tool.toolName === CallIntegrationTool.id) { const description = @@ -111,6 +111,7 @@ function isAwaitingTitle(tool: ToolCallData): boolean { } return ( (tool.toolName === 'sim_cli' || + tool.toolName.startsWith('cli_') || tool.toolName === RunCode.id || tool.toolName === 'search_workspace') && Object.keys(tool.params ?? {}).every((key) => key === 'activity') @@ -144,11 +145,15 @@ export function getActivityHeaderTool( ) } +/** The wait wording: the turn between steps, and a first call whose own title is still streaming. */ +export const THINKING_LABEL = 'Thinking' + /** * 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. + * gave one, else as the wait it continues: a tool's placeholder ("Running CLI + * command") would flash between the turn's "Thinking" and the model's description. */ export function getInProgressActivityLabel( activeLabel: string, @@ -156,7 +161,7 @@ export function getInProgressActivityLabel( tools: ToolCallData[], activityTitle: string | undefined ): string { - const label = (isAwaitingTitle(headerTool) && activityTitle) || activeLabel + const label = isAwaitingTitle(headerTool) ? activityTitle || THINKING_LABEL : activeLabel return getActiveToolActivityTitle(label, headerTool, tools) } @@ -254,6 +259,7 @@ export function ToolActivityGroup({ icon: status.icon, }} activityKey={headerTool.id} + placeholder={working && isAwaitingTitle(headerTool)} attentionKey={attentionKey} expandedLabel={tools.length > 1 ? groupedActivity?.title : undefined} collapsible={tools.length > 1 || hasSearchDetails} 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 5bf3b833b59..4dcfce472a1 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 @@ -37,6 +37,7 @@ import { ownsTurnWait, type TurnLiveIndicators, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' +import { THINKING_LABEL } 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 { WatchActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity' import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' @@ -935,9 +936,9 @@ export function deriveThinkingLabel(blocks: ContentBlock[]): string { case 'tool_call': return last.toolCall && DISPATCH_TOOL_NAMES.has(last.toolCall.name) ? 'Dispatching' - : 'Thinking' + : THINKING_LABEL default: - return 'Thinking' + return THINKING_LABEL } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index ef24ea84065..f07dc0a2502 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -561,9 +561,10 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ) const delta = asString(payload.argumentsDelta) if (delta) node.streamingArgs = (node.streamingArgs ?? '') + delta - // Progressive CLI title: upgrade the placeholder to the specific verb as - // soon as enough argv tokens have streamed to name the command — the - // browser mirror of the server handler's refinement. + // Progressive CLI title: upgrade the row's placeholder to the specific verb + // as soon as enough argv tokens have streamed to name the command — the + // browser mirror of the server handler's refinement. The activity header + // holds it back until the model's description arrives. if (delta && (node.name === 'sim_cli' || node.name.startsWith('cli_'))) { const refined = refineStreamingCliToolName(node.streamingArgs ?? '') if (refined && refined !== node.name) node.name = refined diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index 533cee21dc5..49f7b803091 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -153,9 +153,10 @@ function handleToolArgsDelta( if (!toolCall) return toolCall.streamingArgs = `${toolCall.streamingArgs ?? ''}${data.argumentsDelta}` - // Progressive CLI title: the row upgrades from "Running CLI command" to the - // specific verb as soon as enough argv tokens have streamed to name it — - // no wait for the full (possibly huge) argument payload. + // Progressive CLI title: the row upgrades from its placeholder to the specific + // verb as soon as enough argv tokens have streamed to name it — no wait for the + // full (possibly huge) argument payload. The activity header holds it back until + // the model's description arrives (see `isAwaitingTitle`). if (toolCall.name === 'sim_cli' || toolCall.name.startsWith('cli_')) { const refined = refineStreamingCliToolName(toolCall.streamingArgs) if (refined && refined !== toolCall.name) {