diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx
index a2595fd59a0..37fdc203189 100644
--- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx
+++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx
@@ -217,7 +217,6 @@ export function HeroChatLoop({
agentLabel='Workflow Agent'
items={WORKFLOW_AGENT_BUILDING_ITEMS}
isStreaming
- isCurrentSection
isLaneOpen
defaultExpanded
autoScrollActivity={false}
@@ -237,7 +236,6 @@ export function HeroChatLoop({
agentName='mothership'
agentLabel='Sim'
items={SIM_ITEMS}
- defaultExpanded
/>
}
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
index ecaba9120c2..1c60964abdf 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
@@ -3,6 +3,7 @@
import { useMemo } from 'react'
import { Chip, ChipLink } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
+import { ShimmerText } from '@/components/ui/shimmer-text'
import type {
WorkspaceKnowledgeSearchResult,
WorkspaceSearchFilters,
@@ -188,7 +189,11 @@ export function KnowledgeSearchResults({
)
}
if (isPending || (isFetching && !results)) {
- return
Searching…
+ return (
+
+ Searching…
+
+ )
}
const indexingNote =
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 dd7abace0d6..18b3e767a9a 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
@@ -14,6 +14,7 @@ import { ShimmerText } from '@/components/ui'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown'
+import { getVisibleMainAgentItems } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import {
getAgentIcon,
@@ -47,11 +48,9 @@ export interface AgentGroupProps {
items: AgentGroupItem[]
isDelegating?: boolean
isStreaming?: boolean
- /** This group is the latest section in its parent sequence (drives collapse). */
- isCurrentSection?: boolean
/** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */
isLaneOpen?: boolean
- /** Opens the group on first render without changing production's automatic collapse rules. */
+ /** Opens a subagent group on first render. */
defaultExpanded?: boolean
/** Keeps the activity viewport anchored at the top while new rows stream in. */
autoScrollActivity?: boolean
@@ -151,7 +150,6 @@ export function AgentGroupView({
items,
isDelegating = false,
isStreaming = false,
- isCurrentSection = false,
isLaneOpen = false,
defaultExpanded = false,
autoScrollActivity = true,
@@ -192,16 +190,7 @@ export function AgentGroupView({
const isWorking =
!activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen))
- // SUBAGENT groups never auto-expand: the collapsed row IS the live view —
- // label plus latest running tool title. Expanding is a deliberate user
- // action; only a pending permission prompt or a browser hand-back forces
- // one open. The MAIN lane ("Sim") is not a delegation card: its narration
- // and tool calls are the turn itself, so it keeps the original live-expand
- // behavior (open while streaming/current, settles when superseded).
- const autoExpanded = isMainAgent && isStreaming && (isCurrentSection || isLaneOpen || !resolved)
- const [manualExpanded, setManualExpanded] = useState(
- defaultExpanded ? true : null
- )
+ const [manualExpanded, setManualExpanded] = useState(defaultExpanded)
const [expandedTakeoverId, setExpandedTakeoverId] = useState(null)
// An outstanding permission prompt overrides a manual collapse: the turn
// cannot proceed until it is answered, so hiding it would deadlock the chat
@@ -209,9 +198,7 @@ export function AgentGroupView({
const expanded =
hasAwaitingApproval(items) ||
nestedBrowserTakeover ||
- (activeBrowserTakeover
- ? expandedTakeoverId === activeBrowserTakeover.id
- : (manualExpanded ?? autoExpanded))
+ (activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded)
const toggleExpanded = () => {
if (activeBrowserTakeover) {
@@ -221,9 +208,54 @@ export function AgentGroupView({
setManualExpanded(!expanded)
}
+ const visibleItems = isMainAgent ? getVisibleMainAgentItems(items) : items
+ const activity = (
+
+ {visibleItems.map((item, idx) => {
+ if (item.type === 'tool') {
+ return (
+
+ )
+ }
+ if (item.type === 'agent_group') {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ })}
+
+ )
+
return (
- {hasItems ? (
+ {isMainAgent ? null : hasItems ? (
)}
- {hasItems && (
+ {isMainAgent ? (
+ activity
+ ) : hasItems ? (
-
- {items.map((item, idx) => {
- if (item.type === 'tool') {
- return (
-
- )
- }
- if (item.type === 'agent_group') {
- return (
-
- )
- }
- return (
-
- )
- })}
-
+ {activity}
- )}
+ ) : null}
{activeBrowserTakeover && (
{renderBrowserTakeover?.(activeBrowserTakeover.reason)}
@@ -334,7 +326,7 @@ function NarrationText({ content, isStreaming }: NarrationTextProps) {
const revealed = useSmoothText(content, isStreaming)
return (
-
+
{renderInlineMarkdown(revealed.trim())}
)
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 2057028ff17..ee1df0a2380 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
@@ -2,22 +2,29 @@
* @vitest-environment jsdom
*/
import { act, createElement } from 'react'
-import { createRoot } from 'react-dom/client'
-import { describe, expect, it, vi } from 'vitest'
+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 type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
-import { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
-import type { ToolCallData, ToolCallStatus } from '../../../../types'
+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'
vi.mock('@/lib/browser-agent/transport', () => ({
isBrowserAgentAvailable: () => true,
}))
-vi.mock('../special-tags', () => ({
- CredentialDisplay: ({ data }: { data: Array<{ name?: string }> }) => data[0]?.name ?? '',
- BrowserTakeoverQuestion: ({ reason, answer }: { reason?: string; answer?: string }) =>
- createElement('div', { 'data-takeover-answer': 'true' }, `${reason}: ${answer}`),
-}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags',
+ () => ({
+ CredentialDisplay: ({ data }: { data: Array<{ name?: string }> }) => data[0]?.name ?? '',
+ BrowserTakeoverQuestion: ({ reason, answer }: { reason?: string; answer?: string }) =>
+ createElement('div', { 'data-takeover-answer': 'true' }, `${reason}: ${answer}`),
+ })
+)
let toolSeq = 0
@@ -97,6 +104,164 @@ describe('isAgentGroupResolved', () => {
})
})
+describe('AgentGroup inline main activity', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ root = createRoot(container)
+ })
+
+ afterEach(() => act(() => root.unmount()))
+
+ it('replaces the previous tool in place and settles without restoring the nested log', () => {
+ const first: AgentGroupItem = {
+ type: 'tool',
+ data: { id: 'first', toolName: 'grep', displayTitle: 'Searching files', status: 'executing' },
+ }
+ const next: AgentGroupItem = {
+ type: 'tool',
+ data: { id: 'next', toolName: 'read', displayTitle: 'Reading notes', status: 'executing' },
+ }
+ const render = (items: AgentGroupItem[], isStreaming = true) => {
+ act(() => {
+ root.render(
+ createElement(AgentGroup, {
+ agentName: 'mothership',
+ agentLabel: 'Sim',
+ items,
+ isStreaming,
+ })
+ )
+ })
+ }
+
+ render([first])
+ expect(container.textContent).toBe('Searching files')
+ const activity = container.firstElementChild
+
+ render([first, next])
+ expect(container.firstElementChild).toBe(activity)
+ expect(container.textContent).toBe('Reading notes')
+ expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
+ expect(container.querySelector('button, svg, [data-state], .pl-6')).toBeNull()
+
+ render(
+ [
+ { ...first, data: { ...first.data, status: 'success' } },
+ { ...next, data: { ...next.data, status: 'success' } },
+ ],
+ false
+ )
+ expect(container.textContent).toBe('Read notes')
+ expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ expect(container.querySelector('button, svg, [data-state], .pl-6')).toBeNull()
+ })
+
+ it('keeps a browser question and answer after the main agent resumes tool activity', () => {
+ const takeover = browserTakeover('Choose a result.')
+ const items: AgentGroupItem[] = [
+ {
+ ...takeover,
+ data: {
+ ...takeover.data,
+ status: 'success',
+ result: { success: true, output: { userInstruction: 'Open the second result.' } },
+ },
+ },
+ {
+ type: 'tool',
+ data: {
+ id: 'resumed',
+ toolName: 'grep',
+ displayTitle: 'Searching files',
+ status: 'success',
+ },
+ },
+ ]
+
+ act(() => {
+ root.render(
+ createElement(AgentGroup, {
+ agentName: 'mothership',
+ agentLabel: 'Sim',
+ items,
+ isStreaming: false,
+ })
+ )
+ })
+
+ expect(container.querySelector('[data-takeover-answer="true"]')?.textContent).toBe(
+ 'Choose a result.: Open the second result.'
+ )
+ expect(container.textContent).toContain('Searched files')
+ expect(container.querySelector('button, svg, [data-state], .pl-6')).toBeNull()
+ })
+
+ it('keeps pending permissions and terminal handoffs visible when newer tools arrive', () => {
+ const items: AgentGroupItem[] = [
+ {
+ type: 'tool',
+ data: {
+ id: 'permission',
+ toolName: 'grep',
+ displayTitle: 'Allow search',
+ status: 'awaiting_approval',
+ },
+ },
+ {
+ type: 'tool',
+ data: {
+ id: 'handoff',
+ toolName: 'terminal',
+ displayTitle: 'Finish signing in',
+ status: 'executing',
+ params: { operation: 'handoff' },
+ },
+ },
+ {
+ type: 'tool',
+ data: {
+ id: 'previous',
+ toolName: 'grep',
+ displayTitle: 'Searching files',
+ status: 'success',
+ },
+ },
+ {
+ type: 'tool',
+ data: {
+ id: 'latest',
+ toolName: 'read',
+ displayTitle: 'Reading notes',
+ status: 'executing',
+ },
+ },
+ ]
+ act(() => {
+ root.render(
+ createElement(AgentGroupView, {
+ agentName: 'mothership',
+ agentLabel: 'Sim',
+ items,
+ isStreaming: true,
+ ToolCallComponent: ({ toolCallId, displayTitle }: ToolCallItemProps) =>
+ createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle),
+ })
+ )
+ })
+
+ expect(
+ Array.from(container.querySelectorAll('[data-tool-call-id]'), (row) =>
+ row.getAttribute('data-tool-call-id')
+ )
+ ).toEqual(['permission', 'handoff', 'latest'])
+ expect(container.querySelector('[data-state], .pl-6')).toBeNull()
+ })
+})
+
describe('AgentGroup browser takeover', () => {
it('collapses the browser log and renders the question outside its viewport', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
@@ -111,7 +276,6 @@ describe('AgentGroup browser takeover', () => {
agentLabel: 'Browser Agent',
items: [tool('success'), browserTakeover(reason)],
isStreaming: true,
- isCurrentSection: true,
isLaneOpen: true,
})
)
@@ -185,7 +349,6 @@ describe('AgentGroup browser takeover', () => {
agentLabel: 'Browser Agent',
items: [takeover],
isStreaming: true,
- isCurrentSection: true,
isLaneOpen: true,
})
)
@@ -207,7 +370,6 @@ describe('AgentGroup browser takeover', () => {
agentLabel: 'Browser Agent',
items: [completedTakeover],
isStreaming: true,
- isCurrentSection: true,
isLaneOpen: true,
})
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.ts
new file mode 100644
index 00000000000..f8514d9c8a5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.ts
@@ -0,0 +1,29 @@
+import { Terminal as TerminalTool } from '@/lib/copilot/generated/tool-catalog-v1'
+import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
+import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
+import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
+
+export function getLatestToolId(items: AgentGroupItem[]): string | undefined {
+ for (let index = items.length - 1; index >= 0; index--) {
+ const item = items[index]
+ if (item.type === 'tool') return item.data.id
+ }
+}
+
+/** Keep interaction controls and answers when a newer tool replaces the activity text. */
+export function getVisibleMainAgentItems(
+ items: AgentGroupItem[],
+ latestToolId = getLatestToolId(items)
+): AgentGroupItem[] {
+ return items.filter(
+ (item) =>
+ item.type !== 'tool' ||
+ item.data.id === latestToolId ||
+ item.data.status === ToolCallStatus.awaiting_approval ||
+ (item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID &&
+ item.data.status === ToolCallStatus.success) ||
+ (item.data.status === ToolCallStatus.executing &&
+ item.data.toolName === TerminalTool.id &&
+ item.data.params?.operation === 'handoff')
+ )
+}
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 27ac5efbbaf..3ad666d4152 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,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { isPlainRecord } from '@sim/utils/object'
+import { ActivityStatus } from '@/components/ui/activity-status'
import {
CallIntegrationTool,
PrepareFileEdit,
@@ -11,14 +12,16 @@ import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display'
-import { ToolCallRow } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-row'
+import { ToolPermissionCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card'
+import {
+ BrowserTakeoverQuestion,
+ CredentialDisplay,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { resolveToolDisplayState } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
+import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
import { BrandIcon } from '@/blocks/brand-icon'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getBlockByToolName } from '@/blocks/registry'
-import type { ToolCallData, ToolCallStatus } from '../../../../types'
-import { resolveToolDisplayState } from '../../utils'
-import { BrowserTakeoverQuestion, CredentialDisplay } from '../special-tags'
-import { ToolPermissionCard } from './tool-permission-card'
export function CircleStop({ className }: { className?: string }) {
return (
@@ -101,7 +104,7 @@ function useElapsedMs(active: boolean, startedAt: number | undefined): number {
}
/**
- * A single tool-call row inside an agent group: shimmer while executing, a
+ * Inline tool activity: shimmer while executing, a
* static label once terminal. For `workspace_file` the title is derived live
* from the streaming args; because that path bypasses the completed-title
* rewrite in `toToolData`, the past-tense flip is applied here on success.
@@ -193,18 +196,14 @@ export function ToolCallItem({
const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon
- // A gated row is replaced outright by its permission card, the same way an
- // executing browser takeover swaps itself for the takeover chip.
if (displayState === 'awaiting_approval' && toolCallId) {
return (
-
-
-
+
)
}
@@ -212,35 +211,31 @@ export function ToolCallItem({
if (isBrowserTakeover && status === 'success') {
return (
-
-
-
+
)
}
if (terminalHandoff) {
return (
-
-
-
+
)
}
return (
- }
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-row.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-row.tsx
deleted file mode 100644
index fdb4af0026d..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-row.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { ReactNode } from 'react'
-import { ShimmerText } from '@/components/ui'
-
-interface ToolCallRowProps {
- title: string
- isExecuting: boolean
- icon?: ReactNode
-}
-
-/** Shared activity chrome; callers resolve tool semantics and brand icons. */
-export function ToolCallRow({ title, isExecuting, icon }: ToolCallRowProps) {
- return (
-
- {icon}
- {isExecuting ? (
-
- {title}
-
- ) : (
-
- {title}
-
- )}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
index 76b2976c67c..12c57a11c7e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
@@ -335,7 +335,7 @@ describe('parseBlocks span-identity tree', () => {
expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true)
})
- it('interleaves mothership tools with main text instead of clustering them at the top', () => {
+ it('replaces earlier main activity across prose and subagents while preserving stream order', () => {
const blocks: ContentBlock[] = [
mainText('Let me search.'),
mainToolCall('t1', 'grep'),
@@ -348,25 +348,19 @@ describe('parseBlocks span-identity tree', () => {
const segments = parseBlocks(blocks)
- // Order is preserved chronologically: the second mothership tool stays below
- // the research subagent and the trailing text rather than jumping back up
- // into the first group.
const shape = segments.map((s) => (s.type === 'agent_group' ? s.agentName : s.type))
- expect(shape).toEqual(['text', 'mothership', 'research', 'text', 'mothership'])
+ expect(shape).toEqual(['text', 'research', 'text', 'mothership'])
- // The two mothership tools land in two distinct groups, one each.
const mothershipGroups = segments.filter(
(s) => s.type === 'agent_group' && s.agentName === 'mothership'
)
- expect(mothershipGroups).toHaveLength(2)
- const [first, second] = mothershipGroups
- if (first.type !== 'agent_group' || second.type !== 'agent_group') {
- throw new Error('expected mothership groups')
+ expect(mothershipGroups).toHaveLength(1)
+ const [latest] = mothershipGroups
+ if (latest.type !== 'agent_group') {
+ throw new Error('expected mothership group')
}
- expect(first.items).toHaveLength(1)
- expect(second.items).toHaveLength(1)
- expect(first.items[0].type === 'tool' && first.items[0].data.toolName).toBe('grep')
- expect(second.items[0].type === 'tool' && second.items[0].data.toolName).toBe('glob')
+ expect(latest.items).toHaveLength(1)
+ expect(latest.items[0].type === 'tool' && latest.items[0].data.id).toBe('t2')
})
it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => {
@@ -721,7 +715,7 @@ describe('narration text seams', () => {
})
describe('parseBlocks legacy — thinking between top-level tools', () => {
- it('keeps consecutive mothership tools in one group across intervening thinking', () => {
+ it('shows only the latest main tool across intervening thinking', () => {
const blocks: ContentBlock[] = [
{ type: 'thinking', content: 'planning the search', timestamp: 1 },
mainToolCall('t1', 'grep'),
@@ -734,10 +728,11 @@ describe('parseBlocks legacy — thinking between top-level tools', () => {
expect(groups).toHaveLength(1)
if (groups[0].type !== 'agent_group') throw new Error('expected group')
expect(groups[0].agentName).toBe('mothership')
- expect(groups[0].items).toHaveLength(3)
+ expect(groups[0].items).toHaveLength(1)
+ expect(groups[0].items[0].type === 'tool' && groups[0].items[0].data.id).toBe('t3')
})
- it('still splits the mothership run on real main text', () => {
+ it('replaces earlier main tools across prose without leaving an empty activity segment', () => {
const blocks: ContentBlock[] = [
mainToolCall('t1', 'grep'),
mainText('Here is what I found so far.'),
@@ -745,7 +740,8 @@ describe('parseBlocks legacy — thinking between top-level tools', () => {
]
const segments = parseBlocks(blocks)
const groups = segments.filter((s) => s.type === 'agent_group')
- expect(groups).toHaveLength(2)
+ expect(groups).toHaveLength(1)
+ expect(segments.map((segment) => segment.type)).toEqual(['text', 'agent_group'])
})
it('does not let main thinking affect subagent lane grouping', () => {
@@ -787,10 +783,28 @@ describe('parseBlocks legacy — thinking between top-level tools', () => {
})
describe('assistantMessageHasVisibleExecutingTool', () => {
+ it.each([undefined, 'main'])('ignores a replaced running tool with spanId=%s', (spanId) => {
+ const blocks: ContentBlock[] = [
+ {
+ type: 'tool_call',
+ toolCall: { id: 'older', name: 'grep', status: 'executing' },
+ spanId,
+ timestamp: 1,
+ },
+ mainText('Reading the result.'),
+ mainToolCall('latest', 'read'),
+ ]
+ const segments = parseBlocks(blocks)
+ expect(segments.map((segment) => segment.type)).toEqual(['text', 'agent_group'])
+ expect(assistantMessageHasVisibleExecutingTool(segments)).toBe(false)
+ })
+
it('does not treat an open subagent lane as an executing tool row', () => {
- expect(assistantMessageHasVisibleExecutingTool([subagentStart('workflow', 'S1', 'main')])).toBe(
- false
- )
+ expect(
+ assistantMessageHasVisibleExecutingTool(
+ parseBlocks([subagentStart('workflow', 'S1', 'main')])
+ )
+ ).toBe(false)
})
it('keeps a visible executing tool as active work', () => {
@@ -803,7 +817,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => {
timestamp: 3,
},
]
- expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(true)
+ expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(true)
})
it('does not let open parallel lanes suppress the single turn-level indicator', () => {
@@ -811,7 +825,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => {
subagentStart('workflow', 'S1', 'main'),
subagentStart('search', 'S2', 'main'),
]
- expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false)
+ expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false)
})
it('ignores the executing dispatch tool represented by its subagent lane', () => {
@@ -826,10 +840,81 @@ describe('assistantMessageHasVisibleExecutingTool', () => {
parentToolCallId: 'dispatch-1',
},
]
- expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false)
+ expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false)
})
})
+describe('parseBlocks main activity controls', () => {
+ it.each([undefined, 'main'])(
+ 'retains interaction controls and answers across prose and completion with spanId=%s',
+ (spanId) => {
+ const blocks: ContentBlock[] = [
+ {
+ type: 'tool_call',
+ toolCall: { id: 'permission', name: 'read', status: 'awaiting_approval' },
+ spanId,
+ timestamp: 1,
+ },
+ mainText('A permission decision is pending.'),
+ {
+ type: 'tool_call',
+ toolCall: {
+ id: 'handoff',
+ name: 'terminal',
+ status: 'executing',
+ params: { operation: 'handoff' },
+ },
+ timestamp: 2,
+ },
+ {
+ type: 'tool_call',
+ toolCall: {
+ id: 'answered-takeover',
+ name: 'browser_request_takeover',
+ status: 'success',
+ params: { reason: 'Choose a result.' },
+ result: { success: true, output: { userInstruction: 'Open the second result.' } },
+ },
+ timestamp: 2,
+ },
+ mainToolCall('older', 'grep'),
+ mainText('Checking another source.'),
+ {
+ type: 'tool_call',
+ toolCall: { id: 'latest', name: 'read', status: 'executing' },
+ timestamp: 3,
+ },
+ ]
+
+ const visibleTools = (content: ContentBlock[]) =>
+ parseBlocks(content).flatMap((segment) =>
+ segment.type === 'agent_group'
+ ? segment.items.flatMap((item) => (item.type === 'tool' ? [item.data] : []))
+ : []
+ )
+
+ expect(visibleTools(blocks).map((tool) => tool.id)).toEqual([
+ 'permission',
+ 'handoff',
+ 'answered-takeover',
+ 'latest',
+ ])
+ const completed = blocks.map((block) =>
+ block.toolCall?.id === 'latest'
+ ? { ...block, toolCall: { ...block.toolCall, status: 'success' as const } }
+ : block
+ )
+ expect(visibleTools(completed).map((tool) => tool.id)).toEqual([
+ 'permission',
+ 'handoff',
+ 'answered-takeover',
+ 'latest',
+ ])
+ expect(visibleTools(completed).at(-1)?.status).toBe('success')
+ }
+ )
+})
+
describe('deriveThinkingLabel', () => {
it('maps the most recent block to an activity phrase', () => {
expect(deriveThinkingLabel([])).toBe('Thinking…')
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index 7828f6b187d..76495ca4bb9 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
@@ -21,6 +21,10 @@ import {
humanizeToolName,
} from '@/lib/copilot/tools/tool-display'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
+import {
+ getLatestToolId,
+ getVisibleMainAgentItems,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
@@ -283,12 +287,10 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
return last?.type === 'agent_group' && last.agentName === 'mothership' ? last : null
}
- // Top-level (mothership) tool calls render in a collapsible group. Reuse that
- // group only while it is still the most recent segment so consecutive tools
- // stay together; once another visible segment (main text or a spawned
- // subagent) breaks the run, the next tool opens a fresh group below it
- // instead of jumping back up into the original one. This keeps the mothership's
- // tools and prose interleaved in the order they actually happened.
+ /**
+ * Reuse only the latest main activity segment so tools remain interleaved
+ * with prose and subagents in stream order.
+ */
const ensureMothership = (): AgentGroupSegment => {
const existing = tailMothershipGroup()
if (existing) return existing
@@ -492,7 +494,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
* Groups content blocks into agent-scoped segments.
* Dispatch tool_calls (name matches a subagent key, no calledBy) are absorbed
* into the agent header. Inner tool_calls are nested underneath their agent.
- * Orphan tool_calls (no calledBy, not a dispatch) group under "Sim".
+ * Main-agent tool calls share one latest activity status across all segments.
*
* New backends stamp every subagent block with deterministic span identity; in
* that case {@link parseBlocksWithSpanTree} builds a real nested tree. The
@@ -500,10 +502,22 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
* span identity existed.
*/
export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
- if (blocks.some((block) => Boolean(block.spanId))) {
- return parseBlocksWithSpanTree(blocks)
+ const segments = blocks.some((block) => Boolean(block.spanId))
+ ? parseBlocksWithSpanTree(blocks)
+ : parseBlocksLegacy(blocks)
+ let latestToolId: string | undefined
+ for (let index = segments.length - 1; index >= 0; index--) {
+ const segment = segments[index]
+ if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') continue
+ latestToolId = getLatestToolId(segment.items)
+ if (latestToolId !== undefined) break
}
- return parseBlocksLegacy(blocks)
+
+ return segments.flatMap((segment) => {
+ if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') return [segment]
+ const items = getVisibleMainAgentItems(segment.items, latestToolId)
+ return items.length > 0 ? [{ ...segment, items }] : []
+ })
}
function joinRenderableText(parts: string[]): string {
@@ -762,22 +776,17 @@ export function assistantMessageHasRenderableContent(
}
/** True when the transcript is already rendering an executing tool row. */
-export function assistantMessageHasVisibleExecutingTool(blocks: ContentBlock[]): boolean {
- const subagentDispatchCallIds = new Set()
- for (const block of blocks) {
- if (block.type === 'subagent' && block.parentToolCallId) {
- subagentDispatchCallIds.add(block.parentToolCallId)
- }
- }
-
- return blocks.some((block) => {
- const toolCall = block.toolCall
- if (!toolCall || toolCall.status !== 'executing') return false
- if (isHiddenToolCall(toolCall.name)) return false
- if (toolCall.name === ReadTool.id && isToolResultRead(toolCall.params)) return false
- if (SUBAGENT_KEYS.has(toolCall.name)) return false
- return !subagentDispatchCallIds.has(toolCall.id)
- })
+export function assistantMessageHasVisibleExecutingTool(segments: MessageSegment[]): boolean {
+ const hasExecutingTool = (items: AgentGroupItem[]): boolean =>
+ items.some((item) =>
+ item.type === 'tool'
+ ? item.data.status === 'executing'
+ : item.type === 'agent_group' && hasExecutingTool(item.group.items)
+ )
+
+ return segments.some(
+ (segment) => segment.type === 'agent_group' && hasExecutingTool(segment.items)
+ )
}
export function shouldSmoothTextSegment({
@@ -962,7 +971,7 @@ function MessageContentInner({
// A mid-stream special tag renders nothing until complete, so its bytes are a
// wait, not output — the shimmer bridges it without the quiet-period delay.
const thinkingLabel = deriveThinkingLabel(blocks)
- const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks)
+ const hasExecutingTool = assistantMessageHasVisibleExecutingTool(segments)
const showShimmer =
thinkingExpanded &&
thinkingLabel !== null &&
@@ -1024,7 +1033,6 @@ function MessageContentInner({
items={segment.items}
isDelegating={segment.isDelegating}
isStreaming={isStreaming}
- isCurrentSection={i === segments.length - 1}
isLaneOpen={segment.isOpen}
/>
diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx
new file mode 100644
index 00000000000..e6e66c12d0e
--- /dev/null
+++ b/apps/sim/components/ui/activity-status.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from 'react'
+import { ShimmerText } from '@/components/ui/shimmer-text'
+
+interface ActivityStatusProps {
+ label: string
+ isActive: boolean
+ icon?: ReactNode
+}
+
+/** Inline tool status with the shared shimmer while active. */
+export function ActivityStatus({ label, isActive, icon }: ActivityStatusProps) {
+ return (
+
+ {icon}
+ {isActive ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts
index 234f6f50a60..58003a616a4 100644
--- a/apps/sim/components/ui/index.ts
+++ b/apps/sim/components/ui/index.ts
@@ -1,3 +1,4 @@
+export { ActivityStatus } from '@/components/ui/activity-status'
export { Button, buttonVariants } from './button'
export { GeneratedPasswordInput } from './generated-password-input'
export { Progress } from './progress'