{renderBrowserTakeover?.(activeBrowserTakeover.reason)}
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 405ca546ba0..8fc4982c07a 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
@@ -5,10 +5,10 @@ import { act, createElement } 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 { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content'
import {
type AgentGroupItem,
AgentGroupView,
- isAgentGroupResolved,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
@@ -269,7 +269,7 @@ describe('AgentGroup inline main activity', () => {
expect(container.textContent).not.toContain('Checking requirements')
})
- it('counts unresolved calls and waits for a lane boundary before showing completed activity', () => {
+ it('keeps an open activity in progress without a call count until the lane closes', () => {
vi.useFakeTimers()
const render = (statuses: ToolCallStatus[], isLaneOpen = true) => {
act(() =>
@@ -295,13 +295,16 @@ describe('AgentGroup inline main activity', () => {
act(() => vi.advanceTimersByTime(1000))
return container.querySelector('[role="status"]')!
}
- expect(render(['executing', 'executing', 'success']).textContent).toBe('Reading document 1 + 1')
+ expect(render(['executing', 'executing', 'success']).textContent).toBe('Reading document 1')
expect(render(['executing', 'success', 'success']).textContent).toBe('Reading document 0')
- expect(render(['success', 'success', 'success']).textContent).toBe('Read document 2')
- expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ const gap = render(['success', 'success', 'success'])
+ expect(gap.textContent).toBe('Reading document 2')
+ expect(gap.querySelector('[class*="shimmer"]')).not.toBeNull()
+ expect(gap.querySelector('svg')).not.toBeNull()
const completed = render(['success', 'success', 'success'], false)
expect(completed.textContent).toBe('Built Search API')
- expect(completed.querySelector('svg')).toBeNull()
+ expect(completed.querySelector('[class*="shimmer"]')).toBeNull()
+ expect(completed.querySelector('svg')).not.toBeNull()
act(() => container.querySelector('[role="button"]')?.click())
expect(container.querySelector('[data-state="open"] svg')).not.toBeNull()
})
@@ -344,7 +347,7 @@ describe('AgentGroup inline main activity', () => {
expect(render({}, 'executing')).toBe('Working…')
const params = { code: '1', activity: { id: 'check', completedTitle: 'Checked inputs' } }
expect(render(params, 'executing')).toBe('Running checks')
- expect(render(params, 'success')).toBe('Ran checks')
+ expect(render(params, 'success')).toBe('Running checks')
expect(render(params, 'success', false)).toBe('Ran checks')
}
)
@@ -387,14 +390,81 @@ describe('AgentGroup inline main activity', () => {
expect(container.textContent).not.toContain('Built API')
})
- it('keeps earlier failures in expanded history when completed activities collapse', () => {
+ it.each([
+ [
+ 'approval',
+ false,
+ { id: 'b', toolName: 'grep', displayTitle: 'Searching b', status: 'awaiting_approval' },
+ ],
+ [
+ 'approval',
+ true,
+ { id: 'b', toolName: 'grep', displayTitle: 'Searching b', status: 'awaiting_approval' },
+ ],
+ [
+ 'handoff',
+ false,
+ {
+ id: 'b',
+ toolName: 'terminal',
+ displayTitle: 'Finish signing in',
+ status: 'executing',
+ params: { operation: 'handoff' },
+ },
+ ],
+ [
+ 'handoff',
+ true,
+ {
+ id: 'b',
+ toolName: 'terminal',
+ displayTitle: 'Finish signing in',
+ status: 'executing',
+ params: { operation: 'handoff' },
+ },
+ ],
+ ] as const)(
+ 'keeps a finished group completed while a later %s waits on the user (streaming=%s)',
+ (_kind, isStreaming, pending) => {
+ act(() =>
+ root.render(
+ createElement(AgentGroupView, {
+ agentName: 'mothership',
+ agentLabel: 'Sim',
+ isStreaming,
+ isLaneOpen: true,
+ items: [
+ {
+ type: 'tool',
+ data: { id: 'a', toolName: 'read', displayTitle: 'Read a', status: 'success' },
+ },
+ { type: 'tool', data: { ...pending } as ToolCallData },
+ ],
+ ToolCallComponent: ({ displayTitle, status, renderStatus }: ToolCallItemProps) =>
+ renderStatus
+ ? renderStatus({
+ label: displayTitle,
+ activeLabel: displayTitle.replace(/^Read /, 'Reading '),
+ isActive: status === 'executing',
+ })
+ : createElement('div', { 'data-pending': 'true' }, displayTitle),
+ })
+ )
+ )
+ const header = container.querySelector('[role="status"]')
+ expect(header?.textContent).toBe('Read a')
+ expect(header?.querySelector('[class*="shimmer"]')).toBeNull()
+ expect(container.querySelector('[data-pending]')).not.toBeNull()
+ }
+ )
+
+ it('keeps an earlier failure in expanded history under the only successful call', () => {
act(() =>
root.render(
createElement(AgentGroup, {
agentName: 'mothership',
agentLabel: 'Sim',
activity: { id: 'second', completedTitle: 'Checked inputs' },
- completedGroupCount: 2,
items: [
{
type: 'tool',
@@ -541,7 +611,7 @@ describe('AgentGroup inline main activity', () => {
expect(container.firstElementChild).toBe(activity)
expect(container.textContent).toBe('Searching files')
act(() => vi.advanceTimersByTime(1000))
- expect(container.textContent).toBe('Reading notes + 1')
+ expect(container.textContent).toBe('Reading notes')
expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
expect(
container.querySelector('[role="button"]')?.getAttribute('aria-expanded')
@@ -556,8 +626,9 @@ describe('AgentGroup inline main activity', () => {
],
false
)
- expect(container.textContent).toBe('Read notes + 1')
+ expect(container.textContent).toBe('Searched files, read files')
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ expect(container.querySelector('[role="status"] svg')).not.toBeNull()
const header = container.querySelector('[role="button"]')
act(() => header?.click())
expect(header?.getAttribute('aria-expanded')).toBe('true')
@@ -566,7 +637,7 @@ describe('AgentGroup inline main activity', () => {
)
act(() => header?.click())
expect(header?.getAttribute('aria-expanded')).toBe('false')
- expect(container.textContent).toBe('Read notes + 1')
+ expect(container.textContent).toBe('Searched files, read files')
})
it('keeps history expanded as new tools arrive', () => {
@@ -686,7 +757,7 @@ describe('AgentGroup inline main activity', () => {
read,
{ ...wait, data: { ...wait.data, id: 'wait-second', status: 'success' } },
])
- expect(header?.textContent).toBe('Waited + 2')
+ expect(header?.textContent).toBe('Waited, read files')
expect(container.querySelector('.overflow-y-auto')).toBe(viewport)
expect(clearIntervalSpy).toHaveBeenCalledTimes(2)
} finally {
@@ -728,8 +799,8 @@ describe('AgentGroup inline main activity', () => {
)
)
const header = container.querySelector('[role="button"]')
- expect(header?.textContent).toBe('Ran checks + 1')
- expect(header).toHaveAccessibleName('Ran checks + 1')
+ 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(
@@ -771,7 +842,7 @@ describe('AgentGroup inline main activity', () => {
label: displayTitle,
activeLabel: displayTitle,
isActive: true,
- icon: createElement('svg', { 'data-tool-call-id': toolCallId }),
+ icon: createElement('svg', { 'data-icon-for': toolCallId }),
})
: status
},
@@ -884,7 +955,7 @@ describe('AgentGroup inline main activity', () => {
label: displayTitle,
activeLabel: displayTitle,
isActive: true,
- icon: createElement('svg', { 'data-tool-call-id': toolCallId }),
+ icon: createElement('svg', { 'data-icon-for': toolCallId }),
})
: status
},
@@ -898,6 +969,7 @@ describe('AgentGroup inline main activity', () => {
)
).toEqual(['permission', 'handoff'])
expect(container.querySelector('[role="status"]')?.textContent).toBe('Reading notes')
+ expect(container.querySelector('[role="status"] [data-icon-for="latest"]')).not.toBeNull()
expect(
container.querySelector('[data-tool-call-id="permission"]')?.closest('[data-state]')
).toBeNull()
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 e0b5daa0ff0..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
@@ -268,9 +268,7 @@ describe('BrowserAgentIcon', () => {
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 + 1'
- )
+ 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')
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts
index 0e87649b7f8..f3e00b4a936 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts
@@ -1,6 +1,6 @@
export { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
+export { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content'
export type {
AgentGroupItem,
NestedAgentGroup,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
-export { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts
new file mode 100644
index 00000000000..10928d3bc81
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts
@@ -0,0 +1,71 @@
+/** @vitest-environment node */
+import { describe, expect, it } from 'vitest'
+import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
+import {
+ getLaneLiveIndicator,
+ splitMainLane,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity'
+import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
+
+function call(id: string, toolName: string, extra: Partial = {}): AgentGroupItem {
+ return { type: 'tool', data: { id, toolName, displayTitle: id, status: 'success', ...extra } }
+}
+
+const layout = (items: AgentGroupItem[]) =>
+ splitMainLane(items).map((entry) =>
+ entry.type === 'run'
+ ? `${entry.run.isSearch ? 'search' : 'tools'}:${entry.run.tools.map((tool) => tool.id).join(',')}`
+ : entry.item.type
+ )
+
+const liveId = (items: AgentGroupItem[], isOpen = true) => {
+ const indicator = getLaneLiveIndicator({ kind: 'main', parts: [items], isActive: true, isOpen })
+ return indicator?.type === 'call' ? indicator.tool.id : indicator?.type
+}
+
+describe('splitMainLane', () => {
+ it('splits runs at search boundaries and interactions, in transcript order', () => {
+ expect(
+ layout([
+ call('a', 'read'),
+ call('s1', 'search_workspace'),
+ call('s2', 'search_sources', { params: { action: 'list' } }),
+ call('setup', 'search_sources', { params: { action: 'setup' } }),
+ call('b', 'grep'),
+ call('approval', 'edit_workflow', { status: 'awaiting_approval' }),
+ call('c', 'read'),
+ ])
+ ).toEqual(['tools:a', 'search:s1,s2', 'tools:setup,b', 'tool', 'tools:c'])
+ })
+})
+
+describe('getLaneLiveIndicator', () => {
+ it('picks the newest running call across every run and segment of the lane', () => {
+ const indicator = getLaneLiveIndicator({
+ kind: 'main',
+ parts: [
+ [call('a', 'read', { status: 'executing', startedAt: 2 })],
+ [call('s', 'search_workspace', { status: 'executing', startedAt: 1 })],
+ ],
+ isActive: true,
+ isOpen: true,
+ })
+ expect(indicator?.type === 'call' && indicator.tool.id).toBe('a')
+ })
+
+ it('gives the gap to a succeeded trailing call, never a finished search or a failure', () => {
+ expect(liveId([call('s', 'search_workspace'), call('a', 'read')])).toBe('a')
+ expect(liveId([call('a', 'read'), call('s', 'search_workspace')])).toBeUndefined()
+ expect(liveId([call('a', 'read'), call('b', 'read', { status: 'error' })])).toBeUndefined()
+ expect(liveId([call('a', 'read')], false)).toBeUndefined()
+ })
+
+ it('has no indicator while the lane waits on the user', () => {
+ expect(
+ liveId([
+ call('a', 'read', { status: 'executing' }),
+ call('approval', 'edit_workflow', { status: 'awaiting_approval' }),
+ ])
+ ).toBeUndefined()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts
new file mode 100644
index 00000000000..156a64c42be
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts
@@ -0,0 +1,252 @@
+import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
+import {
+ collectGroupTools,
+ getNewestRunningTool,
+ hasAgentGroupItemContent,
+ isAgentGroupResolved,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content'
+import type {
+ AgentGroupItem,
+ NestedAgentGroup,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
+import { isSearchActivityTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity'
+import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
+import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
+
+/** Keep answers and interactions in the transcript, outside collapsible tool history. */
+function isStandaloneItem(item: AgentGroupItem): boolean {
+ return (
+ item.type !== 'tool' ||
+ needsToolInput(item.data) ||
+ item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID
+ )
+}
+
+/** Interactions render as their own cards, so they never hold a lane's live indicator. */
+function canHoldIndicator(tool: ToolCallData): boolean {
+ return !needsToolInput(tool) && tool.toolName !== RETIRED_BROWSER_REQUEST_TAKEOVER_ID
+}
+
+/** A run of consecutive calls the main lane renders together, as one group or one search list. */
+export interface ActivityRun {
+ tools: ToolCallData[]
+ isSearch: boolean
+}
+
+export type MainLaneEntry =
+ | { type: 'run'; run: ActivityRun }
+ | { type: 'item'; item: AgentGroupItem; index: number }
+
+/**
+ * How the main lane lays out its items: consecutive calls form runs, a search
+ * call never shares a run with another kind of call, and an interaction stands
+ * on its own and closes the run before it.
+ */
+export function splitMainLane(items: AgentGroupItem[]): MainLaneEntry[] {
+ const entries: MainLaneEntry[] = []
+ let run: ActivityRun | undefined
+ for (const [index, item] of items.entries()) {
+ if (item.type === 'tool' && !isStandaloneItem(item)) {
+ const isSearch = isSearchActivityTool(item.data)
+ if (!run || run.isSearch !== isSearch) {
+ run = { tools: [], isSearch }
+ entries.push({ type: 'run', run })
+ }
+ run.tools.push(item.data)
+ continue
+ }
+ run = undefined
+ entries.push({ type: 'item', item, index })
+ }
+ return entries
+}
+
+export interface ActiveBrowserTakeover {
+ id: string
+ reason: string
+}
+
+/**
+ * This lane's own active browser hand-back, if any. Browser-agent calls are
+ * serialized, so only the lane's latest call can be one: a newer call makes an
+ * older takeover stale.
+ */
+export function getActiveBrowserTakeover(items: AgentGroupItem[]): ActiveBrowserTakeover | null {
+ for (let index = items.length - 1; index >= 0; index--) {
+ const item = items[index]
+ if (item.type !== 'tool') continue
+ if (
+ item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID &&
+ item.data.status === ToolCallStatus.executing
+ ) {
+ const reason = item.data.params?.reason
+ return { id: item.data.id, reason: typeof reason === 'string' ? reason.trim() : '' }
+ }
+ return null
+ }
+ return null
+}
+
+/**
+ * The lane, or a lane nested in it, is waiting on the user: a permission
+ * decision or terminal handoff, and, when `includeBrowserTakeover` is set, a
+ * browser takeover (which lanes reveal through their own question instead).
+ */
+export function hasPendingInteraction(
+ items: AgentGroupItem[],
+ includeBrowserTakeover = false
+): boolean {
+ return (
+ (includeBrowserTakeover && getActiveBrowserTakeover(items) !== null) ||
+ items.some((item) =>
+ item.type === 'tool'
+ ? needsToolInput(item.data)
+ : item.type === 'agent_group' &&
+ hasPendingInteraction(item.group.items, includeBrowserTakeover)
+ )
+ )
+}
+
+/** The lane, or a lane nested in it, is waiting on the user rather than on work. */
+function isLaneAwaitingUser(items: AgentGroupItem[]): boolean {
+ return hasPendingInteraction(items, true)
+}
+
+/**
+ * A subagent lane is working while it has not failed and is either delegating
+ * unfinished calls or open on a live stream.
+ */
+export function isLaneWorking(
+ lane: Pick,
+ isStreaming: boolean
+): boolean {
+ return (
+ !lane.error &&
+ ((lane.isDelegating && !isAgentGroupResolved(lane.items)) || (isStreaming && lane.isOpen))
+ )
+}
+
+/**
+ * What owns a lane's live indicator: one call, or, for a subagent lane with
+ * narration but no calls yet, the lane's own "Thinking" header.
+ */
+export type LaneLiveIndicator = { type: 'call'; tool: ToolCallData } | { type: 'narration' }
+
+export interface LaneActivityInput {
+ kind: 'main' | 'subagent'
+ /** The lane's items; the main lane passes one part per transcript segment, in order. */
+ parts: AgentGroupItem[][]
+ /** The stream is running and the lane can still work, so it may show an indicator at all. */
+ isActive: boolean
+ /** Nothing has closed the lane's last run, so a finished latest call can still own the gap. */
+ isOpen: boolean
+}
+
+/**
+ * The latest call of the main lane's last run, which owns the trailing gap. A
+ * finished search shows static results, so its gap is never a call's.
+ */
+function getMainTrailingCall(items: AgentGroupItem[]): ToolCallData | undefined {
+ const last = splitMainLane(items).at(-1)
+ return last?.type === 'run' && !last.run.isSearch ? last.run.tools.at(-1) : undefined
+}
+
+/**
+ * The one live indicator of a lane, if it has one. See
+ * {@link getTurnLiveIndicators} for the rule this implements.
+ */
+export function getLaneLiveIndicator({
+ kind,
+ parts,
+ isActive,
+ isOpen,
+}: LaneActivityInput): LaneLiveIndicator | undefined {
+ if (!isActive || parts.some(isLaneAwaitingUser)) return undefined
+ const calls = parts.flatMap(collectGroupTools).filter(canHoldIndicator)
+ const running = getNewestRunningTool(calls)
+ if (running) return { type: 'call', tool: running }
+ if (!isOpen) return undefined
+ if (kind === 'subagent' && calls.length === 0) {
+ return parts.some((items) => items.some(hasAgentGroupItemContent))
+ ? { type: 'narration' }
+ : undefined
+ }
+ const latest = kind === 'main' ? getMainTrailingCall(parts.at(-1) ?? []) : calls.at(-1)
+ return latest?.status === ToolCallStatus.success ? { type: 'call', tool: latest } : undefined
+}
+
+/** A top-level lane of the turn, as the transcript segments it. */
+export interface TurnLane extends Pick {
+ id: string
+ agentName: string
+ items: AgentGroupItem[]
+}
+
+/** The thinking row stays hidden while a lane shows a live indicator or waits on the user. */
+export function ownsTurnWait({ isAwaitingUser, byLane }: TurnLiveIndicators): boolean {
+ return isAwaitingUser || byLane.size > 0
+}
+
+export interface TurnLiveIndicators {
+ /** Some lane is waiting on the user, so the thinking row stays hidden. */
+ isAwaitingUser: boolean
+ /** Each top-level lane's indicator by lane id; every main-lane segment shares the main one. */
+ byLane: ReadonlyMap
+}
+
+/**
+ * The live indicators of a turn. This is the single owner of the rule:
+ *
+ * - One live indicator per active lane. The turn-level thinking row shows only
+ * when no lane has a live indicator and the turn is not waiting on the user.
+ * Once the stream finishes, nothing shimmers.
+ * - Within a lane there is at most one current live call: the newest running
+ * call anywhere in the lane, across all of its runs and the lanes nested in
+ * it. With none running and the lane open, the latest call of its trailing
+ * run owns the gap, and is live only if it succeeded; an error, rejection,
+ * stop, skip, or interruption hands the wait to the thinking row. A finished
+ * main-lane search shows static results, so its gap is never live. A subagent
+ * lane's trailing call is its latest call, and an open subagent lane with
+ * narration but no calls shows its own "Thinking" header.
+ * - Only the run holding the live call shimmers: its tool group header, or its
+ * one search row. A parent lane whose live call sits in a nested lane defers
+ * to that lane while the nested lane is still working and visible; a nested
+ * lane that has ended hands its last call back to the parent.
+ * - A header reads in the present tense exactly while it is live or its call
+ * is still running, so tense always agrees with the shimmer.
+ * - A lane waiting on the user (a pending approval, a terminal handoff, or a
+ * browser takeover, in the lane or a lane nested in it) shows no indicator:
+ * its interactive card is the call to action. Other lanes that are still
+ * live keep their one indicator, since hiding real parallel work would
+ * misrepresent it. The thinking row stays hidden while any lane waits.
+ *
+ * The main lane spans every main-agent segment of the transcript: prose and
+ * activity changes split it into segments, but it is one lane with one
+ * indicator.
+ */
+export function getTurnLiveIndicators(lanes: TurnLane[], isStreaming: boolean): TurnLiveIndicators {
+ const byLane = new Map()
+ const isAwaiting = lanes.some((lane) => isLaneAwaitingUser(lane.items))
+ if (!isStreaming) return { isAwaitingUser: isAwaiting, byLane }
+ const mainParts = lanes.filter((lane) => lane.agentName === 'mothership')
+ const mainIndicator = getLaneLiveIndicator({
+ kind: 'main',
+ parts: mainParts.map((lane) => lane.items),
+ isActive: true,
+ isOpen: mainParts.at(-1)?.isOpen === true,
+ })
+ for (const lane of lanes) {
+ const working = lane.agentName !== 'mothership' && isLaneWorking(lane, isStreaming)
+ const indicator =
+ lane.agentName === 'mothership'
+ ? mainIndicator
+ : getLaneLiveIndicator({
+ kind: 'subagent',
+ parts: [lane.items],
+ isActive: working,
+ isOpen: working,
+ })
+ if (indicator) byLane.set(lane.id, indicator)
+ }
+ return { isAwaitingUser: isAwaiting, byLane }
+}
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 969a5530ce8..7bd0f2429ec 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
@@ -1,92 +1,56 @@
import { type ComponentType, Fragment, type ReactNode } from 'react'
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
-import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
-import {
- isSearchActivityTool,
- SearchActivity,
-} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity'
+import { splitMainLane } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity'
+import { SearchActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity'
import { ToolActivityGroup } 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 { isToolDone } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
-import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
interface MainAgentActivityProps {
activity?: ToolActivity
- completedGroupCount?: number
items: AgentGroupItem[]
ToolCallComponent: ComponentType
renderItem: (item: AgentGroupItem, index: number) => ReactNode
autoScrollActivity: boolean
- isActive: boolean
-}
-
-/** Keep answers and interactions in the transcript, outside collapsible tool history. */
-function isStandaloneItem(item: AgentGroupItem): boolean {
- return (
- item.type !== 'tool' ||
- needsToolInput(item.data) ||
- item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID
- )
+ /** The call holding the main lane's live indicator; only the run containing it shimmers. */
+ liveToolId?: string
}
+/**
+ * The main lane's runs and interaction cards;
+ * which run is live is decided by the lane, never by a run's position.
+ */
export function MainAgentActivity({
activity: groupActivity,
- completedGroupCount,
items,
ToolCallComponent,
renderItem,
autoScrollActivity,
- isActive,
+ liveToolId,
}: MainAgentActivityProps) {
- const activity: ReactNode[] = []
- const unresolved = items.some((item) => item.type === 'tool' && !isToolDone(item.data.status))
- let tools: ToolCallData[] = []
- const flushTools = (active = false) => {
- if (tools.length === 0) return
- activity.push(
- isSearchActivityTool(tools[0]) ? (
-
- ) : (
-
+ const entries = splitMainLane(items)
+ const activity = entries.map((entry) => {
+ if (entry.type === 'item') {
+ return (
+
+ {renderItem(entry.item, entry.index)}
+
)
- )
- tools = []
- }
-
- for (const [index, item] of items.entries()) {
- if (item.type === 'tool' && !isStandaloneItem(item)) {
- if (tools.length && isSearchActivityTool(tools[0]) !== isSearchActivityTool(item.data)) {
- flushTools()
- }
- tools.push(item.data)
- continue
}
- flushTools()
- activity.push(
-
- {renderItem(item, index)}
-
+ const { tools, isSearch } = entry.run
+ return isSearch ? (
+
+ ) : (
+ tool.id === liveToolId)}
+ ToolCallComponent={ToolCallComponent}
+ autoScrollActivity={autoScrollActivity}
+ />
)
- }
- flushTools(isActive)
+ })
- return
{activity}
+ return
{activity}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx
index 5d586bc7a42..d22a3a13a00 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx
@@ -84,6 +84,46 @@ describe('inline search activity', () => {
expect(container.textContent).not.toContain('Searched sources')
})
+ it.each([
+ ['preparing', { ...tool, params: undefined, streamingArgs: '{"que' }, 'Preparing query'],
+ ['running', tool, 'Launch review'],
+ [
+ 'checking sources',
+ { ...tool, toolName: 'search_sources', params: { action: 'list' } },
+ 'Checking connected sources',
+ ],
+ ['done', { ...tool, status: 'success' }, 'Launch review'],
+ ['failed', { ...tool, status: 'error' }, 'Launch review'],
+ [
+ 'checked sources',
+ { ...tool, toolName: 'search_sources', status: 'success', params: { action: 'list' } },
+ 'Checked connected sources',
+ ],
+ ] as const)(
+ 'labels a %s search and leaves it static unless its lane names it live',
+ (_state, call, label) => {
+ render()
+ const status = container.querySelector('[role="status"]')
+ expect(status?.textContent).toContain(label)
+ expect(status?.querySelector('[class*="shimmer"]')).toBeNull()
+ }
+ )
+
+ it('shimmers only the row holding the lane live call', () => {
+ const first = { ...tool, id: 'first', params: { query: 'First query' } }
+ const second = { ...tool, id: 'second', params: { query: 'Second query' } }
+ const liveLabels = () =>
+ [...container.querySelectorAll('[role="status"]')]
+ .filter((row) => row.querySelector('[class*="shimmer"]'))
+ .map((row) => row.textContent)
+ render()
+ expect(liveLabels()).toEqual(['Second query'])
+ render()
+ expect(liveLabels()).toEqual(['First query'])
+ render()
+ expect(liveLabels()).toEqual([])
+ })
+
it('keeps source setup and approval in the interactive tool renderer', () => {
expect(
isSearchActivityTool({ ...tool, toolName: 'search_sources', params: { action: 'list' } })
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx
index d270c33c51f..637ea3faf95 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx
@@ -13,7 +13,7 @@ import {
import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args'
import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
import { SearchActivityResults } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results'
-import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url'
import { isToolDone } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
@@ -56,21 +56,19 @@ function searchStatus(tool: ToolCallData): string | undefined {
interface SearchQueryActivityProps {
tool: ToolCallData
+ /** This row holds its lane's one live indicator. */
+ isLive: boolean
}
/** Each search call owns a stable result snapshot, so later searches never replace it. */
-function SearchQueryActivity({ tool }: SearchQueryActivityProps) {
+function SearchQueryActivity({ tool, isLive }: SearchQueryActivityProps) {
const [expanded, setExpanded] = useState(true)
const queries = searchQueries(tool)
const status = searchStatus(tool)
const evidence = collectRetrievalCitationEvidence([
{ toolCall: { name: tool.toolName, status: tool.status, result: tool.result } },
])
- const byUrl = new Map()
- for (const source of evidence.values()) {
- if (!byUrl.has(source.url)) byUrl.set(source.url, source)
- }
- const sources = [...byUrl.values()]
+ const sources = [...indexSourcesByUrl(evidence.values()).values()]
const output = parseCitationRecord(tool.result?.output)
const data = parseCitationRecord(output?.data) ?? output
const noResults =
@@ -92,7 +90,7 @@ function SearchQueryActivity({ tool }: SearchQueryActivityProps) {
return (
} />
+ } />
}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
@@ -124,14 +122,19 @@ function SearchQueryActivity({ tool }: SearchQueryActivityProps) {
interface SearchActivityProps {
tools: ToolCallData[]
+ /** The call holding the lane's live indicator, which only a running search can be. */
+ liveToolId?: string
}
-/** Search history and its results stay inspectable without changing the selected panel. */
-export function SearchActivity({ tools }: SearchActivityProps) {
+/**
+ * Search history and its results stay inspectable without changing the
+ * selected panel. Only the row holding the lane's live call shimmers.
+ */
+export function SearchActivity({ tools, liveToolId }: SearchActivityProps) {
return (
-
+
{tools.map((tool) => (
-
+
))}
)
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 7e2f10b45c9..1aa75cca0c9 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,57 +1,105 @@
/** @vitest-environment node */
import { describe, expect, it } from 'vitest'
-import { getToolActivitySummary } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
+import { getCompletedActivityLabel } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
-function tool(id: string, displayTitle: string, status: ToolCallStatus = 'success'): ToolCallData {
- return { id, toolName: 'sim_cli', displayTitle, status }
+/** A finished activity without a model title is labelled by its action summary. */
+function getToolActivitySummary(tools: ToolCallData[]): string {
+ return getCompletedActivityLabel(tools, undefined)
+}
+
+let toolSeq = 0
+
+function tool(
+ toolName: string,
+ displayTitle: string,
+ status: ToolCallStatus = 'success',
+ params?: Record
+): ToolCallData {
+ toolSeq += 1
+ return { id: `tool-${toolSeq}`, toolName, displayTitle, status, params }
}
describe('getToolActivitySummary', () => {
- it('uses the latest concrete resource action plus the remaining call count', () => {
+ it('names the first three distinct CLI actions without counting the rest', () => {
expect(
getToolActivitySummary([
- tool('read', 'Reading invoice inputs'),
- tool('edit', 'Editing invoice workflow'),
- tool('run', 'Running invoice workflow'),
+ tool('cli_tables_list', 'Listed tables'),
+ tool('cli_tables_get', 'Read Invoices'),
+ tool('cli_tables_get', 'Read Customers'),
+ tool('cli_files_read', 'Read notes.md'),
+ tool('cli_workflows_run', 'Ran invoice workflow'),
])
- ).toBe('Ran invoice workflow + 2')
+ ).toBe('Listed, read tables, read files')
})
- it('keeps model supplied descriptions without replacing them with action categories', () => {
+ it('shares one object across adjacent browser actions', () => {
expect(
getToolActivitySummary([
- { ...tool('read', 'Reading inbox'), activityDescription: 'Read the latest inbox emails' },
+ tool('browser_navigate', 'Opened example.com'),
+ tool('browser_read_text', 'Read page'),
+ tool('browser_click', 'Clicked Sign in'),
+ tool('browser_screenshot', 'Took screenshot'),
])
- ).toBe('Read the latest inbox emails')
+ ).toBe('Navigated, read pages, clicked elements')
})
- it('keeps failed attempts in history without naming or counting them in the summary', () => {
+ it('collapses repeated actions so a long run cannot crowd out other kinds', () => {
+ expect(
+ getToolActivitySummary([
+ tool('web_search', 'Searched online for pricing'),
+ tool('web_search', 'Searched online for plans'),
+ tool('web_fetch', 'Fetched pricing page'),
+ tool('run_code', 'Ran code'),
+ ])
+ ).toBe('Searched the web, read web pages, ran code')
+ })
+
+ it('keeps two phrases when only two distinct actions succeeded', () => {
expect(
getToolActivitySummary([
- tool('read', 'Reading invoice inputs'),
- tool('failed', 'Running invoice workflow', 'error'),
- tool('rejected', 'Editing invoice workflow', 'rejected'),
+ tool('cli_workflows_get', 'Read invoice workflow'),
+ tool('cli_workflows_operations_apply', 'Edited invoice workflow'),
])
- ).toBe('Read invoice inputs')
+ ).toBe('Read, edited workflows')
+ })
+
+ it('keeps failed attempts in history without naming or counting them in the summary', () => {
+ const summary = getToolActivitySummary([
+ tool('web_search', 'Searched online for invoices'),
+ tool('browser_navigate', 'Opening billing portal', 'error'),
+ tool('cli_tables_rows_update', 'Updating table row', 'rejected'),
+ tool('read', 'Read invoice inputs'),
+ ])
+ expect(summary).toBe('Searched the web, read files')
+ expect(summary).not.toMatch(/\+\s?\d/)
})
it('does not claim success when every call failed', () => {
expect(
getToolActivitySummary([
- tool('failed', 'Reading invoice inputs', 'error'),
- tool('rejected', 'Editing invoice workflow', 'rejected'),
+ tool('read', 'Reading invoice inputs', 'error'),
+ tool('edit_workflow', 'Editing invoice workflow', 'rejected'),
])
).toBe('2 tool calls')
})
- it('preserves a concrete custom tool name instead of a generic used tools fallback', () => {
+ it('describes custom and unnamed CLI calls with their own completed titles', () => {
+ expect(
+ getToolActivitySummary([
+ tool('custom_inventory', 'Checked inventory'),
+ tool('sim_cli', 'Reconciled account balances'),
+ ])
+ ).toBe('Checked inventory, reconciled account balances')
+ })
+
+ it('keeps a single call title and its model supplied description', () => {
+ expect(getToolActivitySummary([tool('cli_tables_list', 'Listed tables')])).toBe('Listed tables')
expect(
getToolActivitySummary([
- { ...tool('a', 'Checking inventory'), toolName: 'custom_inventory' },
- { ...tool('b', 'Reconciled account balances'), toolName: 'custom_reconcile' },
+ { ...tool('read', 'Reading inbox'), activityDescription: 'Read the latest inbox emails' },
])
- ).toBe('Reconciled account balances + 1')
+ ).toBe('Read the latest inbox emails')
})
})
@@ -67,12 +115,12 @@ describe('interrupted activity summaries', () => {
it('keeps earlier interruption counts without naming failed calls', () => {
expect(
getToolActivitySummary([
- tool('failed', 'Reading file', 'error'),
- tool('stopped', 'Running checks', 'interrupted'),
- tool('skipped', 'Running checks', 'skipped'),
- tool('finished', 'Reading project notes'),
+ tool('read', 'Reading file', 'error'),
+ tool('terminal', 'Running checks', 'interrupted', { operation: 'run' }),
+ tool('terminal', 'Running checks', 'skipped', { operation: 'run' }),
+ tool('read', 'Read project notes'),
])
- ).toBe('Read project notes + 2 · 1 stopped · 1 skipped')
+ ).toBe('Read project notes · 1 stopped · 1 skipped')
})
it('does not infer tool failures from workflow results', () => {
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 4fac406a248..777d2fa81b4 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,14 +3,21 @@
import { type ComponentType, Fragment, useState } from 'react'
import { ActivityStatus } from '@/components/ui/activity-status'
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
-import { readToolActivity } from '@/lib/mothership/tools/tool-activity'
+import {
+ getToolActivitySummaryActions,
+ readToolActivity,
+} from '@/lib/mothership/tools/tool-activity'
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 { isToolDone } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
+/** A completed summary names at most this many distinct actions, and never counts the rest. */
+const MAX_SUMMARY_ACTIONS = 3
+
function isFailedTool(tool: ToolCallData): boolean {
return tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected
}
@@ -19,24 +26,34 @@ function toolCountLabel(tools: ToolCallData[]): string {
return `${tools.length} tool ${tools.length === 1 ? 'call' : 'calls'}`
}
-/** Summarize completed actions without describing failed or skipped work as successful. */
-export function getToolActivitySummary(tools: ToolCallData[]): string {
- const statusTool = getActivityStatusTool(tools)
- if (!statusTool || (tools.length > 1 && isFailedTool(statusTool))) return toolCountLabel(tools)
- const label = getToolStatusDisplayTitle(
- statusTool.displayTitle,
- statusTool.status,
- statusTool.toolName,
- statusTool.activityDescription
- )
- const visibleCount = tools.filter((tool) => !isFailedTool(tool)).length
- return getActiveToolActivityTitle(
- `${label}${visibleCount > 1 ? ` + ${visibleCount - 1}` : ''}`,
- statusTool,
- tools
+function getToolTitle(tool: ToolCallData): string {
+ return getToolStatusDisplayTitle(
+ tool.displayTitle,
+ tool.status,
+ tool.toolName,
+ tool.activityDescription
)
}
+/**
+ * Summarize completed actions without describing failed or skipped work as
+ * successful: a single call, or the only successful one, keeps its own title;
+ * several successful calls name their distinct actions ("Navigated, read
+ * pages, clicked elements"); stopped or skipped calls append an outcome count.
+ */
+function getToolActivitySummary(tools: ToolCallData[]): string {
+ if (tools.length === 1) return getToolTitle(tools[0])
+ const succeeded = tools.filter((tool) => tool.status === ToolCallStatus.success)
+ const [first, ...rest] =
+ succeeded.length === 1
+ ? [getToolTitle(succeeded[0])]
+ : getToolActivitySummaryActions(succeeded, MAX_SUMMARY_ACTIONS)
+ const summary = first
+ ? [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(', ')
+ : toolCountLabel(tools)
+ return [summary, ...getToolActivityInterruptions(tools)].join(' · ')
+}
+
function getToolActivityInterruptions(tools: ToolCallData[]): string[] {
let stopped = 0
let skipped = 0
@@ -48,8 +65,26 @@ function getToolActivityInterruptions(tools: ToolCallData[]): string[] {
return [...(stopped ? [`${stopped} stopped`] : []), ...(skipped ? [`${skipped} skipped`] : [])]
}
+/**
+ * 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.
+ */
+export function getInProgressActivityLabel(
+ activeLabel: string,
+ statusTool: ToolCallData,
+ tools: ToolCallData[]
+): 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)
+}
+
/** Keep earlier interruptions visible while the latest action continues. */
-export function getActiveToolActivityTitle(
+function getActiveToolActivityTitle(
label: string,
tool: ToolCallData,
tools: ToolCallData[]
@@ -59,37 +94,49 @@ export function getActiveToolActivityTitle(
: label
}
-/** Keep running work visible until every parallel call finishes. */
+/**
+ * The call a header describes: the newest running call, else the latest call
+ * that did not error or get rejected, so a failed attempt labels the activity
+ * only when every call failed. A stopped, skipped, or interrupted call can still be it.
+ */
export function getActivityStatusTool(tools: ToolCallData[]): ToolCallData | undefined {
return (
- tools.reduce(
- (newest, tool) =>
- !isToolDone(tool.status) && (!newest || (tool.startedAt ?? 0) >= (newest.startedAt ?? 0))
- ? tool
- : newest,
- undefined
- ) ??
+ getNewestRunningTool(tools) ??
tools.filter((tool) => !isFailedTool(tool)).at(-1) ??
tools.at(-1)
)
}
+/**
+ * The label of a finished activity: the model's completed title when every
+ * call succeeded, else the summary of what did succeed, so a stopped, skipped,
+ * or failed call never hides behind a success title. Main and subagent lanes
+ * share this rule.
+ */
+export function getCompletedActivityLabel(
+ tools: ToolCallData[],
+ activity: ToolActivity | undefined
+): string {
+ return activity?.completedTitle && tools.every((tool) => tool.status === ToolCallStatus.success)
+ ? activity.completedTitle
+ : getToolActivitySummary(tools)
+}
+
interface ToolActivityGroupProps {
activity?: ToolActivity
- completedGroupCount?: number
tools: ToolCallData[]
ToolCallComponent: ComponentType
autoScrollActivity?: boolean
- isActive?: boolean
+ /** The group holds its lane's one live indicator, so its header shimmers. */
+ isLive?: boolean
}
export function ToolActivityGroup({
activity,
- completedGroupCount = 0,
tools,
ToolCallComponent,
autoScrollActivity = true,
- isActive = false,
+ isLive = false,
}: ToolActivityGroupProps) {
const [expanded, setExpanded] = useState(false)
const statusTool = getActivityStatusTool(tools)
@@ -100,34 +147,9 @@ export function ToolActivityGroup({
.map((tool) => readToolActivity(tool.params, tool.streamingArgs))
.reverse()
.find((entry) => entry?.title || entry?.completedTitle)
- const running = tools.filter((tool) => !isToolDone(tool.status))
- const working = running.length > 0
- const complete = !isActive && !working
- const headerActive = working && statusTool.status !== ToolCallStatus.awaiting_approval
+ /** Tense follows liveness: a live group, or one with a call still running, reads in progress. */
+ const working = isLive || tools.some((tool) => !isToolDone(tool.status))
const attentionKey = getActivityAttentionKey(tools)
- /** A merged summary cannot claim success when any represented call did not complete. */
- const failedActivityTool = tools.find(
- (tool) => isToolDone(tool.status) && tool.status !== ToolCallStatus.success
- )
- const completedActivityLabel = groupedActivity?.completedTitle
- ? failedActivityTool
- ? isFailedTool(failedActivityTool)
- ? undefined
- : getToolStatusDisplayTitle(
- failedActivityTool.displayTitle,
- failedActivityTool.status,
- failedActivityTool.toolName,
- failedActivityTool.activityDescription
- )
- : groupedActivity.completedTitle
- : undefined
- const completedLabel = completedActivityLabel
- ? `${completedActivityLabel}${completedGroupCount > 1 ? ` + ${completedGroupCount - 1}` : ''}`
- : undefined
- const generatingCall =
- working &&
- (statusTool.toolName === 'sim_cli' || statusTool.toolName === 'run_code') &&
- Object.keys(statusTool.params ?? {}).every((key) => key === 'activity')
return (
(
1 ? ` + ${running.length - 1}` : ''}`,
- statusTool,
- tools
- )
- : complete
- ? (completedLabel ?? getToolActivitySummary(tools))
- : getActiveToolActivityTitle(status.label, statusTool, tools),
- isActive: headerActive,
+ label: working
+ ? getInProgressActivityLabel(status.activeLabel, statusTool, tools)
+ : tools.length === 1
+ ? status.label
+ : getCompletedActivityLabel(tools, groupedActivity),
+ isActive: isLive,
+ icon: status.icon,
}}
activityKey={statusTool.id}
attentionKey={attentionKey}
@@ -160,7 +174,7 @@ export function ToolActivityGroup({
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 064ba96fd99..4bc151bce2a 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
@@ -36,6 +36,18 @@ describe('ToolCallItem', () => {
}
)
+ it('decodes escaped characters in a streamed file-edit title instead of truncating it', () => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Writing Report "Q3" draft.md')
+ })
+
it('does not restore a progressive streamed title after the tool settles', () => {
const markup = renderToStaticMarkup(
{
expect(markup).toContain('Read recent emails')
})
- it('paces an iconless header while preserving integration icons on expanded child tools', () => {
+ it('keeps the header brand icon stable from running through completion', () => {
vi.useFakeTimers()
const container = document.createElement('div')
const root = createRoot(container)
@@ -235,29 +247,29 @@ describe('ToolCallItem', () => {
displayTitle: 'Reading messages',
status: 'executing',
}
- const render = (tools: ToolCallData[], isActive = true) =>
+ const render = (tools: ToolCallData[], isLive = true) =>
act(() =>
root.render(
-
+
)
)
const header = () => container.querySelector('[role="status"]')!
try {
render([first])
expect(header().textContent).toBe('Reading mail')
- expect(header().querySelector('svg')).toBeNull()
+ expect(header().querySelector('[data-testid="gmail-icon"]')).not.toBeNull()
act(() => vi.advanceTimersByTime(100))
render([{ ...first, status: 'success' }, next])
expect(header().textContent).toBe('Reading mail')
- expect(header().querySelector('svg')).toBeNull()
+ expect(header().querySelector('[data-testid="gmail-icon"]')).not.toBeNull()
act(() => vi.advanceTimersByTime(900))
expect(header().textContent).toBe('Reading messages')
- expect(header().querySelector('svg')).toBeNull()
+ expect(header().querySelector('[data-testid="slack-icon"]')).not.toBeNull()
const disclosure = container.querySelector('[role="button"]')!
act(() => disclosure.click())
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(header().textContent).toBe('Reading messages')
- expect(header().querySelector('svg')).toBeNull()
+ expect(header().querySelector('[data-testid="slack-icon"]')).not.toBeNull()
const childRows = Array.from(container.querySelectorAll('[role="status"]')).slice(1)
expect(childRows).toHaveLength(2)
expect(childRows[0].textContent).toBe('Read mail')
@@ -271,13 +283,15 @@ describe('ToolCallItem', () => {
],
false
)
- expect(header().textContent).toBe('Read messages + 1')
- expect(header().querySelector('svg')).toBeNull()
+ /** Completion keeps the latest call's brand icon rather than switching to another call's. */
+ expect(header().textContent).toBe('Read mail, read messages')
+ expect(header().querySelector('[data-testid="slack-icon"]')).not.toBeNull()
+ expect(header().querySelector('[data-testid="gmail-icon"]')).toBeNull()
expect(container.querySelector('[data-testid="gmail-icon"]')).not.toBeNull()
expect(container.querySelector('[data-testid="slack-icon"]')).not.toBeNull()
render([{ ...next, status: 'success' }], false)
expect(header().textContent).toBe('Read messages')
- expect(header().querySelector('svg')).toBeNull()
+ expect(header().querySelector('[data-testid="slack-icon"]')).not.toBeNull()
expect(container.querySelector('[role="button"]')).toBeNull()
} finally {
act(() => root.unmount())
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 66174dc49e6..71c63bd116b 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,20 +1,15 @@
-import { type ReactNode, useEffect, useMemo, useState } from 'react'
+import { type ReactNode, useMemo } from 'react'
import { isPlainRecord } from '@sim/utils/object'
import { ActivityStatus, type ActivityStatusProps } from '@/components/ui/activity-status'
import {
CallIntegrationTool,
- PrepareFileEdit,
Read as ReadTool,
Terminal as TerminalTool,
- Wait as WaitTool,
} from '@/lib/mothership/generated/tool-catalog-v1'
import { getReadTargetBlock } from '@/lib/mothership/tools/client/read-block'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args'
-import {
- getToolStatusDisplayTitle,
- getWaitCountdownTitle,
-} from '@/lib/mothership/tools/tool-display'
+import { useToolCallTitle } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title'
import { ToolPermissionCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card'
import {
BrowserTakeoverQuestion,
@@ -69,39 +64,6 @@ function nestedStringParam(params: Record | undefined, key: str
return typeof value === 'string' ? value : ''
}
-/**
- * How often the countdown re-reads the clock. Comfortably under a second so
- * the displayed number turns over close to when it actually should, rather
- * than drifting by most of a second against an interval that started late.
- */
-const COUNTDOWN_TICK_MS = 250
-
-/**
- * Milliseconds elapsed since the call started, while `active`.
- *
- * Anchors to `startedAt` so a row that mounts partway through a pause resumes
- * mid-countdown instead of restarting; falls back to activation time when the
- * caller has no start to give.
- */
-function useElapsedMs(
- active: boolean,
- startedAt: number | undefined,
- toolCallId: string | undefined
-): number {
- const [sample, setSample] = useState({ toolCallId, elapsedMs: 0 })
-
- useEffect(() => {
- if (!active) return
- const anchor = startedAt ?? Date.now()
- const tick = () => setSample({ toolCallId, elapsedMs: Date.now() - anchor })
- tick()
- const interval = setInterval(tick, COUNTDOWN_TICK_MS)
- return () => clearInterval(interval)
- }, [active, startedAt, toolCallId])
-
- return active && sample.toolCallId === toolCallId ? sample.elapsedMs : 0
-}
-
/**
* Inline tool activity: shimmer while executing, a
* static label once terminal. For `workspace_file` the title is derived live
@@ -114,6 +76,7 @@ function useElapsedMs(
* An executing `browser_request_takeover` is lifted by AgentGroup into its
* parent flow; this row remains the canonical completed-history entry after
* the browser agent resumes.
+ * Rows are history and never shimmer; the lane decides which header or search row is live.
*/
export function ToolCallItem({
toolName,
@@ -143,51 +106,20 @@ export function ToolCallItem({
return typeof toolId === 'string' ? getBlockByToolName(toolId) : undefined
}, [toolName, params, streamingArgs])
- const liveWorkspaceFileTitle = useMemo(() => {
- if (toolName !== PrepareFileEdit.id || !streamingArgs) return null
- const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/)
- if (!titleMatch?.[1]) return null
- const opMatch = streamingArgs.match(/"operation"\s*:\s*"(\w+)"/)
- const op = opMatch?.[1] ?? ''
- const verb =
- op === 'create'
- ? 'Creating'
- : op === 'append'
- ? 'Adding'
- : op === 'patch'
- ? 'Editing'
- : op === 'update'
- ? 'Writing'
- : op === 'rename'
- ? 'Renaming'
- : op === 'delete'
- ? 'Deleting'
- : 'Writing'
- const unescaped = titleMatch[1]
- .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
- String.fromCharCode(Number.parseInt(hex, 16))
- )
- .replace(/\\"/g, '"')
- .replace(/\\\\/g, '\\')
- return `${verb} ${unescaped}`
- }, [toolName, streamingArgs])
-
const displayState = resolveToolDisplayState(status)
const isExecuting = displayState === 'spinner'
const isBrowserTakeover = toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID
- const isCountingDown = toolName === WaitTool.id && isExecuting
- const elapsedMs = useElapsedMs(isCountingDown, startedAt, toolCallId)
-
- const liveTitle = isCountingDown
- ? getWaitCountdownTitle(params, elapsedMs)
- : liveWorkspaceFileTitle || displayTitle
- const title = getToolStatusDisplayTitle(
- liveTitle,
- status,
+ const { label: title, activeLabel } = useToolCallTitle({
+ toolCallId,
toolName,
- isCountingDown ? undefined : activityDescription
- )
+ displayTitle,
+ activityDescription,
+ status,
+ params,
+ streamingArgs,
+ startedAt,
+ }) ?? { label: displayTitle, activeLabel: displayTitle }
// A waiting terminal handoff swaps its row for the hand-back chip, the same
// way a browser takeover does: the row would otherwise spin with nothing
@@ -241,11 +173,8 @@ export function ToolCallItem({
const activity: ToolActivityPresentation = {
label: title,
- activeLabel:
- status === 'success'
- ? getToolStatusDisplayTitle(liveTitle, 'executing', toolName, activityDescription)
- : title,
- isActive: isExecuting,
+ activeLabel,
+ isActive: false,
icon: BlockIcon ? (
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts
new file mode 100644
index 00000000000..6ed9702b6c5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts
@@ -0,0 +1,106 @@
+import { useEffect, useMemo, useState } from 'react'
+import { PrepareFileEdit, Wait as WaitTool } from '@/lib/mothership/generated/tool-catalog-v1'
+import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args'
+import {
+ getToolInProgressTitle,
+ getToolStatusDisplayTitle,
+ getWaitCountdownTitle,
+} from '@/lib/mothership/tools/tool-display'
+import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
+
+/**
+ * How often the countdown re-reads the clock. Comfortably under a second so
+ * the displayed number turns over close to when it actually should, rather
+ * than drifting by most of a second against an interval that started late.
+ */
+const COUNTDOWN_TICK_MS = 250
+
+/** Present participle for each `prepare_file_edit` operation, read from the streaming args. */
+const FILE_EDIT_VERBS: Readonly> = {
+ create: 'Creating',
+ append: 'Adding',
+ patch: 'Editing',
+ update: 'Writing',
+ rename: 'Renaming',
+ delete: 'Deleting',
+}
+
+/**
+ * Milliseconds elapsed since the call started, while `active`.
+ *
+ * Anchors to `startedAt` so a row that mounts partway through a pause resumes
+ * mid-countdown instead of restarting; falls back to activation time when the
+ * caller has no start to give.
+ */
+function useElapsedMs(
+ active: boolean,
+ startedAt: number | undefined,
+ toolCallId: string | undefined
+): number {
+ const [sample, setSample] = useState({ toolCallId, elapsedMs: 0 })
+
+ useEffect(() => {
+ if (!active) return
+ const anchor = startedAt ?? Date.now()
+ const tick = () => setSample({ toolCallId, elapsedMs: Date.now() - anchor })
+ tick()
+ const interval = setInterval(tick, COUNTDOWN_TICK_MS)
+ return () => clearInterval(interval)
+ }, [active, startedAt, toolCallId])
+
+ return active && sample.toolCallId === toolCallId ? sample.elapsedMs : 0
+}
+
+/**
+ * A `prepare_file_edit` title read live from its streaming arguments, before
+ * the parsed title exists; that path bypasses the completed-title rewrite in
+ * `toToolData`, so the status-aware title applies it on success.
+ */
+function getLiveFileEditTitle(toolName?: string, streamingArgs?: string): string | null {
+ if (toolName !== PrepareFileEdit.id || !streamingArgs) return null
+ const title = extractStreamingStringArgument(streamingArgs, 'title')
+ if (!title) return null
+ const operation = extractStreamingStringArgument(streamingArgs, 'operation') ?? ''
+ const verb = Object.hasOwn(FILE_EDIT_VERBS, operation) ? FILE_EDIT_VERBS[operation] : 'Writing'
+ return `${verb} ${title}`
+}
+
+export type ToolCallTitleInput = Pick<
+ ToolCallData,
+ 'toolName' | 'displayTitle' | 'activityDescription' | 'status' | 'params' | 'streamingArgs'
+> & {
+ toolCallId?: string
+ startedAt?: number
+}
+
+export interface ToolCallTitle {
+ /** The call's status-aware title, as its own row shows it. */
+ label: string
+ /** The call described as work in progress, as an open header shows it. */
+ activeLabel: string
+}
+
+/**
+ * The titles of one call, shared by its row and by any header that names it:
+ * a running `wait` counts down, a streaming file edit names its file, and a
+ * model description replaces the generic title.
+ */
+export function useToolCallTitle(tool: ToolCallTitleInput | undefined): ToolCallTitle | undefined {
+ const toolName = tool?.toolName
+ const streamingArgs = tool?.streamingArgs
+ const liveFileEditTitle = useMemo(
+ () => getLiveFileEditTitle(toolName, streamingArgs),
+ [toolName, streamingArgs]
+ )
+ const isCountingDown = toolName === WaitTool.id && tool?.status === 'executing'
+ const elapsedMs = useElapsedMs(isCountingDown, tool?.startedAt, tool?.toolCallId)
+ if (!tool) return undefined
+ const liveTitle = isCountingDown
+ ? getWaitCountdownTitle(tool.params, elapsedMs)
+ : liveFileEditTitle || tool.displayTitle
+ const description = isCountingDown ? undefined : tool.activityDescription
+ return {
+ label: getToolStatusDisplayTitle(liveTitle, tool.status, tool.toolName, description),
+ activeLabel: getToolInProgressTitle(liveTitle, tool.status, tool.toolName, description),
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx
index e3b483048bf..0af0622c44d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import {
ChevronDown,
Chip,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -14,6 +15,11 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
+import {
+ ACTIVITY_ICON_SLOT_CLASS,
+ ACTIVITY_ROW_CLASS,
+ ActivityTextColumn,
+} from '@/components/ui/activity-status'
import { requestJson } from '@/lib/api/client/request'
import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot'
import { generalSettingsKeys } from '@/hooks/queries/current-user-data'
@@ -132,8 +138,13 @@ export function ToolPermissionCard({
if (expired) {
return (
-
-
+
+
+
+
{displayTitle} — this request is no longer active
@@ -143,66 +154,76 @@ export function ToolPermissionCard({
return (
-
- {/* 16px icon + 2px + the row's 4px gap = the 22px text inset the sibling
- rows below are tuned to (`pl-[22px]`, and `gap-1.5` on an unmargined
- icon). Margin and gap add, so this cannot be `mr-1.5`. */}
-
- {preview ? (
-
-
-
- {displayTitle}
-
-
-
-