Skip to content

Commit eae212b

Browse files
committed
fix(chat): replace activity across message segments
1 parent 2421e33 commit eae212b

4 files changed

Lines changed: 156 additions & 67 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx‎

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn'
1313
import { ShimmerText } from '@/components/ui'
1414
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
15-
import { Terminal as TerminalTool } from '@/lib/copilot/generated/tool-catalog-v1'
1615
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
1716
import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown'
17+
import { getVisibleMainAgentItems } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
1818
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
1919
import {
2020
getAgentIcon,
@@ -208,27 +208,7 @@ export function AgentGroupView({
208208
setManualExpanded(!expanded)
209209
}
210210

211-
let latestTool: AgentGroupItem | undefined
212-
if (isMainAgent) {
213-
for (let index = items.length - 1; index >= 0; index--) {
214-
if (items[index].type === 'tool') {
215-
latestTool = items[index]
216-
break
217-
}
218-
}
219-
}
220-
/** Keep blocking controls visible even when a newer tool replaces the activity text. */
221-
const visibleItems = isMainAgent
222-
? items.filter(
223-
(item) =>
224-
item.type !== 'tool' ||
225-
item === latestTool ||
226-
item.data.status === ToolCallStatus.awaiting_approval ||
227-
(item.data.status === ToolCallStatus.executing &&
228-
item.data.toolName === TerminalTool.id &&
229-
item.data.params?.operation === 'handoff')
230-
)
231-
: items
211+
const visibleItems = isMainAgent ? getVisibleMainAgentItems(items) : items
232212
const activity = (
233213
<div className={cn('flex min-w-0 flex-col gap-1.5', !isMainAgent && 'py-0.5 pl-6')}>
234214
{visibleItems.map((item, idx) => {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Terminal as TerminalTool } from '@/lib/copilot/generated/tool-catalog-v1'
2+
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
3+
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
4+
5+
export function getLatestToolId(items: AgentGroupItem[]): string | undefined {
6+
for (let index = items.length - 1; index >= 0; index--) {
7+
const item = items[index]
8+
if (item.type === 'tool') return item.data.id
9+
}
10+
}
11+
12+
/** Keep blocking controls visible even when a newer tool replaces the activity text. */
13+
export function getVisibleMainAgentItems(
14+
items: AgentGroupItem[],
15+
latestToolId = getLatestToolId(items)
16+
): AgentGroupItem[] {
17+
return items.filter(
18+
(item) =>
19+
item.type !== 'tool' ||
20+
item.data.id === latestToolId ||
21+
item.data.status === ToolCallStatus.awaiting_approval ||
22+
(item.data.status === ToolCallStatus.executing &&
23+
item.data.toolName === TerminalTool.id &&
24+
item.data.params?.operation === 'handoff')
25+
)
26+
}

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts‎

Lines changed: 96 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ describe('parseBlocks span-identity tree', () => {
335335
expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true)
336336
})
337337

338-
it('interleaves mothership tools with main text instead of clustering them at the top', () => {
338+
it('replaces earlier main activity across prose and subagents while preserving stream order', () => {
339339
const blocks: ContentBlock[] = [
340340
mainText('Let me search.'),
341341
mainToolCall('t1', 'grep'),
@@ -348,25 +348,19 @@ describe('parseBlocks span-identity tree', () => {
348348

349349
const segments = parseBlocks(blocks)
350350

351-
// Order is preserved chronologically: the second mothership tool stays below
352-
// the research subagent and the trailing text rather than jumping back up
353-
// into the first group.
354351
const shape = segments.map((s) => (s.type === 'agent_group' ? s.agentName : s.type))
355-
expect(shape).toEqual(['text', 'mothership', 'research', 'text', 'mothership'])
352+
expect(shape).toEqual(['text', 'research', 'text', 'mothership'])
356353

357-
// The two mothership tools land in two distinct groups, one each.
358354
const mothershipGroups = segments.filter(
359355
(s) => s.type === 'agent_group' && s.agentName === 'mothership'
360356
)
361-
expect(mothershipGroups).toHaveLength(2)
362-
const [first, second] = mothershipGroups
363-
if (first.type !== 'agent_group' || second.type !== 'agent_group') {
364-
throw new Error('expected mothership groups')
357+
expect(mothershipGroups).toHaveLength(1)
358+
const [latest] = mothershipGroups
359+
if (latest.type !== 'agent_group') {
360+
throw new Error('expected mothership group')
365361
}
366-
expect(first.items).toHaveLength(1)
367-
expect(second.items).toHaveLength(1)
368-
expect(first.items[0].type === 'tool' && first.items[0].data.toolName).toBe('grep')
369-
expect(second.items[0].type === 'tool' && second.items[0].data.toolName).toBe('glob')
362+
expect(latest.items).toHaveLength(1)
363+
expect(latest.items[0].type === 'tool' && latest.items[0].data.id).toBe('t2')
370364
})
371365

372366
it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => {
@@ -721,7 +715,7 @@ describe('narration text seams', () => {
721715
})
722716

723717
describe('parseBlocks legacy — thinking between top-level tools', () => {
724-
it('keeps consecutive mothership tools in one group across intervening thinking', () => {
718+
it('shows only the latest main tool across intervening thinking', () => {
725719
const blocks: ContentBlock[] = [
726720
{ type: 'thinking', content: 'planning the search', timestamp: 1 },
727721
mainToolCall('t1', 'grep'),
@@ -734,18 +728,20 @@ describe('parseBlocks legacy — thinking between top-level tools', () => {
734728
expect(groups).toHaveLength(1)
735729
if (groups[0].type !== 'agent_group') throw new Error('expected group')
736730
expect(groups[0].agentName).toBe('mothership')
737-
expect(groups[0].items).toHaveLength(3)
731+
expect(groups[0].items).toHaveLength(1)
732+
expect(groups[0].items[0].type === 'tool' && groups[0].items[0].data.id).toBe('t3')
738733
})
739734

740-
it('still splits the mothership run on real main text', () => {
735+
it('replaces earlier main tools across prose without leaving an empty activity segment', () => {
741736
const blocks: ContentBlock[] = [
742737
mainToolCall('t1', 'grep'),
743738
mainText('Here is what I found so far.'),
744739
mainToolCall('t2', 'read'),
745740
]
746741
const segments = parseBlocks(blocks)
747742
const groups = segments.filter((s) => s.type === 'agent_group')
748-
expect(groups).toHaveLength(2)
743+
expect(groups).toHaveLength(1)
744+
expect(segments.map((segment) => segment.type)).toEqual(['text', 'agent_group'])
749745
})
750746

751747
it('does not let main thinking affect subagent lane grouping', () => {
@@ -787,10 +783,28 @@ describe('parseBlocks legacy — thinking between top-level tools', () => {
787783
})
788784

789785
describe('assistantMessageHasVisibleExecutingTool', () => {
786+
it.each([undefined, 'main'])('ignores a replaced running tool with spanId=%s', (spanId) => {
787+
const blocks: ContentBlock[] = [
788+
{
789+
type: 'tool_call',
790+
toolCall: { id: 'older', name: 'grep', status: 'executing' },
791+
spanId,
792+
timestamp: 1,
793+
},
794+
mainText('Reading the result.'),
795+
mainToolCall('latest', 'read'),
796+
]
797+
const segments = parseBlocks(blocks)
798+
expect(segments.map((segment) => segment.type)).toEqual(['text', 'agent_group'])
799+
expect(assistantMessageHasVisibleExecutingTool(segments)).toBe(false)
800+
})
801+
790802
it('does not treat an open subagent lane as an executing tool row', () => {
791-
expect(assistantMessageHasVisibleExecutingTool([subagentStart('workflow', 'S1', 'main')])).toBe(
792-
false
793-
)
803+
expect(
804+
assistantMessageHasVisibleExecutingTool(
805+
parseBlocks([subagentStart('workflow', 'S1', 'main')])
806+
)
807+
).toBe(false)
794808
})
795809

796810
it('keeps a visible executing tool as active work', () => {
@@ -803,15 +817,15 @@ describe('assistantMessageHasVisibleExecutingTool', () => {
803817
timestamp: 3,
804818
},
805819
]
806-
expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(true)
820+
expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(true)
807821
})
808822

809823
it('does not let open parallel lanes suppress the single turn-level indicator', () => {
810824
const blocks: ContentBlock[] = [
811825
subagentStart('workflow', 'S1', 'main'),
812826
subagentStart('search', 'S2', 'main'),
813827
]
814-
expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false)
828+
expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false)
815829
})
816830

817831
it('ignores the executing dispatch tool represented by its subagent lane', () => {
@@ -826,10 +840,68 @@ describe('assistantMessageHasVisibleExecutingTool', () => {
826840
parentToolCallId: 'dispatch-1',
827841
},
828842
]
829-
expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false)
843+
expect(assistantMessageHasVisibleExecutingTool(parseBlocks(blocks))).toBe(false)
830844
})
831845
})
832846

847+
describe('parseBlocks main activity controls', () => {
848+
it.each([undefined, 'main'])(
849+
'retains blocking controls across prose and completion with spanId=%s',
850+
(spanId) => {
851+
const blocks: ContentBlock[] = [
852+
{
853+
type: 'tool_call',
854+
toolCall: { id: 'permission', name: 'read', status: 'awaiting_approval' },
855+
spanId,
856+
timestamp: 1,
857+
},
858+
mainText('A permission decision is pending.'),
859+
{
860+
type: 'tool_call',
861+
toolCall: {
862+
id: 'handoff',
863+
name: 'terminal',
864+
status: 'executing',
865+
params: { operation: 'handoff' },
866+
},
867+
timestamp: 2,
868+
},
869+
mainToolCall('older', 'grep'),
870+
mainText('Checking another source.'),
871+
{
872+
type: 'tool_call',
873+
toolCall: { id: 'latest', name: 'read', status: 'executing' },
874+
timestamp: 3,
875+
},
876+
]
877+
878+
const visibleTools = (content: ContentBlock[]) =>
879+
parseBlocks(content).flatMap((segment) =>
880+
segment.type === 'agent_group'
881+
? segment.items.flatMap((item) => (item.type === 'tool' ? [item.data] : []))
882+
: []
883+
)
884+
885+
expect(visibleTools(blocks).map((tool) => tool.id)).toEqual([
886+
'permission',
887+
'handoff',
888+
'latest',
889+
])
890+
const completed = blocks.map((block) =>
891+
block.toolCall?.id === 'latest'
892+
? { ...block, toolCall: { ...block.toolCall, status: 'success' as const } }
893+
: block
894+
)
895+
expect(visibleTools(completed).map((tool) => tool.id)).toEqual([
896+
'permission',
897+
'handoff',
898+
'latest',
899+
])
900+
expect(visibleTools(completed).at(-1)?.status).toBe('success')
901+
}
902+
)
903+
})
904+
833905
describe('deriveThinkingLabel', () => {
834906
it('maps the most recent block to an activity phrase', () => {
835907
expect(deriveThinkingLabel([])).toBe('Thinking…')

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx‎

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import {
2121
humanizeToolName,
2222
} from '@/lib/copilot/tools/tool-display'
2323
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
24+
import {
25+
getLatestToolId,
26+
getVisibleMainAgentItems,
27+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
2428
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
2529
import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
2630
import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
@@ -490,18 +494,30 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
490494
* Groups content blocks into agent-scoped segments.
491495
* Dispatch tool_calls (name matches a subagent key, no calledBy) are absorbed
492496
* into the agent header. Inner tool_calls are nested underneath their agent.
493-
* Orphan tool_calls (no calledBy, not a dispatch) group under "Sim".
497+
* Main-agent tool calls share one latest activity status across all segments.
494498
*
495499
* New backends stamp every subagent block with deterministic span identity; in
496500
* that case {@link parseBlocksWithSpanTree} builds a real nested tree. The
497501
* legacy flat heuristics below are retained for transcripts persisted before
498502
* span identity existed.
499503
*/
500504
export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
501-
if (blocks.some((block) => Boolean(block.spanId))) {
502-
return parseBlocksWithSpanTree(blocks)
505+
const segments = blocks.some((block) => Boolean(block.spanId))
506+
? parseBlocksWithSpanTree(blocks)
507+
: parseBlocksLegacy(blocks)
508+
let latestToolId: string | undefined
509+
for (let index = segments.length - 1; index >= 0; index--) {
510+
const segment = segments[index]
511+
if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') continue
512+
latestToolId = getLatestToolId(segment.items)
513+
if (latestToolId !== undefined) break
503514
}
504-
return parseBlocksLegacy(blocks)
515+
516+
return segments.flatMap<MessageSegment>((segment) => {
517+
if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') return [segment]
518+
const items = getVisibleMainAgentItems(segment.items, latestToolId)
519+
return items.length > 0 ? [{ ...segment, items }] : []
520+
})
505521
}
506522

507523
function joinRenderableText(parts: string[]): string {
@@ -760,22 +776,17 @@ export function assistantMessageHasRenderableContent(
760776
}
761777

762778
/** True when the transcript is already rendering an executing tool row. */
763-
export function assistantMessageHasVisibleExecutingTool(blocks: ContentBlock[]): boolean {
764-
const subagentDispatchCallIds = new Set<string>()
765-
for (const block of blocks) {
766-
if (block.type === 'subagent' && block.parentToolCallId) {
767-
subagentDispatchCallIds.add(block.parentToolCallId)
768-
}
769-
}
770-
771-
return blocks.some((block) => {
772-
const toolCall = block.toolCall
773-
if (!toolCall || toolCall.status !== 'executing') return false
774-
if (isHiddenToolCall(toolCall.name)) return false
775-
if (toolCall.name === ReadTool.id && isToolResultRead(toolCall.params)) return false
776-
if (SUBAGENT_KEYS.has(toolCall.name)) return false
777-
return !subagentDispatchCallIds.has(toolCall.id)
778-
})
779+
export function assistantMessageHasVisibleExecutingTool(segments: MessageSegment[]): boolean {
780+
const hasExecutingTool = (items: AgentGroupItem[]): boolean =>
781+
items.some((item) =>
782+
item.type === 'tool'
783+
? item.data.status === 'executing'
784+
: item.type === 'agent_group' && hasExecutingTool(item.group.items)
785+
)
786+
787+
return segments.some(
788+
(segment) => segment.type === 'agent_group' && hasExecutingTool(segment.items)
789+
)
779790
}
780791

781792
export function shouldSmoothTextSegment({
@@ -960,7 +971,7 @@ function MessageContentInner({
960971
// A mid-stream special tag renders nothing until complete, so its bytes are a
961972
// wait, not output — the shimmer bridges it without the quiet-period delay.
962973
const thinkingLabel = deriveThinkingLabel(blocks)
963-
const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks)
974+
const hasExecutingTool = assistantMessageHasVisibleExecutingTool(segments)
964975
const showShimmer =
965976
thinkingExpanded &&
966977
thinkingLabel !== null &&

0 commit comments

Comments
 (0)