Skip to content

Commit a6114e8

Browse files
committed
improvement(chat): restore stable inline tool activity headers and action summaries
Mothership v1.0.0 reintroduced the "Searching -> Searched -> Searching" flicker that staging had fixed: between two calls of an open main-agent activity, the header dropped to the past tense, lost its shimmer, and the turn-level Thinking row appeared until the next call started. It also removed the header icon and summarized headers as "<latest title> + N". Stable in-progress header - An open main-agent activity group (the streaming turn's tail, with nothing after its last call) stays in the present tense through the gaps between its calls, so the header no longer flips tense. Succeeded calls arrive with past-tense titles, so the open header maps them back through the exact inverse of the completed-verb rewrite; one helper (getToolInProgressTitle) serves main lanes, subagent lanes, and the landing hero. One live indicator per lane - A new pure module (lane-activity) owns the rule and states it in TSDoc. Each active lane has one live call: the newest running call anywhere in the lane (across all its runs, including runs split by a search or an interaction card, and its nested lanes). With none running and the lane open, the latest call of its trailing run owns the gap, and only if it succeeded. A finished search shows static results, so the gap after it belongs to Thinking. - Only the run holding that call shimmers: its group header, or its one search row. Tool rows never shimmer by themselves. A parent lane defers to a visible nested lane holding the live call only while that lane is still working; an ended nested lane hands its last call back to the parent's header. - Tense follows liveness: a header reads in progress only while it is live or its call still runs, so a group whose latest call failed or stopped reads as finished instead of keeping a stale present tense. - The turn computes this once; every lane view receives its share, and the Thinking row reads the same result. The main lane spans all main-agent segments. Thinking shows only when no lane is live and no lane waits on the user. A lane waiting on an approval, terminal handoff, or browser takeover shows no indicator, while other lanes doing real work keep theirs. When the stream finishes, nothing shimmers. - Rendering and liveness share one run split, and failed subagent lanes no longer count as working. Only the tail group is open, so a later pending interaction no longer re-opens earlier finished groups. Header icons - Group headers and single inline rows render the icon of the call they describe again, including integration brand icons, and keep it through completion. Main and subagent headers share one title rule: "Working…" while a sim_cli or run_code call generates its arguments, a live wait countdown, and streaming file-edit titles. Summaries without "+N" - Running headers show only the active call. Main and subagent lanes share one completed-label rule: the model's completedTitle only when every call succeeded, and otherwise a fallback naming up to three distinct successful actions ("Navigated, read pages, clicked elements"), picked by first occurrence in transcript order. Failed calls are left out, and the " · N stopped/skipped" outcome suffix stays. - A rebuilt action catalog covers the current tool surface: every visible catalog tool and operation, plus sim_cli commands. Each command's verb comes from its own display title, and the object comes from its resource path, so commands on one resource share a word ("Listed, read tables"). `mkdir` commands read as creating folders and `tables upsert` as writing table rows. Tools outside the catalog use their own completed title. Separate finished activity groups - groupByActivity still splits main-agent calls by activity id, but no longer merges finished groups into one header, so the completedGroupCount plumbing is removed, along with the unused getAgentIcon fallback parameter. Flat expanded layout - Indentation now only marks nested work. Expanded tool rows in main and subagent groups line up with their header: the icon in the shared 14px icon column, the text one gap-2 to its right. Nested subagent lanes, narration, lane errors, and the permission card's rows sit on that same text column through one shared primitive instead of pl-6 or pl-[22px]. - One spacing scale: gap-1.5 between rows inside a group (tool rows, search queries), gap-3 between blocks in the main lane (tool groups, search runs, lanes, interaction cards, prose). Inline links in answers - A link's favicon is centered on the text's own middle with align-middle, like inline citation chips, instead of a baseline pixel nudge. - The link tooltip names the page instead of showing its URL. It uses the turn's retrieved source for that exact URL, then the link preview's title, then the site name, with the site name muted beneath. One helper dedupes a turn's sources by URL everywhere they are listed or looked up. - Prose links show a thin muted underline on hover instead of a filled background, and keep the keyboard focus outline. Verb rewrites - The past-tense and present-tense verb tables are Maps, because titles include model-written text, and a description starting with "constructor" must never resolve to an inherited object property.
1 parent 2d0353a commit a6114e8

36 files changed

Lines changed: 3109 additions & 885 deletions

‎apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { Table } from '@sim/emcn/icons'
22
import { SlackIcon } from '@/components/icons'
33
import { ActivityStatus } from '@/components/ui/activity-status'
4-
import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display'
4+
import {
5+
getToolInProgressTitle,
6+
getToolStatusDisplayTitle,
7+
} from '@/lib/mothership/tools/tool-display'
58
import type {
69
ToolActivityPresentation,
710
ToolCallItemProps,
811
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
912
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
1013

11-
/** Demo fixtures have known brands, so the landing page never loads the block registry. */
14+
/**
15+
* Demo fixtures have known brands, so the landing page never loads the block registry.
16+
* Rows are history and never shimmer; the lane decides which header is live.
17+
*/
1218
export function HeroToolCallItem({
1319
toolCallId,
1420
renderStatus,
@@ -25,13 +31,8 @@ export function HeroToolCallItem({
2531
: getToolIcon(toolName)
2632
const activity: ToolActivityPresentation = {
2733
label: getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription),
28-
activeLabel: getToolStatusDisplayTitle(
29-
displayTitle,
30-
status === 'success' ? 'executing' : status,
31-
toolName,
32-
activityDescription
33-
),
34-
isActive: status === 'executing',
34+
activeLabel: getToolInProgressTitle(displayTitle, status, toolName, activityDescription),
35+
isActive: false,
3536
icon: <Icon className='size-full' />,
3637
}
3738
return renderStatus ? renderStatus(activity) : <ActivityStatus {...activity} />
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
8+
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
9+
import type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
10+
11+
vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: () => true }))
12+
13+
const ICON_SLOT = 'size-[14px]'
14+
15+
function tool(
16+
id: string,
17+
toolName = 'search_docs',
18+
status: ToolCallStatus = 'success'
19+
): AgentGroupItem {
20+
return { type: 'tool', data: { id, toolName, displayTitle: `Searching ${id}`, status } }
21+
}
22+
23+
function lane(id: string, items: AgentGroupItem[]): AgentGroupItem {
24+
return {
25+
type: 'agent_group',
26+
group: {
27+
id,
28+
agentName: 'deploy',
29+
agentLabel: 'Deploy',
30+
items,
31+
isDelegating: false,
32+
isOpen: false,
33+
},
34+
}
35+
}
36+
37+
/** Indentation classes a row or its containers must not carry. */
38+
const hasIndent = (element: Element) =>
39+
[...element.classList].some((name) => /^(pl|ml|ps|ms)-/.test(name))
40+
41+
describe('flat expanded activity layout', () => {
42+
let root: Root
43+
let container: HTMLDivElement
44+
45+
beforeEach(() => {
46+
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false }))
47+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
48+
container = document.createElement('div')
49+
document.body.appendChild(container)
50+
root = createRoot(container)
51+
})
52+
53+
afterEach(() => {
54+
act(() => root.unmount())
55+
container.remove()
56+
vi.unstubAllGlobals()
57+
})
58+
59+
const render = (agentName: string, items: AgentGroupItem[], error?: string) =>
60+
act(() =>
61+
root.render(
62+
<AgentGroup agentName={agentName} agentLabel='Agent' items={items} error={error} />
63+
)
64+
)
65+
const expand = () => act(() => container.querySelector<HTMLElement>('[role="button"]')!.click())
66+
const statuses = () => [...container.querySelectorAll<HTMLElement>('[role="status"]')]
67+
const iconSlot = (status: Element) => status.querySelector(':scope > [aria-hidden="true"]')!
68+
/** The reserved icon slot of a text-column wrapper. */
69+
const reservedSlot = (column: Element) => column.querySelector(':scope > [aria-hidden="true"]')
70+
71+
it.each(['mothership', 'workflow'])(
72+
'lines %s rows up with the header icon and text columns',
73+
(agentName) => {
74+
render(agentName, [tool('a'), tool('b', 'web_search')])
75+
expand()
76+
const [header, ...rows] = statuses()
77+
expect(rows.length).toBeGreaterThan(0)
78+
for (const row of [header, ...rows]) {
79+
expect(row.classList).toContain('gap-2')
80+
expect(iconSlot(row).classList).toContain(ICON_SLOT)
81+
}
82+
const list = rows[0].closest('.flex-col')!
83+
expect(list.classList).toContain('gap-1.5')
84+
let node: Element | null = rows[0]
85+
while (node && node !== container) {
86+
expect(hasIndent(node), node.className).toBe(false)
87+
node = node.parentElement
88+
}
89+
}
90+
)
91+
92+
it('keeps a nested lane indented into its parent text column', () => {
93+
render('workflow', [tool('a'), lane('deploy', [tool('child')])])
94+
expand()
95+
const nestedHeader = statuses().find((status) => status.textContent === 'Searched child')!
96+
const column = nestedHeader.closest('.items-start')!
97+
expect(column.classList).toContain('gap-2')
98+
expect(reservedSlot(column)?.classList).toContain(ICON_SLOT)
99+
expect(reservedSlot(column)?.childElementCount).toBe(0)
100+
})
101+
102+
it('puts a lane error on the shared text column instead of a hand-tuned inset', () => {
103+
render('workflow', [tool('a')], 'Subagent failed.')
104+
const error = [...container.querySelectorAll('p')].find(
105+
(node) => node.textContent === 'Subagent failed.'
106+
)!
107+
expect(hasIndent(error)).toBe(false)
108+
const column = error.closest('.items-start')!
109+
expect(column.classList).toContain('gap-2')
110+
expect(reservedSlot(column)?.classList).toContain(ICON_SLOT)
111+
})
112+
113+
it('stacks main-lane blocks one gap-3 apart and search queries one gap-1.5 apart', () => {
114+
render('mothership', [
115+
tool('a'),
116+
{
117+
type: 'tool',
118+
data: {
119+
id: 's1',
120+
toolName: 'search_workspace',
121+
displayTitle: 'Searching',
122+
status: 'success',
123+
params: { query: 'first' },
124+
},
125+
},
126+
{
127+
type: 'tool',
128+
data: {
129+
id: 's2',
130+
toolName: 'search_workspace',
131+
displayTitle: 'Searching',
132+
status: 'success',
133+
params: { query: 'second' },
134+
},
135+
},
136+
])
137+
const blocks = container.querySelector('.flex-col.gap-3')!
138+
expect(blocks.contains(statuses()[0])).toBe(true)
139+
expect(blocks.classList).toContain('gap-3')
140+
expect(blocks.classList).not.toContain('gap-1.5')
141+
const queries = statuses().filter((status) => status.textContent === 'first')
142+
const searchList = queries[0].closest('.flex-col.gap-1\\.5')!
143+
expect(searchList).not.toBeNull()
144+
expect(searchList.parentElement?.closest('.flex-col.gap-3')).toBe(blocks)
145+
})
146+
})

0 commit comments

Comments
 (0)