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) {