From f07a87a563c86fbeed50126d0576640c20b6b7e1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 21:01:06 -0700 Subject: [PATCH] improvement(chat): restore stable inline tool activity headers and action summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " + 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. Live file-edit titles - A prepare_file_edit title streamed with escaped characters (e.g. a quote) was cut at the escape; it now reads through the shared streaming-argument parser, which matches escapes and decodes them. --- .../hero-chat-loop/hero-tool-call-item.tsx | 19 +- .../agent-group/activity-layout.test.tsx | 146 ++++ .../agent-group/activity-stream.test.tsx | 127 ++-- .../agent-group/agent-group-content.ts | 30 + .../agent-group/agent-group-view.tsx | 238 +++---- .../agent-group/agent-group.test.ts | 106 ++- .../agent-group/browser-agent-icon.test.tsx | 4 +- .../components/agent-group/index.ts | 2 +- .../agent-group/lane-activity.test.ts | 71 ++ .../components/agent-group/lane-activity.ts | 252 +++++++ .../agent-group/main-agent-activity.tsx | 96 +-- .../agent-group/search-activity.test.tsx | 40 ++ .../agent-group/search-activity.tsx | 27 +- .../agent-group/tool-activity-group.test.ts | 102 ++- .../agent-group/tool-activity-group.tsx | 158 +++-- .../agent-group/tool-call-item.test.tsx | 34 +- .../components/agent-group/tool-call-item.tsx | 99 +-- .../components/agent-group/tool-call-title.ts | 106 +++ .../agent-group/tool-permission-card.tsx | 139 ++-- .../components/chat-content/chat-content.tsx | 109 +-- .../chat-content/external-link.test.tsx | 134 ++++ .../components/chat-content/external-link.tsx | 85 ++- .../components/source-chip/source-chip.tsx | 6 +- .../message-content-thinking.test.tsx | 459 ++++++++++++- .../message-content/message-content.test.ts | 145 ++-- .../message-content/message-content.tsx | 69 +- .../message-content/message-sources.ts | 16 +- .../message-content/resolve-citations.test.ts | 7 + .../message-content/resolve-citations.ts | 7 +- .../message-content/sources-by-url.ts | 16 + .../home/components/message-content/utils.ts | 4 +- apps/sim/components/ui/activity-status.tsx | 29 +- .../mothership/tools/tool-activity.test.ts | 161 ++++- .../sim/lib/mothership/tools/tool-activity.ts | 638 ++++++++++++++++++ .../lib/mothership/tools/tool-display.test.ts | 36 + apps/sim/lib/mothership/tools/tool-display.ts | 284 ++++---- 36 files changed, 3116 insertions(+), 885 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/sources-by-url.ts diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx index 941d2dc0510..19c6ec5ba45 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx @@ -1,14 +1,20 @@ import { Table } from '@sim/emcn/icons' import { SlackIcon } from '@/components/icons' import { ActivityStatus } from '@/components/ui/activity-status' -import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' +import { + getToolInProgressTitle, + getToolStatusDisplayTitle, +} from '@/lib/mothership/tools/tool-display' import type { ToolActivityPresentation, ToolCallItemProps, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' -/** Demo fixtures have known brands, so the landing page never loads the block registry. */ +/** + * Demo fixtures have known brands, so the landing page never loads the block registry. + * Rows are history and never shimmer; the lane decides which header is live. + */ export function HeroToolCallItem({ toolCallId, renderStatus, @@ -25,13 +31,8 @@ export function HeroToolCallItem({ : getToolIcon(toolName) const activity: ToolActivityPresentation = { label: getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription), - activeLabel: getToolStatusDisplayTitle( - displayTitle, - status === 'success' ? 'executing' : status, - toolName, - activityDescription - ), - isActive: status === 'executing', + activeLabel: getToolInProgressTitle(displayTitle, status, toolName, activityDescription), + isActive: false, icon: , } return renderStatus ? renderStatus(activity) : diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx new file mode 100644 index 00000000000..7821353adad --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx @@ -0,0 +1,146 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { 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 type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' + +vi.mock('@/lib/browser-agent/transport', () => ({ isBrowserAgentAvailable: () => true })) + +const ICON_SLOT = 'size-[14px]' + +function tool( + id: string, + toolName = 'search_docs', + status: ToolCallStatus = 'success' +): AgentGroupItem { + return { type: 'tool', data: { id, toolName, displayTitle: `Searching ${id}`, status } } +} + +function lane(id: string, items: AgentGroupItem[]): AgentGroupItem { + return { + type: 'agent_group', + group: { + id, + agentName: 'deploy', + agentLabel: 'Deploy', + items, + isDelegating: false, + isOpen: false, + }, + } +} + +/** Indentation classes a row or its containers must not carry. */ +const hasIndent = (element: Element) => + [...element.classList].some((name) => /^(pl|ml|ps|ms)-/.test(name)) + +describe('flat expanded activity layout', () => { + let root: Root + let container: HTMLDivElement + + beforeEach(() => { + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false })) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + const render = (agentName: string, items: AgentGroupItem[], error?: string) => + act(() => + root.render( + + ) + ) + const expand = () => act(() => container.querySelector('[role="button"]')!.click()) + const statuses = () => [...container.querySelectorAll('[role="status"]')] + const iconSlot = (status: Element) => status.querySelector(':scope > [aria-hidden="true"]')! + /** The reserved icon slot of a text-column wrapper. */ + const reservedSlot = (column: Element) => column.querySelector(':scope > [aria-hidden="true"]') + + it.each(['mothership', 'workflow'])( + 'lines %s rows up with the header icon and text columns', + (agentName) => { + render(agentName, [tool('a'), tool('b', 'web_search')]) + expand() + const [header, ...rows] = statuses() + expect(rows.length).toBeGreaterThan(0) + for (const row of [header, ...rows]) { + expect(row.classList).toContain('gap-2') + expect(iconSlot(row).classList).toContain(ICON_SLOT) + } + const list = rows[0].closest('.flex-col')! + expect(list.classList).toContain('gap-1.5') + let node: Element | null = rows[0] + while (node && node !== container) { + expect(hasIndent(node), node.className).toBe(false) + node = node.parentElement + } + } + ) + + it('keeps a nested lane indented into its parent text column', () => { + render('workflow', [tool('a'), lane('deploy', [tool('child')])]) + expand() + const nestedHeader = statuses().find((status) => status.textContent === 'Searched child')! + const column = nestedHeader.closest('.items-start')! + expect(column.classList).toContain('gap-2') + expect(reservedSlot(column)?.classList).toContain(ICON_SLOT) + expect(reservedSlot(column)?.childElementCount).toBe(0) + }) + + it('puts a lane error on the shared text column instead of a hand-tuned inset', () => { + render('workflow', [tool('a')], 'Subagent failed.') + const error = [...container.querySelectorAll('p')].find( + (node) => node.textContent === 'Subagent failed.' + )! + expect(hasIndent(error)).toBe(false) + const column = error.closest('.items-start')! + expect(column.classList).toContain('gap-2') + expect(reservedSlot(column)?.classList).toContain(ICON_SLOT) + }) + + it('stacks main-lane blocks one gap-3 apart and search queries one gap-1.5 apart', () => { + render('mothership', [ + tool('a'), + { + type: 'tool', + data: { + id: 's1', + toolName: 'search_workspace', + displayTitle: 'Searching', + status: 'success', + params: { query: 'first' }, + }, + }, + { + type: 'tool', + data: { + id: 's2', + toolName: 'search_workspace', + displayTitle: 'Searching', + status: 'success', + params: { query: 'second' }, + }, + }, + ]) + const blocks = container.querySelector('.flex-col.gap-3')! + expect(blocks.contains(statuses()[0])).toBe(true) + expect(blocks.classList).toContain('gap-3') + expect(blocks.classList).not.toContain('gap-1.5') + const queries = statuses().filter((status) => status.textContent === 'first') + const searchList = queries[0].closest('.flex-col.gap-1\\.5')! + expect(searchList).not.toBeNull() + expect(searchList.parentElement?.closest('.flex-col.gap-3')).toBe(blocks) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx index 38c3750a95e..7091699b47a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx @@ -128,10 +128,8 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a advance(100) render([tool('first', 'success'), tool('second')]) render([tool('first', 'success'), tool('second', status)]) - const label = - status === 'error' || status === 'rejected' - ? 'Reading second' - : `${status === 'skipped' ? 'Skipped' : 'Stopped'} reading second` + /** A stopped or skipped latest call ends the live activity, so it reads as finished. */ + const label = `Read first · 1 ${status === 'skipped' ? 'skipped' : 'stopped'}` expect(header()?.textContent).toBe(label) expect(container.querySelector('[class*="shimmer"]')).toBeNull() advance(1500) @@ -146,7 +144,8 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a advance(100) render([tool('first', 'success'), tool('second')]) render([tool('first', 'success'), tool('second', status)]) - const label = agentName === 'mothership' ? 'Read first' : 'Reading first' + /** A failed latest call ends the live activity, so it reads as finished. */ + const label = 'Read first' expect(header()?.textContent).toBe(label) advance(1500) expect(header()?.textContent).toBe(label) @@ -164,7 +163,7 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a advance(100) render([tool('first', 'success'), tool('second')]) render([tool('first', 'success'), tool('second', 'success')], false) - const completed = 'Read second + 1' + const completed = 'Read files' expect(header()?.textContent).toBe(completed) expect(container.querySelector('[class*="shimmer"]')).toBeNull() advance(2000) @@ -241,7 +240,7 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a } ) - it('shows the latest concrete action and keeps the complete history available', () => { + it('names the first distinct actions without a count and keeps the complete history', () => { render( [ tool('first', 'success'), @@ -251,7 +250,7 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a ], false ) - expect(header()?.textContent).toBe('Read fourth + 3') + expect(header()?.textContent).toBe('Read files, searched files, ran commands') const trigger = container.querySelector('[role="button"]')! act(() => trigger.click()) expect(container.querySelector('[data-state="open"]')?.textContent).toBe( @@ -259,48 +258,42 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a ) }) - it('keeps narration from prematurely completing an open lane', () => { - act(() => - root.render( - + /** Main lanes never hold narration: prose becomes its own transcript segment. */ + if (agentName !== 'mothership') { + it('keeps narration from prematurely completing an open lane', () => { + act(() => + root.render( + + ) ) - ) - const rows = container.querySelectorAll('[role="status"]') - if (agentName === 'mothership') { - expect(rows[0].textContent).toBe('Read first') - expect(rows[0].querySelector('[class*="shimmer"]')).toBeNull() - } - const liveRow = rows[rows.length - 1] - expect(liveRow.textContent).toBe(agentName === 'mothership' ? 'Read second' : 'Reading second') - if (agentName === 'mothership') { - expect(liveRow.querySelector('[class*="shimmer"]')).toBeNull() - } else { + const rows = container.querySelectorAll('[role="status"]') + const liveRow = rows[rows.length - 1] + expect(liveRow.textContent).toBe('Reading second') expect(liveRow.querySelector('[class*="shimmer"]')).not.toBeNull() - } - }) + }) + } - it('distinguishes a finished tool from an open agent lane', () => { + it('keeps a finished tool in progress while its lane stays open', () => { render([tool('first')]) advance(100) render([tool('first', 'success')]) - expect(header()?.textContent).toBe(agentName === 'mothership' ? 'Read first' : 'Reading first') - if (agentName === 'mothership') { - expect(container.querySelector('[class*="shimmer"]')).toBeNull() - } else { - expect(container.querySelector('[class*="shimmer"]')).not.toBeNull() - } + expect(header()?.textContent).toBe('Reading first') + expect(container.querySelector('[class*="shimmer"]')).not.toBeNull() advance(1500) - expect(header()?.textContent).toBe(agentName === 'mothership' ? 'Read first' : 'Reading first') + expect(header()?.textContent).toBe('Reading first') + render([tool('first', 'success')], false) + expect(header()?.textContent).toBe('Read first') + expect(container.querySelector('[class*="shimmer"]')).toBeNull() }) it('keeps the present-tense intent when expanded while completed collapse uses past tense', () => { @@ -326,7 +319,7 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a ) ) renderActivity('executing', true) - expect(header()?.textContent).toBe('Reading second + 1') + expect(header()?.textContent).toBe('Reading second') act(() => container.querySelector('[role="button"]')!.click()) expect(header()?.textContent).toBe(activity.title) renderActivity('success', false) @@ -363,7 +356,7 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a } ) - it('does not claim a merged activity completed when its unlabelled validation failed', () => { + it('does not claim an activity completed when its unlabelled validation failed', () => { const activity = { id: 'review', title: 'Reviewing invoices', @@ -375,7 +368,6 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a agentName='mothership' agentLabel='Sim' activity={activity} - completedGroupCount={2} items={items([ { ...tool('read', 'success'), params: { activity: { id: 'first' } } }, { ...tool('configure', 'success'), params: { activity } }, @@ -384,7 +376,7 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a /> ) ) - expect(header()?.textContent).toBe('Read configure + 1') + expect(header()?.textContent).toBe('Read files') expect(header()?.textContent).not.toContain(activity.completedTitle) act(() => container.querySelector('[role="button"]')!.click()) expect(header()?.textContent).toBe(activity.title) @@ -393,7 +385,7 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a ) }) - it('shows active tool names and count, reserving the grouped completed title for lane closure', () => { + it('shows the active call without a count, reserving the completed title for lane closure', () => { const activity: ToolActivity = { id: 'research', title: 'Comparing files', @@ -412,48 +404,15 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a ) ) renderActivity([tool('first'), tool('second')], true) - expect(header()?.textContent).toBe('Reading second + 1') + expect(header()?.textContent).toBe('Reading second') expect(container.textContent).not.toContain(activity.completedTitle) renderActivity([tool('first', 'success'), tool('second', 'success')], true) - expect(header()?.textContent).toBe('Read second') - expect(container.querySelector('[class*="shimmer"]')).toBeNull() + expect(header()?.textContent).toBe('Reading second') + expect(container.querySelector('[class*="shimmer"]')).not.toBeNull() renderActivity([tool('first', 'success'), tool('second', 'success')], false) expect(header()?.textContent).toBe(activity.completedTitle) advance(2000) expect(header()?.textContent).toBe(activity.completedTitle) }) - - it('shows singleton actions separated by text directly without duplicating their group title', () => { - const activity: ToolActivity = { - id: 'research', - title: 'Comparing files', - completedTitle: 'Compared files', - } - const renderActivity = (open: boolean) => - act(() => - root.render( - - ) - ) - renderActivity(true) - expect( - [...container.querySelectorAll('[role="status"]')].map((row) => row.textContent) - ).toEqual(['Read first', 'Read second']) - expect(container.querySelector('[class*="shimmer"]')).toBeNull() - renderActivity(false) - expect( - [...container.querySelectorAll('[role="status"]')].map((row) => row.textContent) - ).toEqual(['Read first', 'Read second']) - }) } }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts index c4bc59e308d..0e1e4779ea5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts @@ -2,8 +2,23 @@ import type { AgentGroupItem, NestedAgentGroup, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' +import { isToolDone } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' +/** + * The newest call that is still in progress, by start time, so parallel calls + * hand the live indicator to whichever began last and back as each finishes. + */ +export function getNewestRunningTool(tools: ToolCallData[]): ToolCallData | undefined { + return tools.reduce( + (newest, tool) => + !isToolDone(tool.status) && (!newest || (tool.startedAt ?? 0) >= (newest.startedAt ?? 0)) + ? tool + : newest, + undefined + ) +} + /** Empty agent lanes share the turn's thinking indicator until they have output. */ export function hasAgentGroupItemContent(item: AgentGroupItem): boolean { switch (item.type) { @@ -42,3 +57,18 @@ export function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] { walk(items) return tools } + +/** Every call in the lane, nested lanes included, has finished, and there was work to finish. */ +export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { + let hasWork = false + for (const item of items) { + if (item.type === 'tool') { + hasWork = true + if (!isToolDone(item.data.status)) return false + } else if (item.type === 'agent_group') { + hasWork = true + if (item.group.isDelegating || !isAgentGroupResolved(item.group.items)) return false + } + } + return hasWork +} 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 bbb3ea06cc4..10468b3a0a6 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 @@ -1,12 +1,13 @@ 'use client' import { type ComponentType, type ReactNode, useState } from 'react' +import { ActivityTextColumn } from '@/components/ui/activity-status' import { ThinkingLoader } from '@/components/ui/thinking-loader' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import type { ToolActivity } from '@/lib/mothership/generated/protocol' import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools' import { readToolActivity } from '@/lib/mothership/tools/tool-activity' -import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' +import { getToolDisplayTitle } from '@/lib/mothership/tools/tool-display' import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' import { collectGroupTools, @@ -14,13 +15,21 @@ import { } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon' import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown' +import { + getActiveBrowserTakeover, + getLaneLiveIndicator, + hasPendingInteraction, + isLaneWorking, + type LaneLiveIndicator, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' import { - getActiveToolActivityTitle, getActivityStatusTool, - getToolActivitySummary, + getCompletedActivityLabel, + getInProgressActivityLabel, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import { useToolCallTitle } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title' import { getActivityAttentionKey, needsToolInput, @@ -54,7 +63,6 @@ export type AgentGroupItem = export interface AgentGroupProps { activity?: ToolActivity - completedGroupCount?: number error?: string agentName: string agentLabel: string @@ -67,50 +75,12 @@ export interface AgentGroupProps { defaultExpanded?: boolean /** Follows incoming activity until the user scrolls up. */ autoScrollActivity?: boolean -} - -function activeToolTitle(tool: ToolCallData): string { - return getToolStatusDisplayTitle( - tool.displayTitle || getToolDisplayTitle(String(tool.toolName ?? ''), undefined), - tool.status === ToolCallStatus.success ? ToolCallStatus.executing : tool.status, - tool.toolName, - tool.activityDescription - ) -} - -/** Reveal blocking interactions even when a parent group was manually collapsed. */ -function hasPendingInteraction(items: AgentGroupItem[]): boolean { - return items.some((item) => { - if (item.type === 'tool') return needsToolInput(item.data) - return item.type === 'agent_group' ? hasPendingInteraction(item.group.items) : false - }) -} - -interface ActiveBrowserTakeover { - id: string - reason: string -} - -/** Returns this group's own active browser hand-back, if any. */ -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() : '', - } - } - // Browser-agent tools are serialized. Once a newer tool exists, an older - // executing takeover is stale and must not keep a question on screen. - return null - } - return null + /** + * The live indicator this lane shares with its turn, decided once for the + * whole turn. A root lane rendered on its own leaves it undefined and + * decides it from its own items; `null` means the lane has none. + */ + liveIndicator?: LaneLiveIndicator | null } /** True when a nested group owns a browser hand-back question. */ @@ -124,20 +94,6 @@ function hasNestedBrowserTakeover(items: AgentGroupItem[]): boolean { ) } -export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { - let hasWork = false - for (const item of items) { - if (item.type === 'tool') { - hasWork = true - if (!isToolDone(item.data.status)) return false - } else if (item.type === 'agent_group') { - hasWork = true - if (item.group.isDelegating || !isAgentGroupResolved(item.group.items)) return false - } - } - return hasWork -} - interface AgentGroupViewProps extends AgentGroupProps { /** Supplies tool behavior without coupling the group layout to the block registry. */ ToolCallComponent: ComponentType @@ -148,7 +104,6 @@ export function AgentGroupView({ agentName, agentLabel, activity: groupActivity, - completedGroupCount, error, items, isDelegating = false, @@ -158,33 +113,34 @@ export function AgentGroupView({ autoScrollActivity = true, ToolCallComponent, renderBrowserTakeover, + liveIndicator, }: AgentGroupViewProps) { const AgentIcon = getAgentIcon(agentName) const isMainAgent = agentName === 'mothership' const tools = isMainAgent ? [] : collectGroupTools(items) const statusTool = getActivityStatusTool(tools) + const statusTitle = useToolCallTitle( + statusTool && { + ...statusTool, + toolCallId: statusTool.id, + displayTitle: + statusTool.displayTitle || + getToolDisplayTitle(String(statusTool.toolName ?? ''), undefined), + } + ) const activityDescriptor = groupActivity ?? tools .map((tool) => readToolActivity(tool.params, tool.streamingArgs)) .reverse() .find((entry) => entry?.title || entry?.completedTitle) - const runningCount = tools.filter((tool) => !isToolDone(tool.status)).length - const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() const activeBrowserTakeover = browserAgentAvailable && isLaneOpen ? getActiveBrowserTakeover(items) : null const nestedBrowserTakeover = browserAgentAvailable && hasNestedBrowserTakeover(items) const isWorking = - !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - const agentIcon = - isWorking && !statusTool ? ( - - ) : agentName === 'browser' ? ( - - ) : ( - - ) + !activeBrowserTakeover && + isLaneWorking({ error, isDelegating, isOpen: isLaneOpen, items }, isStreaming) const [manualExpanded, setManualExpanded] = useState(defaultExpanded) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) @@ -195,6 +151,60 @@ export function AgentGroupView({ nestedBrowserTakeover || (activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded) + const live = + liveIndicator !== undefined + ? liveIndicator + : getLaneLiveIndicator( + isMainAgent + ? { + kind: 'main', + parts: [items], + isActive: isStreaming, + isOpen: isStreaming && isLaneOpen, + } + : { + kind: 'subagent', + parts: [items], + isActive: isStreaming && isWorking, + isOpen: isStreaming && isWorking, + } + ) + const liveCall = live?.type === 'call' ? live.tool : undefined + /** This lane's subtree holds the live indicator: its narration header, or its live call. */ + const isLaneLive = + !error && + (live?.type === 'narration' || + (liveCall !== undefined && tools.some((tool) => tool.id === liveCall.id))) + /** + * The nested lanes still working, which show their own header. One that has + * ended hands its last call back to this lane's header. + */ + const workingNestedLanes = items.flatMap((item) => + item.type === 'agent_group' && isLaneWorking(item.group, isStreaming) ? [item.group] : [] + ) + /** An expanded lane defers to a visible, still-working nested lane holding the live call. */ + const liveInNestedLane = + liveCall !== undefined && + workingNestedLanes.some((lane) => + collectGroupTools(lane.items).some((tool) => tool.id === liveCall.id) + ) + const headerActive = isLaneLive && !(expanded && liveInNestedLane) + /** + * Tense follows liveness: a live lane, or a working lane whose status call + * still runs, reads in progress. An ended lane reads as finished even when a + * stale call was never closed. + */ + const inProgress = + isLaneLive || (isWorking && statusTool !== undefined && !isToolDone(statusTool.status)) + const agentIcon = + headerActive && !statusTool ? ( + + ) : agentName === 'browser' ? ( + + ) : ( + + ) + const meaningfulItems = items.filter(hasAgentGroupItemContent) if (meaningfulItems.length === 0 && !error) return null @@ -206,6 +216,11 @@ export function AgentGroupView({ setManualExpanded(!expanded) } + /** + * Main lanes hold only calls, so every nested lane and narration row here + * belongs to a subagent lane and sits in its text column: indentation means + * nested work. + */ const renderItem = (item: AgentGroupItem, idx: number) => { if (item.type === 'tool') { return ( @@ -225,66 +240,55 @@ export function AgentGroupView({ } if (item.type === 'agent_group') { return ( - + + + ) } if (!item.content.trim()) return null return ( - + + + ) } const activity = isMainAgent ? ( ) : ( -
{items.map(renderItem)}
+
{items.map(renderItem)}
) const headerText = error ? agentLabel - : isWorking - ? statusTool - ? getActiveToolActivityTitle( - `${activeToolTitle(statusTool)}${runningCount > 1 ? ` + ${runningCount - 1}` : ''}`, - statusTool, - tools - ) + : inProgress + ? statusTool && statusTitle + ? getInProgressActivityLabel(statusTitle.activeLabel, statusTool, tools) : 'Thinking' - : activityDescriptor?.completedTitle && - tools.every((tool) => tool.status === ToolCallStatus.success) - ? activityDescriptor.completedTitle - : tools.length > 0 - ? getToolActivitySummary(tools) - : agentLabel - const headerActive = - !error && - isWorking && - (!statusTool || - statusTool.status === ToolCallStatus.executing || - statusTool.status === ToolCallStatus.success) + : tools.length > 0 + ? getCompletedActivityLabel(tools, activityDescriptor) + : agentLabel const collapsible = meaningfulItems.length > 1 || meaningfulItems.some( @@ -315,7 +319,11 @@ export function AgentGroupView({ {activity} )} - {error &&

{error}

} + {error && ( + +

{error}

+
+ )} {activeBrowserTakeover && (
{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} - - - -
-
{toolName}
-
{preview}
-
-
-
- ) : ( - - {displayTitle} - - )} - - void submit('allow', [toolCallId])}> - Allow - - - - - Don't ask again - - - - void submit('allow_chat', [toolCallId])}> - For this chat - - void submit('always_allow', [toolCallId])}> - For every chat - - - - void submit('skip', [toolCallId])}> - Skip - -
- - {showBulkActions && ( -
- - {outstandingIds.length} tools need permission - - void submit('allow', outstandingIds)}> - Allow all +
+ +
+ {preview ? ( + + + + {displayTitle} + + + +
+
{toolName}
+
{preview}
+
+
+
+ ) : ( + + {displayTitle} + + )} + + void submit('allow', [toolCallId])} + > + Allow - void submit('skip', outstandingIds)}> - Skip all + + + + Don't ask again + + + + void submit('allow_chat', [toolCallId])}> + For this chat + + void submit('always_allow', [toolCallId])}> + For every chat + + + + void submit('skip', [toolCallId])}> + Skip
+
+ + {showBulkActions && ( + +
+ + {outstandingIds.length} tools need permission + + void submit('allow', outstandingIds)}> + Allow all + + void submit('skip', outstandingIds)}> + Skip all + +
+
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 1dfd0a5b552..dc3537b8e1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -35,6 +35,12 @@ import { isInlineFileReference, } from '@/lib/mothership/chat/inline-image-reference' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import { + ExternalLink, + externalLinkHostname, + LinkSourcesContext, + PROSE_LINK_CLASS, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link' import { HighlightedLines } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/highlighted-lines' import { remarkPlainText } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/remark-plain-text' import { @@ -50,10 +56,10 @@ import { WorkspaceResourceDisplay, type WorkspaceResourceTagData, } 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 type { WorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/types' import { useSmoothText } from '@/hooks/use-smooth-text' import { sanitizeChatDisplayContent } from './chat-sanitize' -import { ExternalLink, externalLinkHostname } from './external-link' const LANG_ALIASES: Record = { js: 'javascript', @@ -347,18 +353,13 @@ const MARKDOWN_COMPONENTS = { } if (href?.startsWith('mailto:')) { return ( - + {children} ) } return ( - + {children} ) @@ -456,6 +457,8 @@ interface ChatContentProps { * nothing (tags are suppressed until complete). A wait from the user's POV. */ onPendingTagChange?: (pending: boolean) => void + /** The turn's retrieved sources by URL, which title the answer's plain links to them. */ + linkSources?: ReadonlyMap } /** Explicit options keep Streamdown's processor cache scoped to this chat and turn. */ @@ -496,6 +499,7 @@ function ChatContentInner({ onRevealStateChange, onStreamActivityChange, onPendingTagChange, + linkSources, }: ChatContentProps) { const { chatId } = useChatSurface() const imageRehypePlugins = useMemo< @@ -631,6 +635,11 @@ function ChatContentInner({ () => parsed.segments.flatMap((segment) => (segment.type === 'source' ? [segment.data] : [])), [parsed] ) + /** Retrieved sources first, then this segment's own citations, first URL wins. */ + const linkSourcesByUrl = useMemo( + () => indexSourcesByUrl(linkSources?.values() ?? [], sourceRefs), + [linkSources, sourceRefs] + ) const groups: RenderGroup[] = [] let pendingMarkdown = '' @@ -691,49 +700,51 @@ function ChatContentInner({ * the new special block mounts. */ return ( - - -
- {groups.map((group, i) => { - if (group.kind === 'inline') { - return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')} - > - + + +
+ {groups.map((group, i) => { + if (group.kind === 'inline') { + return ( +
:first-child]:mt-0 [&>:last-child]:mb-0')} > - {group.markdown} - -
+ + {group.markdown} + +
+ ) + } + return ( + ) - } - return ( - - ) - })} -
- - + })} +
+
+
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx new file mode 100644 index 00000000000..1bc3049cd02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.test.tsx @@ -0,0 +1,134 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPreview } = vi.hoisted(() => ({ mockPreview: vi.fn() })) + +vi.mock('@/lib/browser-agent/open-in-panel', () => ({ + shouldOpenInBrowserPanel: () => false, + openInBrowserPanel: vi.fn(), +})) +vi.mock('@/hooks/queries/link-preview', () => ({ + useLinkPreview: () => ({ data: { preview: mockPreview() } }), +})) + +import { + ExternalLink, + getExternalLinkTooltip, + LinkSourcesContext, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link' +import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +const HREF = 'https://mail.google.com/mail/u/0/#inbox/FMfcgzQ' +const PREVIEW = { title: 'Preview title', description: 'Preview description', siteName: 'Gmail' } +const SOURCE: SourceTagData = { url: HREF, title: 'Quarterly plan thread', siteName: 'Gmail' } + +describe('getExternalLinkTooltip', () => { + it('prefers the cited source title, then the preview title, then the site name', () => { + expect(getExternalLinkTooltip(HREF, SOURCE, PREVIEW)).toMatchObject({ + title: 'Quarterly plan thread', + siteName: 'Gmail', + }) + expect(getExternalLinkTooltip(HREF, undefined, PREVIEW)).toMatchObject({ + title: 'Preview title', + siteName: 'Gmail', + description: 'Preview description', + }) + expect(getExternalLinkTooltip(HREF, undefined, undefined)).toEqual({ + title: 'mail.google.com', + }) + expect(getExternalLinkTooltip('https://www.example.com/a', undefined, null)).toEqual({ + title: 'example.com', + }) + }) + + it('never shows the raw URL, and takes a description only from the preview', () => { + for (const tooltip of [ + getExternalLinkTooltip(HREF, SOURCE, undefined), + getExternalLinkTooltip( + HREF, + { url: HREF }, + { title: ' ', description: null, siteName: null } + ), + getExternalLinkTooltip(HREF, undefined, undefined), + ]) { + expect(Object.values(tooltip)).not.toContain(HREF) + expect(tooltip.description).toBeUndefined() + } + }) +}) + +describe('ExternalLink', () => { + let root: Root + let container: HTMLDivElement + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockPreview.mockReturnValue(null) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + const render = (sources: ReadonlyMap = new Map()) => + act(() => + root.render( + +

+ See the{' '} + + Conversation + +

+
+ ) + ) + const link = () => container.querySelector('a')! + const hover = () => + act(() => { + link().dispatchEvent(new MouseEvent('pointerover', { bubbles: true, clientX: 9, clientY: 9 })) + }) + + it('centers the favicon on the text middle without a pixel offset or an inline-flex link', () => { + render() + const icon = link().querySelector('img')! + expect(icon.classList).toContain('align-middle') + expect(icon.parentElement).toBe(link()) + const offsets = [...icon.classList].filter((name) => /^(-?top|relative|translate)/.test(name)) + expect(offsets).toEqual([]) + expect(link().classList).not.toContain('inline-flex') + }) + + it('underlines only on hover, with no fill, and keeps the keyboard focus outline', () => { + render() + const classes = [...link().classList] + expect(classes).toContain('no-underline') + expect(classes).toContain('hover:underline') + expect(classes).toContain('decoration-[var(--text-muted)]') + expect(classes.some((name) => name.startsWith('hover:bg-'))).toBe(false) + expect(classes).toContain('focus-visible:outline') + }) + + it('titles the tooltip with the cited source for this exact URL', () => { + render(new Map([[HREF, SOURCE]])) + hover() + const tooltip = document.querySelector('[role="tooltip"]')! + expect(tooltip.textContent).toContain('Quarterly plan thread') + expect(tooltip.textContent).toContain('Gmail') + expect(tooltip.textContent).not.toContain(HREF) + }) + + it('falls back to the site name instead of the URL for a private page with no preview', () => { + render() + hover() + const tooltip = document.querySelector('[role="tooltip"]')! + expect(tooltip.textContent).toContain('mail.google.com') + expect(tooltip.textContent).not.toContain(HREF) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx index 3e9ddaadd37..fbcac378d76 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx @@ -1,8 +1,11 @@ 'use client' +import { createContext, useContext } from 'react' import { Tooltip } from '@sim/emcn' +import type { LinkPreview } from '@/lib/api/contracts/link-preview' import { openInBrowserPanel, shouldOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel' import { faviconUrl } from '@/lib/core/utils/favicon' +import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { useLinkPreview } from '@/hooks/queries/link-preview' /** Hides a favicon img that failed to load so the link degrades to plain text. */ @@ -24,6 +27,51 @@ export function externalLinkHostname(href?: string): string | null { } } +/** The site a link belongs to: its known site name, else its hostname without `www.`. */ +export function linkSiteName(url: string, siteName?: string | null): string { + return siteName?.trim() || (externalLinkHostname(url) ?? url).replace(/^www\./, '') +} + +/** + * A prose link: no fill, a thin muted underline only on hover, and an outline + * for keyboard focus. + */ +export const PROSE_LINK_CLASS = + 'not-prose text-[var(--text-primary)] no-underline decoration-1 decoration-[var(--text-muted)] underline-offset-2 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--text-primary)]' + +/** + * The turn's retrieved sources by URL. A link the model writes to a document it + * retrieved takes that document's title, which private pages (Gmail, Slack, + * Drive) never expose through a link preview. + */ +export const LinkSourcesContext = createContext>(new Map()) + +export interface ExternalLinkTooltip { + title: string + /** The site, shown muted beneath the title when it adds information. */ + siteName?: string + /** A description, only ever from the link preview. */ + description?: string +} + +/** + * What a link's tooltip says, never the raw URL: the cited source's title for + * this exact URL, else the link preview's title, else the site name. + */ +export function getExternalLinkTooltip( + href: string, + source: SourceTagData | undefined, + preview: LinkPreview | undefined +): ExternalLinkTooltip { + const siteName = linkSiteName(href, source?.siteName ?? preview?.siteName) + const title = source?.title?.trim() || preview?.title?.trim() || siteName + return { + title, + ...(title !== siteName ? { siteName } : {}), + ...(preview?.description?.trim() ? { description: preview.description.trim() } : {}), + } +} + interface ExternalLinkProps { href: string hostname: string @@ -47,23 +95,24 @@ export function handleExternalLinkClick( } /** - * Favicon + understated external link with an OG-preview tooltip. The - * preview query fires when the link renders, so metadata is normally cached - * (client and server side) before the first hover; the tooltip shows the - * destination URL until metadata arrives or when the site has none. Previews - * are https-only — plain-http links keep the URL tooltip, since fetching them - * server-side would reach the URL validator's self-host loopback exception. + * Favicon + understated external link with a titled tooltip. The favicon is + * `align-middle`, like citation chips, so it tracks any font size without an + * offset while the link text still wraps. The preview query fires on render, so + * metadata is normally cached before the first hover. Previews are https-only: + * fetching a plain-http link server-side would reach the URL validator's + * self-host loopback exception. */ export function ExternalLink({ href, hostname, children }: ExternalLinkProps) { + const source = useContext(LinkSourcesContext).get(href) const { data } = useLinkPreview(href.startsWith('https://') ? href : undefined) - const preview = data?.preview + const tooltip = getExternalLinkTooltip(href, source, data?.preview ?? undefined) return ( handleExternalLinkClick(event, href)} @@ -71,24 +120,20 @@ export function ExternalLink({ href, hostname, children }: ExternalLinkProps) { {children} - {preview ? ( - - {preview.title && {preview.title}} - {preview.description && ( - {preview.description} - )} - {preview.siteName ?? hostname} - - ) : ( - {href} - )} + + {tooltip.title} + {tooltip.description && ( + {tooltip.description} + )} + {tooltip.siteName && {tooltip.siteName}} + ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx index cb7522a91ae..2e383d00af9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx @@ -2,17 +2,15 @@ import { chipFilledFillTokens, chipHoverSurfaceClass, cn, OverflowText, Tooltip } from '@sim/emcn' import { - externalLinkHostname, handleExternalLinkClick, + linkSiteName, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link' import { SourceIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon' import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' /** The source's site or provider, separate from its document title. */ export function sourceSiteName(source: SourceTagData): string { - const siteName = source.siteName?.trim() - if (siteName) return siteName - return (externalLinkHostname(source.url) ?? source.url).replace(/^www\./, '') + return linkSiteName(source.url, source.siteName) } /** Citations identify the document; source metadata is the fallback when its title is unavailable. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx index 9ef1b979f26..c7af52fffb1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx @@ -12,6 +12,11 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: vi.fn(() => ({ data: null, isPending: false })), })) +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), + useRouter: () => ({ prefetch: vi.fn(), push: vi.fn() }), +})) + function start(name: string, parentSpanId = 'main'): ContentBlock { return { type: 'subagent', content: name, spanId: name, parentSpanId, timestamp: 1 } } @@ -66,52 +71,219 @@ describe('MessageContent shared thinking indicator', () => { const groups = () => container.querySelectorAll('[data-agent-group]') it.each([undefined, 'main'])( - 'shows thinking after main tools finish and yields to the next tool (spanId=%s)', + 'keeps an open main activity in progress through gaps without thinking (spanId=%s)', (spanId) => { - const mainCall = (id: string, status: ToolCallStatus): ContentBlock => ({ + const inspect = { + id: 'inspect', + title: 'Inspecting workspace resources', + completedTitle: 'Inspected workspace resources', + } + const call = ( + id: string, + name: string, + displayTitle: string, + status: ToolCallStatus, + activity: Record = inspect + ): ContentBlock => ({ type: 'tool_call', spanId, - toolCall: { - id, - name: 'sim_cli', - status, - displayTitle: 'List tables in Alfred', - params: { - args: ['tables', 'list'], - activity: { - id: 'inspect', - title: 'Inspecting workspace resources', - completedTitle: 'Inspected workspace resources', - }, - }, - }, + toolCall: { id, name, status, displayTitle, params: { activity } }, }) - render([mainCall('first', 'executing')]) - act(() => vi.advanceTimersByTime(1_500)) + const header = () => + container.querySelector('[data-agent-group]:last-of-type [role="status"]') + const shimmering = () => Boolean(header()?.querySelector('[class*="shimmer"]')) + const headers: string[] = [] + const step = (blocks: ContentBlock[], isStreaming = true) => { + render(blocks, isStreaming) + act(() => vi.advanceTimersByTime(1_500)) + headers.push(header()?.textContent ?? '') + } + + render([]) + expect(thinking()).toHaveLength(1) + + const list = call('list', 'cli_tables_list', 'Listing tables', 'executing') + step([list]) expect(thinking()).toHaveLength(0) - expect(container.querySelector('[class*="shimmer"]')).not.toBeNull() + expect(shimmering()).toBe(true) - const completed = [mainCall('first', 'success')] - render(completed) + const listed = { ...list, toolCall: { ...list.toolCall!, status: 'success' as const } } + step([listed]) + step([listed, { type: 'thinking', content: 'Checking the result.', timestamp: 3 }]) expect(thinking()).toHaveLength(0) - act(() => vi.advanceTimersByTime(1_500)) - expect(thinking()).toHaveLength(1) - expect(container.querySelector('[aria-hidden="false"]')?.textContent).toContain('Thinking') + expect(shimmering()).toBe(true) - render([...completed, { type: 'thinking', content: 'Checking the result.', timestamp: 3 }]) - expect(thinking()).toHaveLength(1) - render([...completed, mainCall('next', 'executing')]) + const get = call('get', 'cli_tables_get', 'Reading table Invoices', 'executing') + step([listed, get]) + expect(thinking()).toHaveLength(0) + const got = { ...get, toolCall: { ...get.toolCall!, status: 'success' as const } } + step([listed, got]) + expect(thinking()).toHaveLength(0) + expect(shimmering()).toBe(true) + expect(headers).toEqual([ + 'Listing tables', + 'Listing tables', + 'Listing tables', + 'Reading table Invoices', + 'Reading table Invoices', + ]) + + const draft = { + id: 'draft', + title: 'Drafting the summary', + completedTitle: 'Drafted the summary', + } + step([listed, got, call('write', 'cli_files_create', 'Creating file', 'executing', draft)]) + const groupHeaders = () => + [...container.querySelectorAll('[data-agent-group]')].map( + (group) => group.querySelector('[role="status"]')?.textContent + ) + expect(groupHeaders()).toEqual(['Inspected workspace resources', 'Creating file']) expect(thinking()).toHaveLength(0) - const finished = [...completed, mainCall('next', 'success')] - render(finished) + const written = call('write', 'cli_files_create', 'Creating file', 'success', draft) + step([listed, got, written]) + expect(groupHeaders()).toEqual(['Inspected workspace resources', 'Creating file']) + expect(thinking()).toHaveLength(0) + + render([listed, got, written], false) + expect(groupHeaders()).toEqual(['Inspected workspace resources', 'Created file']) + expect(shimmering()).toBe(false) + expect(thinking()).toHaveLength(0) + } + ) + + it('returns the turn indicator once prose closes the main activity', () => { + const read: ContentBlock = { + type: 'tool_call', + toolCall: { + id: 'docs', + name: 'search_docs', + status: 'success', + displayTitle: 'Searching Sim docs', + }, + } + render([read]) + act(() => vi.advanceTimersByTime(1_500)) + expect(thinking()).toHaveLength(0) + expect(container.querySelector('[role="status"]')?.textContent).toBe('Searching Sim docs') + + render([read, { type: 'text', content: 'Found the docs.', timestamp: 3 }]) + expect(container.querySelector('[role="status"]')?.textContent).toBe('Searched Sim docs') + act(() => vi.advanceTimersByTime(3_000)) + expect(thinking()).toHaveLength(1) + }) + + it.each([ + ['executing', true], + ['success', false], + ] as const)( + 'shows exactly one live indicator around a %s main search', + (status, searchIsLive) => { + render([ + { + type: 'tool_call', + toolCall: { + id: 'search', + name: 'search_workspace', + status, + displayTitle: 'Searching documents', + params: { query: 'launch review' }, + }, + }, + ]) + act(() => vi.advanceTimersByTime(1_500)) + const shimmers = container.querySelectorAll('[data-agent-group] [class*="shimmer"]') + expect(shimmers).toHaveLength(searchIsLive ? 1 : 0) + expect(thinking()).toHaveLength(searchIsLive ? 0 : 1) + } + ) + + it.each([ + ['success then error', ['success', 'error'], false], + ['success then stopped', ['success', 'cancelled'], false], + ['error then success', ['error', 'success'], true], + ['success then running', ['success', 'executing'], true], + ] as const)('decides the open group from its latest call: %s', (_case, statuses, groupIsLive) => { + render( + statuses.map( + (status, index): ContentBlock => ({ + type: 'tool_call', + toolCall: { + id: `call-${index}`, + name: 'search_docs', + status, + displayTitle: `Searching Sim docs ${index}`, + }, + }) + ) + ) + act(() => vi.advanceTimersByTime(1_500)) + const header = container.querySelector('[data-agent-group] [role="status"]') + expect(Boolean(header?.querySelector('[class*="shimmer"]'))).toBe(groupIsLive) + expect(thinking()).toHaveLength(groupIsLive ? 0 : 1) + }) + + it.each(['error', 'cancelled'] as const)( + 'lets thinking bridge the gap after a %s main call', + (status) => { + render([ + { + type: 'tool_call', + toolCall: { id: 'read', name: 'read', status, displayTitle: 'Reading notes' }, + }, + ]) act(() => vi.advanceTimersByTime(1_500)) expect(thinking()).toHaveLength(1) - render(finished, false) - expect(thinking()).toHaveLength(0) + expect(container.querySelector('[data-agent-group] [class*="shimmer"]')).toBeNull() } ) + describe('subagent lane', () => { + const laneCall = (index: number, status: ToolCallStatus): ContentBlock => ({ + type: 'tool_call', + spanId: 'workflow', + toolCall: { + id: `lane-${index}`, + name: 'search_docs', + calledBy: 'workflow', + status, + displayTitle: `Searching Sim docs ${index}`, + }, + timestamp: 2 + index, + }) + const liveHeaders = () => + container.querySelectorAll('[data-agent-group] [role="button"] [class*="shimmer"]') + + it.each([ + ['success then error', ['success', 'error'], false], + ['success then stopped', ['success', 'cancelled'], false], + ['error then success', ['error', 'success'], true], + ['success then running', ['success', 'executing'], true], + ] as const)('decides the open lane from its latest call: %s', (_case, statuses, laneIsLive) => { + render([start('workflow'), ...statuses.map((status, index) => laneCall(index, status))]) + act(() => vi.advanceTimersByTime(1_500)) + expect(liveHeaders()).toHaveLength(laneIsLive ? 1 : 0) + expect(thinking()).toHaveLength(laneIsLive ? 0 : 1) + }) + + it('keeps exactly one indicator for an open lane without calls', () => { + render([start('workflow')]) + act(() => vi.advanceTimersByTime(1_500)) + expect(groups()).toHaveLength(0) + expect(thinking()).toHaveLength(1) + + render([ + start('workflow'), + { type: 'subagent_text', spanId: 'workflow', content: 'Planning.', timestamp: 2 }, + ]) + act(() => vi.advanceTimersByTime(1_500)) + expect(groups()).toHaveLength(1) + expect(liveHeaders()).toHaveLength(1) + expect(thinking()).toHaveLength(0) + }) + }) + it('shares one indicator across parallel and nested empty agents', () => { render([start('workflow'), start('browser'), start('deploy', 'workflow')]) expect(thinking()).toHaveLength(1) @@ -189,13 +361,16 @@ describe('MessageContent shared thinking indicator', () => { ) }) - it.each(['awaiting_approval', 'cancelled'] as const)( + it.each([ + ['awaiting_approval', 0], + ['cancelled', 1], + ] as const)( 'keeps %s tool rows visible while another agent is pending', - (status) => { + (status, thinkingRows) => { render([start('workflow'), start('browser'), tool('workflow', status)]) expect(groups()).toHaveLength(1) expect(container.querySelector('[role="status"]')?.textContent).toContain('workflow notes') - expect(thinking()).toHaveLength(1) + expect(thinking()).toHaveLength(thinkingRows) } ) @@ -280,4 +455,220 @@ describe('MessageContent shared thinking indicator', () => { expect(thinking()).toHaveLength(0) expect(groups()).toHaveLength(0) }) + describe('one live indicator per lane', () => { + const call = ( + id: string, + name: string, + status: ToolCallStatus, + extra: Partial> = {}, + spanId?: string + ): ContentBlock => ({ + type: 'tool_call', + spanId, + toolCall: { id, name, status, displayTitle: `Title ${id}`, ...extra }, + timestamp: 2, + }) + const search = (id: string, status: ToolCallStatus, startedAtMs = 1) => + call(id, 'search_workspace', status, { params: { query: `Query ${id}` }, startedAtMs }) + const liveRows = () => + [...container.querySelectorAll('[data-agent-group] [class*="shimmer"]')].map( + (node) => node.closest('[role="status"]')?.textContent ?? '' + ) + const settle = (blocks: ContentBlock[]) => { + render(blocks) + act(() => vi.advanceTimersByTime(1_500)) + } + + it.each([ + [ + 'a running search before a finished call', + [search('s', 'executing'), call('r', 'search_docs', 'success')], + 'Query s', + ], + [ + 'a still-streaming search before a finished call', + [call('s', 'search_workspace', 'executing'), call('r', 'search_docs', 'success')], + 'Preparing query', + ], + [ + 'an older search and a newer call both running', + [search('s', 'executing', 1), call('r', 'search_docs', 'executing', { startedAtMs: 2 })], + 'Title r', + ], + [ + 'an older call and a newer search both running', + [call('r', 'search_docs', 'executing', { startedAtMs: 1 }), search('s', 'executing', 2)], + 'Query s', + ], + ] as const)('shows exactly one indicator for %s', (_case, blocks, live) => { + settle([...blocks]) + expect(liveRows()).toEqual([live]) + expect(thinking()).toHaveLength(0) + }) + + it.each([ + [ + 'a pending approval that splits the lane', + [ + call('r', 'search_docs', 'executing'), + call('approval', 'edit_workflow', 'awaiting_approval'), + call('w', 'web_search', 'success'), + ], + ], + [ + 'a terminal handoff', + [ + call('r', 'search_docs', 'success'), + call('handoff', 'terminal', 'executing', { + params: { operation: 'handoff', args: { reason: 'Sign in' } }, + }), + ], + ], + [ + 'a browser takeover', + [ + call('r', 'search_docs', 'success'), + call('takeover', 'browser_request_takeover', 'executing', { + params: { reason: 'Pick a seat' }, + }), + ], + ], + ] as const)('shows no indicator and no thinking while waiting on %s', (_case, blocks) => { + settle([...blocks]) + expect(liveRows()).toEqual([]) + expect(thinking()).toHaveLength(0) + }) + + it('shows no indicator in a nested lane waiting on an approval', () => { + settle([ + start('workflow'), + call('parent', 'search_docs', 'executing', { calledBy: 'workflow' }, 'workflow'), + start('deploy', 'workflow'), + call('approve', 'deploy_as_api', 'awaiting_approval', { calledBy: 'deploy' }, 'deploy'), + ]) + expect(liveRows()).toEqual([]) + expect(thinking()).toHaveLength(0) + }) + + it('keeps a running parallel lane live while the main lane waits on an approval', () => { + settle([ + start('workflow'), + call('w', 'search_docs', 'executing', { calledBy: 'workflow' }, 'workflow'), + call('approval', 'edit_workflow', 'awaiting_approval'), + ]) + expect(liveRows()).toEqual(['Title w']) + expect(thinking()).toHaveLength(0) + }) + + it('hands the indicator to a visible nested lane instead of shimmering both', () => { + settle([ + start('workflow'), + call('parent', 'search_docs', 'success', { calledBy: 'workflow' }, 'workflow'), + start('deploy', 'workflow'), + call('child', 'search_docs', 'executing', { calledBy: 'deploy' }, 'deploy'), + ]) + expect(liveRows()).toEqual(['Title child']) + const parentHeader = container.querySelector('[role="button"]')! + act(() => parentHeader.click()) + const live = container.querySelectorAll('[data-agent-group] [class*="shimmer"]') + expect(live).toHaveLength(1) + expect(parentHeader.contains(live[0])).toBe(false) + expect(thinking()).toHaveLength(0) + }) + + it('keeps the indicator on the parent when a nested lane has already ended', () => { + settle([ + start('workflow'), + call('parent', 'search_docs', 'success', { calledBy: 'workflow' }, 'workflow'), + start('deploy', 'workflow'), + call( + 'child', + 'search_docs', + 'success', + { calledBy: 'deploy', displayTitle: 'Searching Sim docs child' }, + 'deploy' + ), + { type: 'subagent_end', spanId: 'deploy', timestamp: 3 }, + ]) + const parentHeader = container.querySelector('[role="button"]')! + act(() => parentHeader.click()) + const live = container.querySelectorAll('[data-agent-group] [class*="shimmer"]') + expect(live).toHaveLength(1) + expect(parentHeader.contains(live[0])).toBe(true) + expect(parentHeader.textContent).toContain('Searching Sim docs child') + expect(thinking()).toHaveLength(0) + }) + + it.each([ + ['main', undefined], + ['subagent', 'workflow'], + ] as const)( + 'reads a %s activity whose latest call failed as finished, not in progress', + (_lane, spanId) => { + const calledBy = spanId ? { calledBy: spanId } : {} + settle([ + ...(spanId ? [start(spanId)] : []), + call( + 'a', + 'search_docs', + 'success', + { ...calledBy, displayTitle: 'Searching Sim docs a' }, + spanId + ), + call('b', 'search_docs', 'error', { ...calledBy, displayTitle: 'Searching b' }, spanId), + ]) + const header = container.querySelector('[data-agent-group] [role="status"]') + expect(header?.textContent).toBe('Searched Sim docs a') + expect(header?.querySelector('[class*="shimmer"]')).toBeNull() + expect(thinking()).toHaveLength(1) + } + ) + + it('shows one indicator for each parallel subagent lane', () => { + settle([ + start('workflow'), + start('research'), + call('w', 'search_docs', 'executing', { calledBy: 'workflow' }, 'workflow'), + call('r', 'web_search', 'executing', { calledBy: 'research' }, 'research'), + ]) + expect(liveRows()).toHaveLength(2) + expect(thinking()).toHaveLength(0) + }) + + it('leaves the wait of an open subagent lane that failed to the thinking row', () => { + settle([ + { ...start('workflow'), error: 'Subagent failed.' }, + call('w', 'search_docs', 'success', { calledBy: 'workflow' }, 'workflow'), + ]) + expect(liveRows()).toEqual([]) + expect(thinking()).toHaveLength(1) + }) + + it.each([ + ['a stopped call', ['success', 'cancelled'], 'Searched Sim docs a · 1 stopped'], + ['only stopped calls', ['cancelled', 'cancelled'], '2 tool calls · 2 stopped'], + ['every call succeeded', ['success', 'success'], 'Inspected the docs'], + ] as const)( + 'summarizes a finished activity with %s under one rule', + (_case, statuses, header) => { + const activity = { + id: 'inspect', + title: 'Inspecting the docs', + completedTitle: 'Inspected the docs', + } + render( + statuses.map((status, index) => + call(['a', 'b'][index], 'search_docs', status, { + displayTitle: `Searching Sim docs ${['a', 'b'][index]}`, + params: { activity }, + }) + ), + false + ) + expect(container.querySelector('[data-agent-group] [role="status"]')?.textContent).toBe( + header + ) + } + ) + }) }) 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 37c0af4ca3b..b2ef813729e 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 @@ -20,6 +20,10 @@ import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/mothership/generated/ import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { getHiddenToolNames } from '@/lib/mothership/tools/client/hidden-tools' import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' +import { + getTurnLiveIndicators, + ownsTurnWait, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' import { createTurnModel, reduceEvent, @@ -27,13 +31,22 @@ import { import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize' import type { ContentBlock } from '../../types' import { - assistantMessageHasVisibleActivity, deriveThinkingLabel, getOrchestratorMessageText, parseBlocks, shouldSmoothTextSegment, } from './message-content' +/** Whether the transcript owns the turn's wait, read through the rule's single owner. */ +function ownsWait(segments: ReturnType, isStreaming = false): boolean { + return ownsTurnWait( + getTurnLiveIndicators( + segments.flatMap((segment) => (segment.type === 'agent_group' ? [segment] : [])), + isStreaming + ) + ) +} + function subagentStart(name: string, spanId: string, parentSpanId: string): ContentBlock { return { type: 'subagent', content: name, spanId, parentSpanId, timestamp: 1 } } @@ -111,18 +124,23 @@ describe('top-level activity groups', () => { ) const stillRunning = [...completed.slice(0, -1), blocks.at(-1)!] expect(parseBlocks(stillRunning)).toHaveLength(3) - const merged = parseBlocks(completed) - expect(merged).toHaveLength(1) - expect(merged[0]).toMatchObject({ - id: groups[0].id, - completedGroupCount: 3, - activity: { completedTitle: 'Checked invoice inputs' }, - items: blocks.map((block) => ({ type: 'tool', data: { id: block.toolCall!.id } })), - }) + const finished = parseBlocks(completed) + expect(finished).toHaveLength(3) + expect(finished[0]).toMatchObject({ id: groups[0].id }) + expect( + finished.map((group) => group.type === 'agent_group' && group.activity?.completedTitle) + ).toEqual(['Checked invoice inputs', 'Checked customer inputs', 'Checked invoice inputs']) + expect( + finished.flatMap((group) => + group.type === 'agent_group' + ? group.items.map((item) => item.type === 'tool' && item.data.id) + : [] + ) + ).toEqual(['a1', 'a2', 'b1', 'a3', 'a4']) } ) - it('collapses completed contiguous batches independently across text and reused ids', () => { + it('keeps every completed activity under its own header across text and reused ids', () => { const blocks = [ activityCall('a1', 'Checking invoice inputs'), activityCall('b1', 'Checking customer inputs'), @@ -136,20 +154,24 @@ describe('top-level activity groups', () => { }) ) const segments = parseBlocks(blocks) - expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group']) + expect(segments.map((segment) => segment.type)).toEqual([ + 'agent_group', + 'agent_group', + 'text', + 'agent_group', + 'agent_group', + ]) const groups = segments.filter((segment) => segment.type === 'agent_group') - expect(groups[0].id).not.toBe(groups[1].id) - expect(groups.map((group) => group.completedGroupCount)).toEqual([2, 2]) + expect(new Set(groups.map((group) => group.id)).size).toBe(4) expect(groups.map((group) => group.activity?.completedTitle)).toEqual([ + 'Checked invoice inputs', 'Checked customer inputs', + 'Checked invoice inputs', 'Checked customer inputs', ]) expect( groups.map((group) => group.items.map((item) => item.type === 'tool' && item.data.id)) - ).toEqual([ - ['a1', 'b1'], - ['a2', 'b2'], - ]) + ).toEqual([['a1'], ['b1'], ['a2'], ['b2']]) }) it('keeps calls without new metadata inside the current sequential activity', () => { @@ -186,10 +208,23 @@ describe('top-level activity groups', () => { { isOpen: true, items: [{ data: { id: 'a2' } }] }, ]) const settled = parseBlocks(completed, false) - expect(settled).toHaveLength(1) - expect(settled[0]).toMatchObject({ isOpen: false, completedGroupCount: 3 }) + expect(settled).toMatchObject([ + { isOpen: false, activity: { completedTitle: 'Checked invoice inputs' } }, + { isOpen: false, activity: { completedTitle: 'Checked customer inputs' } }, + { isOpen: false, activity: { completedTitle: 'Checked invoice inputs' } }, + ]) const proseClosed = parseBlocks([...completed, mainText('The inputs are ready.')], true) - expect(proseClosed[0]).toMatchObject({ isOpen: false, completedGroupCount: 3 }) + expect(proseClosed.map((segment) => segment.type)).toEqual([ + 'agent_group', + 'agent_group', + 'agent_group', + 'text', + ]) + expect(proseClosed.slice(0, 3)).toMatchObject([ + { isOpen: false }, + { isOpen: false }, + { isOpen: false }, + ]) }) it('keeps one active group when parallel calls settle out of order without reordering history', () => { @@ -208,7 +243,10 @@ describe('top-level activity groups', () => { const open = parseBlocks(blocks, true).filter((segment) => segment.type === 'agent_group') expect(open.map((group) => group.isOpen)).toEqual([false, true]) expect(open[0].id).toBe(pending[0].type === 'agent_group' ? pending[0].id : '') - expect(parseBlocks(blocks)[0]).toMatchObject({ completedGroupCount: 2 }) + expect(parseBlocks(blocks)).toMatchObject([ + { activity: { title: 'Checking invoice inputs' }, items: [{ data: { id: 'a1' } }] }, + { activity: { title: 'Checking customer inputs' }, items: [{ data: { id: 'b1' } }] }, + ]) }) it('retains labels on id-only reuse and does not rewrite an earlier closed activity', () => { @@ -1024,20 +1062,49 @@ describe('parseBlocks legacy — thinking between top-level tools', () => { }) }) -describe('assistantMessageHasVisibleActivity', () => { - it('leaves the gap after a completed main tool to the turn indicator', () => { +describe('turn wait ownership', () => { + it('keeps the gap after a completed main tool with its open activity until prose closes it', () => { const blocks = [mainToolCall('finished', 'read')] - expect(assistantMessageHasVisibleActivity(parseBlocks(blocks), true)).toBe(false) - expect(assistantMessageHasVisibleActivity(parseBlocks(blocks), false)).toBe(false) - expect( - assistantMessageHasVisibleActivity(parseBlocks([...blocks, mainText('Done.')]), true) - ).toBe(false) + expect(ownsWait(parseBlocks(blocks, true), true)).toBe(true) + expect(ownsWait(parseBlocks(blocks), false)).toBe(false) + expect(ownsWait(parseBlocks([...blocks, mainText('Done.')], true), true)).toBe(false) + }) + + it.each(['error', 'cancelled'] as const)( + 'leaves the gap after a %s main tool to the turn indicator', + (status) => { + const blocks: ContentBlock[] = [ + { type: 'tool_call', toolCall: { id: 'last', name: 'read', status }, timestamp: 1 }, + ] + expect(ownsWait(parseBlocks(blocks, true), true)).toBe(false) + } + ) + + const searchCall = (id: string, status: 'executing' | 'success'): ContentBlock => ({ + type: 'tool_call', + toolCall: { id, name: 'search_workspace', status, params: { query: 'launch review' } }, + timestamp: 1, + }) + + it('lets a running main search, whose label shimmers, own the wait', () => { + const blocks = [mainToolCall('read', 'read'), searchCall('search', 'executing')] + expect(ownsWait(parseBlocks(blocks, true), true)).toBe(true) + }) + + it('leaves the gap after a finished trailing search, which shows static results, to thinking', () => { + const blocks = [mainToolCall('read', 'read'), searchCall('search', 'success')] + expect(ownsWait(parseBlocks(blocks, true), true)).toBe(false) + }) + + it('lets the open group own the gap once a later non-search call follows a search', () => { + const blocks = [searchCall('search', 'success'), mainToolCall('read', 'read')] + expect(ownsWait(parseBlocks(blocks, true), true)).toBe(true) }) it('leaves an empty open subagent to the turn indicator', () => { const segments = parseBlocks([subagentStart('workflow', 'S1', 'main')]) - expect(assistantMessageHasVisibleActivity(segments, true)).toBe(false) - expect(assistantMessageHasVisibleActivity(segments, false)).toBe(false) + expect(ownsWait(segments, true)).toBe(false) + expect(ownsWait(segments, false)).toBe(false) }) it.each([undefined, 'main'])('retains an earlier running tool with spanId=%s', (spanId) => { @@ -1051,15 +1118,14 @@ describe('assistantMessageHasVisibleActivity', () => { mainText('Reading the result.'), mainToolCall('latest', 'read'), ] - const segments = parseBlocks(blocks) + const segments = parseBlocks(blocks, true) expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group']) - expect(assistantMessageHasVisibleActivity(segments)).toBe(true) + expect(ownsWait(segments, true)).toBe(true) + expect(ownsWait(parseBlocks(blocks), false)).toBe(false) }) it('does not treat an open subagent lane as an executing tool row', () => { - expect( - assistantMessageHasVisibleActivity(parseBlocks([subagentStart('workflow', 'S1', 'main')])) - ).toBe(false) + expect(ownsWait(parseBlocks([subagentStart('workflow', 'S1', 'main')]))).toBe(false) }) it('keeps a visible executing tool as active work', () => { @@ -1072,7 +1138,8 @@ describe('assistantMessageHasVisibleActivity', () => { timestamp: 3, }, ] - expect(assistantMessageHasVisibleActivity(parseBlocks(blocks))).toBe(true) + expect(ownsWait(parseBlocks(blocks, true), true)).toBe(true) + expect(ownsWait(parseBlocks(blocks), false)).toBe(false) }) it('does not let open parallel lanes suppress the single turn-level indicator', () => { @@ -1080,7 +1147,7 @@ describe('assistantMessageHasVisibleActivity', () => { subagentStart('workflow', 'S1', 'main'), subagentStart('search', 'S2', 'main'), ] - expect(assistantMessageHasVisibleActivity(parseBlocks(blocks))).toBe(false) + expect(ownsWait(parseBlocks(blocks))).toBe(false) }) it('ignores the executing dispatch tool represented by its subagent lane', () => { @@ -1095,7 +1162,7 @@ describe('assistantMessageHasVisibleActivity', () => { parentToolCallId: 'dispatch-1', }, ] - expect(assistantMessageHasVisibleActivity(parseBlocks(blocks))).toBe(false) + expect(ownsWait(parseBlocks(blocks))).toBe(false) }) }) @@ -1232,7 +1299,7 @@ describe.each([undefined, 'main'])('watch presentation (%s)', (spanId) => { { type: 'agent_group', items: [{ type: 'tool', data: { id: 'other' } }] }, { type: 'text', content: 'Continuing independent work.' }, ]) - expect(assistantMessageHasVisibleActivity(segments, true)).toBe(false) + expect(ownsWait(segments, true)).toBe(false) }) it.each(['pending', 'completed', 'stopped'] as const)( 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 ac4538a41a6..bda17642585 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 @@ -28,16 +28,20 @@ import { } from '@/lib/mothership/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { - collectGroupTools, hasAgentGroupItemContent, hasPendingAgentGroup, + isAgentGroupResolved, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' -import { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' -import { getActivityStatusTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' +import { + getTurnLiveIndicators, + ownsTurnWait, + type TurnLiveIndicators, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { WatchActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity' import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations' +import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url' import { useToolResourceTitles } from '@/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles' import type { ContentBlock, @@ -71,7 +75,6 @@ interface TextSegment { interface AgentGroupSegment { activity?: ToolActivity - completedGroupCount?: number error?: string type: 'agent_group' id: string @@ -551,7 +554,10 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { ) } -/** Activities follow transcript order; unfinished parallel calls share one active group. */ +/** + * Activities follow transcript order; unfinished parallel calls share one + * active group. Each finished activity keeps its own header and summary. + */ function groupByActivity(segments: MessageSegment[], isStreaming: boolean): MessageSegment[] { const labels = new Map() return segments.flatMap((segment, index): MessageSegment[] => { @@ -584,17 +590,6 @@ function groupByActivity(segments: MessageSegment[], isStreaming: boolean): Mess } if (!current) return [segment] current.isOpen = isOpen - /** Finished contiguous activities keep the compact summary and chronological expanded history. */ - const summaryActivity = [...groups].reverse().find((group) => group.activity)?.activity - if ( - groups.length > 1 && - !isOpen && - summaryActivity && - segment.items.every((item) => item.type === 'tool') && - isAgentGroupResolved(segment.items) - ) { - return [{ ...segment, activity: summaryActivity, completedGroupCount: groups.length }] - } const firstWorking = groups.findIndex((group) => !isAgentGroupResolved(group.items)) if (firstWorking >= 0 && firstWorking < groups.length - 1) { const working = groups[firstWorking] @@ -896,23 +891,15 @@ export function assistantMessageHasRenderableContent( ) } -/** Only suppress the turn indicator when a tool or agent row is visibly active. */ -export function assistantMessageHasVisibleActivity( +/** The turn's live indicators, decided once by {@link getTurnLiveIndicators} for every lane. */ +function getMessageLiveIndicators( segments: MessageSegment[], - isStreaming = false -): boolean { - return segments.some((segment) => { - if (segment.type !== 'agent_group' || !segment.items.some(hasAgentGroupItemContent)) { - return false - } - const tools = collectGroupTools(segment.items) - if (tools.some((tool) => tool.status === 'executing')) return true - if (!isStreaming || segment.agentName === 'mothership') return false - const statusTool = getActivityStatusTool(tools) - return ( - (segment.isOpen || segment.isDelegating) && (!statusTool || statusTool.status === 'success') - ) - }) + isStreaming: boolean +): TurnLiveIndicators { + return getTurnLiveIndicators( + segments.flatMap((segment) => (segment.type === 'agent_group' ? [segment] : [])), + isStreaming + ) } export function shouldSmoothTextSegment({ @@ -1007,6 +994,7 @@ function MessageContentInner({ [blocks, fallbackContent, requestMode] ) const titledBlocks = useToolResourceTitles(cited.blocks) + const linkSources = useMemo(() => indexSourcesByUrl(cited.sources), [cited.sources]) const parsed = useMemo( () => (titledBlocks.length > 0 ? parseBlocks(titledBlocks, isStreaming) : []), [titledBlocks, blockOverlayVersion, isStreaming] @@ -1049,6 +1037,11 @@ function MessageContentInner({ [segments] ) const visibleStreamActivityKey = getVisibleStreamActivityKey(segments) + /** Decided once per parse, not on every idle-timer or text-reveal render. */ + const liveIndicators = useMemo( + () => getMessageLiveIndicators(segments, isStreaming), + [segments, isStreaming] + ) // Every visible stream update restarts the quiet-period clock. A layout // effect clears an already-visible shimmer before paint, so a chunk from any @@ -1092,11 +1085,10 @@ function MessageContentInner({ if (segments.length === 0 && !isLast) return null - /** Active tool and agent rows own the shimmer until the turn is waiting again. */ // 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 hasActivityIndicator = assistantMessageHasVisibleActivity(segments, isStreaming) + const hasActivityIndicator = ownsTurnWait(liveIndicators) const hasPendingAgents = isStreaming && segments.some((segment) => segment.type === 'agent_group' && hasPendingAgentGroup(segment)) @@ -1128,6 +1120,7 @@ function MessageContentInner({ messageId={messageId} imageRequestId={imageRequestId} requestMode={requestMode} + linkSources={linkSources} isStreaming={shouldSmoothTextSegment({ isStreaming, segmentIndex: i, @@ -1160,18 +1153,14 @@ function MessageContentInner({ >
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-sources.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-sources.ts index 6ba3bc05017..8d6dca8ea6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-sources.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-sources.ts @@ -3,6 +3,7 @@ import { type SourceTagData, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations' +import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url' import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' /** @@ -11,15 +12,12 @@ import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' * renders as its answer. */ export function collectMessageSources(texts: readonly string[]): SourceTagData[] { - const byUrl = new Map() - for (const text of texts) { - for (const segment of parseSpecialTags(text, false).segments) { - if (segment.type === 'source' && !byUrl.has(segment.data.url)) { - byUrl.set(segment.data.url, segment.data) - } - } - } - return [...byUrl.values()] + const cited = texts.flatMap((text) => + parseSpecialTags(text, false).segments.flatMap((segment) => + segment.type === 'source' ? [segment.data] : [] + ) + ) + return [...indexSourcesByUrl(cited).values()] } /** Only main-answer citations populate the panel, never every fetched result or an agent's scratch work. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts index b416e86b497..6c6e102fe81 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts @@ -38,6 +38,13 @@ function blocks(result: unknown = output): ContentBlock[] { ] } describe('evidence-linked citations', () => { + it('exposes the retrieved sources that title plain links to them', () => { + expect(resolveMessageCitations(blocks(), '').sources).toEqual([ + expect.objectContaining({ url: 'https://docs.example.test/a', title: 'Actual title' }), + ]) + expect(resolveMessageCitations([], '').sources).toEqual([]) + }) + it('uses returned metadata and escapes source-tag terminators', () => { const result = resolveMessageCitations(blocks(), '', true) expect(result.blocks[1].content).toContain('Actual title') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.ts index 1782bbc54ca..b625e59b790 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.ts @@ -4,7 +4,11 @@ import { } from '@/lib/mothership/chat/citation-evidence' import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' -/** Source cards use metadata from successful retrieval, never model-authored IDs or URLs. */ +/** + * Source cards use metadata from successful retrieval, never model-authored IDs + * or URLs. Also returns every retrieved source, so a plain link the answer + * writes to one of them can show that document's title. + */ export function resolveMessageCitations( blocks: readonly ContentBlock[], fallbackContent: string, @@ -48,5 +52,6 @@ export function resolveMessageCitations( block.content ? { ...block, content: resolve(block.content) } : block ), fallbackContent: resolve(fallbackContent), + sources: [...evidence.values()], } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/sources-by-url.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/sources-by-url.ts new file mode 100644 index 00000000000..10c4ce304e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/sources-by-url.ts @@ -0,0 +1,16 @@ +import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +/** + * Sources keyed by URL across the given lists, in order, where the first source + * seen for a URL wins. Every surface that lists or looks up a turn's sources + * dedupes through this. + */ +export function indexSourcesByUrl(...lists: Iterable[]): Map { + const byUrl = new Map() + for (const list of lists) { + for (const source of list) { + if (!byUrl.has(source.url)) byUrl.set(source.url, source) + } + } + return byUrl +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 5357f638a08..9c5690678d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -193,8 +193,8 @@ export const TOOL_ICONS: Readonly> = { workflow: Hammer, } -export function getAgentIcon(name: string, fallback: IconComponent = Blimp): IconComponent { - return Object.hasOwn(TOOL_ICONS, name) ? TOOL_ICONS[name] : fallback +export function getAgentIcon(name: string): IconComponent { + return Object.hasOwn(TOOL_ICONS, name) ? TOOL_ICONS[name] : Blimp } export function getToolIcon(name: string): IconComponent { diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx index 4e603e84c75..e7b853906e6 100644 --- a/apps/sim/components/ui/activity-status.tsx +++ b/apps/sim/components/ui/activity-status.tsx @@ -1,7 +1,13 @@ import type { ReactNode } from 'react' -import { OverflowText } from '@sim/emcn' +import { cn, OverflowText } from '@sim/emcn' import { ShimmerText } from '@/components/ui/shimmer-text' +/** The icon column every activity row shares, sized for the default 14px icon. */ +export const ACTIVITY_ICON_SLOT_CLASS = 'flex size-[14px] shrink-0 items-center justify-center' + +/** An activity row: the icon column, then the text column one `gap-2` to its right. */ +export const ACTIVITY_ROW_CLASS = 'flex min-w-0 items-center gap-2' + export interface ActivityStatusProps { label: string isActive: boolean @@ -11,11 +17,11 @@ export interface ActivityStatusProps { /** Inline tool status with the shared shimmer while active. */ export function ActivityStatus({ label, isActive, icon }: ActivityStatusProps) { return ( - + {icon && ( @@ -32,3 +38,20 @@ export function ActivityStatus({ label, isActive, icon }: ActivityStatusProps) { ) } + +interface ActivityTextColumnProps { + children: ReactNode +} + +/** + * Places content in the activity text column by reserving the icon column, so + * it lines up with every row's label without a hand-tuned offset. + */ +export function ActivityTextColumn({ children }: ActivityTextColumnProps) { + return ( +
+
+ ) +} diff --git a/apps/sim/lib/mothership/tools/tool-activity.test.ts b/apps/sim/lib/mothership/tools/tool-activity.test.ts index 568b487ea18..345d3735c01 100644 --- a/apps/sim/lib/mothership/tools/tool-activity.test.ts +++ b/apps/sim/lib/mothership/tools/tool-activity.test.ts @@ -1,10 +1,22 @@ /** * @vitest-environment node */ +import { isRecordLike } from '@sim/utils/object' import { describe, expect, it } from 'vitest' import { TOOL_CATALOG } from '@/lib/mothership/generated/tool-catalog-v1' +import { CLI_TOOL_TITLES } from '@/lib/mothership/tools/cli-tool-display' import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' -import { readToolActivity } from '@/lib/mothership/tools/tool-activity' +import { + getToolActivitySummaryActions, + readToolActivity, + TOOL_ACTIVITIES, +} from '@/lib/mothership/tools/tool-activity' + +/** One call's summary phrase, read through the public summary API. */ +function getToolActivityLabel(toolName: string, params?: Record): string { + return getToolActivitySummaryActions([{ toolName, params }], 1)[0] +} + import { TOOL_ICONS } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' const visibleTools = Object.values(TOOL_CATALOG).filter( @@ -75,3 +87,150 @@ describe('activity argument streaming', () => { expect(readToolActivity(undefined, JSON.stringify({ activity: escaped }))).toEqual(escaped) }) }) + +describe('tool activity catalog coverage', () => { + it.each(visibleTools)('explicitly describes $id and every declared operation', (tool) => { + expect(Object.hasOwn(TOOL_ACTIVITIES, tool.id), `${tool.id} summary`).toBe(true) + const activity = TOOL_ACTIVITIES[tool.id] + const properties = isRecordLike(tool.parameters) ? tool.parameters.properties : undefined + if (!isRecordLike(properties)) return + for (const parameter of ['operation', 'action'] as const) { + const schema = properties[parameter] + if (!isRecordLike(schema) || !Array.isArray(schema.enum)) continue + expect(typeof activity, `${tool.id}.${parameter}`).toBe('object') + if (typeof activity === 'string' || !('parameter' in activity)) continue + expect(activity.parameter).toBe(parameter) + expect(Object.keys(activity.operations).sort()).toEqual([...schema.enum].sort()) + if (typeof schema.default === 'string') { + expect(activity.defaultOperation).toBe(schema.default) + } + for (const operation of schema.enum) { + expect(getToolActivityLabel(tool.id, { [parameter]: operation })).not.toMatch( + /^used (tools|the browser)$/ + ) + } + } + }) + + /** `sim_cli` is the placeholder before a command is named; its own title describes it. */ + it.each(Object.keys(CLI_TOOL_TITLES).filter((name) => name !== 'sim_cli'))( + 'describes CLI tool %s with a concrete action', + (name) => { + expect(getToolActivityLabel(name)).not.toMatch(/^used (tools|the browser)$/) + } + ) +}) + +describe('getToolActivityLabel', () => { + it.each([ + ['browser_go_back', 'navigated pages'], + ['browser_batch', 'ran page actions'], + ['run_code', 'ran code'], + ['call_integration_tool', 'used integrations'], + ['terminal_run', 'ran commands'], + ['cli_tables_list', 'listed tables'], + ['cli_tables_rows_query', 'queried table rows'], + ['cli_files_set_content', 'wrote files'], + ['cli_knowledge_search', 'searched knowledge bases'], + ['cli_workflows_deploy', 'deployed workflows'], + ['cli_workflows_runs_get', 'checked workflow runs'], + ['cli_workflows_rollback', 'rolled back workflows'], + ['cli_workflow_mcp_servers_tools_create', 'published workflow MCP tools'], + ['cli_settings_workspace_update', 'updated settings'], + ['cli_workspaces_create', 'created workspaces'], + ['cli_help', 'read the CLI reference'], + ['cli_files_mkdir', 'created file folders'], + ['cli_knowledge_mkdir', 'created knowledge folders'], + ['cli_tables_mkdir', 'created table folders'], + ['cli_workflows_mkdir', 'created workflow folders'], + ['cli_tables_upsert', 'wrote table rows'], + ])('describes %s as %s', (toolName, expected) => { + expect(getToolActivityLabel(toolName)).toBe(expected) + }) + + it.each([ + ['terminal', 'handoff', 'handed over terminal control'], + ['table_rows', 'batch_insert_rows', 'added rows'], + ['manage_knowledge_base', 'query', 'searched sources'], + ['prepare_file_edit', 'create', 'prepared file edits'], + ])('describes %s %s accurately', (toolName, operation, expected) => { + expect(getToolActivityLabel(toolName, { operation })).toBe(expected) + }) + + it.each(['future_operation', '', null, 4, [], {}, 'constructor', 'toString', '__proto__'])( + 'uses neutral fallbacks for invalid or unknown operations: %j', + (operation) => { + expect(getToolActivityLabel('terminal', { operation })).toBe('used the terminal') + expect(getToolActivityLabel('deploy_as_api', { action: operation })).toBe('used deployments') + } + ) + + it.each(['future_tool', '', 'constructor', 'toString', '__proto__', 'cli_future_command'])( + 'keeps unknown or historical tool %s safe without a title', + (toolName) => { + expect(getToolActivityLabel(toolName)).toBe('used tools') + } + ) +}) + +describe('getToolActivitySummaryActions', () => { + it('orders distinct actions by first occurrence and stops at the limit', () => { + expect( + getToolActivitySummaryActions( + [ + { toolName: 'grep' }, + { toolName: 'read' }, + { toolName: 'grep' }, + { toolName: 'web_search' }, + { toolName: 'run_code' }, + ], + 3 + ) + ).toEqual(['searched files', 'read files', 'searched the web']) + }) + + it('shares an object only between adjacent chosen phrases', () => { + expect( + getToolActivitySummaryActions( + [{ toolName: 'cli_tables_list' }, { toolName: 'cli_tables_get' }, { toolName: 'read' }], + 3 + ) + ).toEqual(['listed', 'read tables', 'read files']) + expect( + getToolActivitySummaryActions( + [{ toolName: 'cli_tables_list' }, { toolName: 'read' }, { toolName: 'cli_tables_get' }], + 3 + ) + ).toEqual(['listed tables', 'read files', 'read tables']) + }) + + it('names the folder a CLI mkdir creates and shares it with other folder actions', () => { + expect( + getToolActivitySummaryActions( + [{ toolName: 'cli_files_mkdir' }, { toolName: 'cli_files_folders_list' }], + 3 + ) + ).toEqual(['created', 'listed file folders']) + expect( + getToolActivitySummaryActions( + [{ toolName: 'cli_tables_upsert' }, { toolName: 'cli_tables_rows_list' }], + 3 + ) + ).toEqual(['wrote', 'listed table rows']) + }) + + it('prefers a model description over the title for tools outside the catalog', () => { + expect( + getToolActivitySummaryActions( + [ + { + toolName: 'mcp-crm-sync_accounts', + displayTitle: 'Sync accounts', + activityDescription: 'Syncing CRM accounts', + }, + ], + 3 + ) + ).toEqual(['synced CRM accounts']) + }) +}) diff --git a/apps/sim/lib/mothership/tools/tool-activity.ts b/apps/sim/lib/mothership/tools/tool-activity.ts index f71c9d15f9c..2a1090b0a7e 100644 --- a/apps/sim/lib/mothership/tools/tool-activity.ts +++ b/apps/sim/lib/mothership/tools/tool-activity.ts @@ -1,5 +1,11 @@ import { isRecordLike } from '@sim/utils/object' import { ToolActivity } from '@/lib/mothership/generated/protocol' +import { + firstWordOf, + getToolCompletedTitle, + getToolDisplayTitle, + getToolStatusDisplayTitle, +} from '@/lib/mothership/tools/tool-display' /** One provider-independent activity shape for both parsed and still-streaming calls. */ export function readToolActivity( @@ -49,3 +55,635 @@ export function readToolActivity( } return undefined } + +/** Two actions on one object, so adjacent summary phrases can share it ("listed, read tables"). */ +interface SharedObjectActivity { + verb: string + object: string +} + +type ActivityPhrase = string | SharedObjectActivity + +/** A tool whose action depends on one enumerated argument. */ +interface OperationActivity { + label: string + parameter: 'operation' | 'action' + defaultOperation?: string + operations: Readonly> +} + +const PAGE_NAVIGATION = { + verb: 'navigated', + object: 'pages', +} as const satisfies SharedObjectActivity +const PAGE_READING = { verb: 'read', object: 'pages' } as const satisfies SharedObjectActivity +const PAGE_SEARCHING = { verb: 'searched', object: 'pages' } as const satisfies SharedObjectActivity +const PAGE_SCROLLING = { verb: 'scrolled', object: 'pages' } as const satisfies SharedObjectActivity + +const DEPLOYMENT_ACTIVITY: OperationActivity = { + label: 'used deployments', + parameter: 'action', + defaultOperation: 'deploy', + operations: { deploy: 'deployed workflows', undeploy: 'undeployed workflows' }, +} + +const QUERY_USER_TABLE_OPERATIONS = { + get: 'read tables', + get_schema: 'read tables', + get_row: 'read rows', + query_rows: 'searched rows', +} as const + +const TABLE_MANAGE_OPERATIONS = { + create: 'created tables', + create_from_file: 'created tables', + import_file: 'imported table data', + rename: 'renamed tables', +} as const + +const TABLE_ROWS_OPERATIONS = { + insert_row: 'added rows', + batch_insert_rows: 'added rows', + update_row: 'updated rows', + batch_update_rows: 'updated rows', + delete_row: 'deleted rows', + batch_delete_rows: 'deleted rows', + update_rows_by_filter: 'updated rows', + delete_rows_by_filter: 'deleted rows', +} as const + +const TABLE_COLUMNS_OPERATIONS = { + add_column: 'edited table columns', + rename_column: 'edited table columns', + delete_column: 'edited table columns', + update_column: 'edited table columns', +} as const + +const TABLE_AUTOMATIONS_OPERATIONS = { + list_workflow_outputs: 'read table automations', + add_workflow_group: 'edited table automations', + update_workflow_group: 'edited table automations', + delete_workflow_group: 'edited table automations', + add_workflow_group_output: 'edited table automations', + delete_workflow_group_output: 'edited table automations', + run_column: 'ran table automations', + cancel_table_runs: 'stopped table runs', +} as const + +const TABLE_ENRICHMENTS_OPERATIONS = { + list_enrichments: 'read table enrichments', + add_enrichment: 'configured enrichments', +} as const + +/** + * Client-owned action phrases for completed-activity summaries, keyed by tool + * name; the executable tool registry stays outside the UI bundle. Phrases are + * past tense so several of them read as one sentence. Named `sim_cli` commands + * (`cli_`) resolve through {@link CLI_ACTIVITY_OVERRIDES} and + * {@link CLI_ACTIVITY_OBJECTS}; a command not yet named keeps its own title. + */ +export const TOOL_ACTIVITIES: Readonly> = { + apply_file_edit: 'edited files', + browser_batch: 'ran page actions', + browser_click: 'clicked elements', + browser_click_at: 'clicked elements', + browser_close_tab: 'closed tabs', + browser_drag: 'dragged elements', + browser_extract: PAGE_READING, + browser_fill_form: 'filled forms', + browser_find: PAGE_SEARCHING, + browser_go_back: PAGE_NAVIGATION, + browser_go_forward: PAGE_NAVIGATION, + browser_hover: 'hovered over elements', + browser_insert_text: 'entered text', + browser_list_downloads: 'listed downloads', + browser_list_sessions: 'checked signed-in sites', + browser_list_tabs: 'listed tabs', + browser_navigate: PAGE_NAVIGATION, + browser_open_tab: 'opened tabs', + browser_open_url: PAGE_NAVIGATION, + browser_press_key: 'pressed keys', + browser_read_text: PAGE_READING, + browser_reload: PAGE_NAVIGATION, + browser_request_takeover: 'resumed browser control', + browser_save_download: 'saved downloads', + browser_screenshot: 'captured screenshots', + browser_scroll: PAGE_SCROLLING, + browser_select_option: 'selected options', + browser_set_checked: 'updated selections', + browser_snapshot: PAGE_READING, + browser_switch_tab: 'switched tabs', + browser_type: 'entered text', + browser_upload_file: 'uploaded files', + browser_wait_for: 'waited', + browser_zoom: { + label: 'adjusted page zoom', + parameter: 'action', + operations: { + in: 'adjusted page zoom', + out: 'adjusted page zoom', + reset: 'adjusted page zoom', + }, + }, + call_integration_tool: 'used integrations', + cancel_workflow_run: 'stopped workflow runs', + cli_help: 'read the CLI reference', + connect_slack_bot: 'connected integrations', + context_compaction: 'summarized context', + cp: 'copied resources', + create_empty_file: 'created files', + create_workflow: 'created workflows', + create_workspace_mcp_server: 'created MCP servers', + delete_workspace_mcp_server: 'deleted MCP servers', + deploy_as_api: DEPLOYMENT_ACTIVITY, + deploy_as_chat: DEPLOYMENT_ACTIVITY, + deploy_as_mcp: DEPLOYMENT_ACTIVITY, + diff_workflows: 'compared workflows', + download_file: 'downloaded files', + edit_workflow: 'edited workflows', + extract_doc_assets: 'extracted document assets', + ffmpeg: { + label: 'used media tools', + parameter: 'operation', + operations: { + overlay_audio: 'edited media', + mix_audio: 'edited media', + concat: 'edited media', + trim: 'edited media', + scale_pad: 'edited media', + overlay_image: 'edited media', + add_text: 'edited media', + fade: 'edited media', + extract_audio: 'edited media', + convert: 'edited media', + thumbnail: 'edited media', + probe: 'inspected media', + }, + }, + generate_api_key: 'created API keys', + generate_audio: 'generated audio', + generate_image: 'generated images', + generate_video: 'generated video', + get_block_outputs: 'read workflow outputs', + get_block_upstream_references: 'read workflows', + get_deployed_workflow_state: 'read deployments', + get_deployment_status: 'read deployments', + get_workflow_data: 'read workflows', + get_workflow_run_options: 'read workflows', + glob: 'found files', + grep: 'searched files', + import_local_files: 'imported local files', + interrupt_agent: 'stopped agents', + list_deployment_versions: 'read deployments', + list_integration_tools: 'read integration tools', + list_integrations: 'read integrations', + list_workspace_mcp_servers: 'read MCP servers', + list_workspaces: { verb: 'listed', object: 'workspaces' }, + load_deployment: 'loaded workflow versions', + load_integration_tool: 'loaded integration tools', + load_skill: 'loaded skills', + load_slide_layout: 'loaded slide layouts', + manage_credential: { + label: 'managed credentials', + parameter: 'operation', + operations: { + rename: 'renamed credentials', + delete: 'deleted credentials', + }, + }, + manage_custom_tool: { + label: 'managed custom tools', + parameter: 'operation', + operations: { + add: 'created custom tools', + edit: 'edited custom tools', + delete: 'deleted custom tools', + list: 'read custom tools', + }, + }, + manage_knowledge_base: { + label: 'used knowledge bases', + parameter: 'operation', + operations: { + create: 'created knowledge bases', + get: 'read knowledge bases', + query: 'searched sources', + add_file: 'added source documents', + update: 'updated knowledge bases', + delete_document: 'deleted source documents', + update_document: 'updated source documents', + list_tags: 'read source tags', + create_tag: 'edited source tags', + update_tag: 'edited source tags', + delete_tag: 'edited source tags', + get_tag_usage: 'read source tags', + add_connector: 'created connections', + update_connector: 'edited connections', + delete_connector: 'deleted connections', + sync_connector: 'synced sources', + }, + }, + manage_mcp_connection: { + label: 'managed connections', + parameter: 'operation', + operations: { + add: 'created connections', + edit: 'edited connections', + delete: 'deleted connections', + list: 'read connections', + }, + }, + manage_sandbox: { + label: 'managed sandboxes', + parameter: 'operation', + operations: { + add: 'created sandboxes', + edit: 'edited sandboxes', + delete: 'deleted sandboxes', + list: 'read sandboxes', + }, + }, + manage_skill: { + label: 'managed skills', + parameter: 'operation', + operations: { + add: 'created skills', + edit: 'edited skills', + delete: 'deleted skills', + list: 'read skills', + }, + }, + mkdir: 'created folders', + mv: 'moved resources', + oauth_get_auth_link: 'prepared sign-in links', + oauth_request_access: 'requested access', + open_resource: 'opened resources', + prepare_file_edit: { + label: 'prepared file edits', + parameter: 'operation', + operations: { + create: 'prepared file edits', + append: 'prepared file edits', + update: 'prepared file edits', + patch: 'prepared file edits', + }, + }, + promote_to_live: 'deployed workflows', + publish_custom_block: { + label: 'managed custom blocks', + parameter: 'action', + defaultOperation: 'deploy', + operations: { + deploy: 'published custom blocks', + undeploy: 'unpublished custom blocks', + }, + }, + query_logs: 'read logs', + query_user_table: { + label: 'read tables', + parameter: 'operation', + operations: QUERY_USER_TABLE_OPERATIONS, + }, + read: 'read files', + read_document: 'read documents', + read_local_file: 'read local files', + read_output: 'read tool outputs', + redeploy: 'deployed workflows', + remember: 'updated memory', + restore_resource: 'restored resources', + rm: 'deleted resources', + run_block: 'ran workflows', + run_code: 'ran code', + run_enrichment: 'ran enrichments', + run_from_block: 'ran workflows', + run_function: 'ran code', + run_workflow: 'ran workflows', + run_workflow_until_block: 'ran workflows', + save_upload: { + label: 'used uploaded files', + parameter: 'operation', + defaultOperation: 'save', + operations: { + save: 'saved files', + import: 'imported workflows', + extract: 'extracted files', + }, + }, + search_docs: 'read documentation', + search_integration_tools: 'found integrations', + search_knowledge_base: { + label: 'searched sources', + parameter: 'operation', + operations: { + get: 'read knowledge bases', + query: 'searched sources', + list_tags: 'read source tags', + }, + }, + search_library_docs: 'read documentation', + search_sources: { + label: 'checked search sources', + parameter: 'action', + operations: { + list: 'read search sources', + get: 'read search sources', + providers: 'read search sources', + setup: 'set up search sources', + approve: 'updated search sources', + }, + }, + search_workspace: 'searched the workspace', + set_block_enabled: 'edited workflows', + set_environment_variables: 'updated environment variables', + set_global_workflow_variables: 'updated workflow variables', + settings: { + label: 'checked settings', + parameter: 'action', + operations: { + list: { verb: 'read', object: 'settings' }, + get: { verb: 'read', object: 'settings' }, + describe: { verb: 'read', object: 'settings' }, + open: { verb: 'opened', object: 'settings' }, + update: { verb: 'updated', object: 'settings' }, + execute: { verb: 'updated', object: 'settings' }, + }, + }, + share_file: { + label: 'managed file sharing', + parameter: 'action', + defaultOperation: 'share', + operations: { + share: 'shared files', + unshare: 'stopped sharing files', + }, + }, + steer_agent: 'guided agents', + table_automations: { + label: 'used table automations', + parameter: 'operation', + operations: TABLE_AUTOMATIONS_OPERATIONS, + }, + table_columns: { + label: 'used table columns', + parameter: 'operation', + operations: TABLE_COLUMNS_OPERATIONS, + }, + table_enrichments: { + label: 'used table enrichments', + parameter: 'operation', + operations: TABLE_ENRICHMENTS_OPERATIONS, + }, + table_manage: { + label: 'used tables', + parameter: 'operation', + operations: TABLE_MANAGE_OPERATIONS, + }, + table_rows: { + label: 'used tables', + parameter: 'operation', + operations: TABLE_ROWS_OPERATIONS, + }, + table_views: { + label: 'used table views', + parameter: 'operation', + operations: { + list_views: 'read table views', + get_view: 'read table views', + create_view: 'edited table views', + update_view: 'edited table views', + delete_view: 'edited table views', + set_default_view: 'edited table views', + }, + }, + tail_agent: 'read agent progress', + task: 'delegated tasks', + terminal: { + label: 'used the terminal', + parameter: 'operation', + operations: { + run: 'ran commands', + read: 'read terminal output', + input: 'sent terminal input', + kill: 'stopped commands', + cwd: 'checked terminal locations', + list: 'listed terminals', + new: 'opened terminals', + switch: 'switched terminals', + close: 'closed terminals', + panes: 'listed terminal panes', + handoff: 'handed over terminal control', + }, + }, + terminal_cwd: 'checked terminal locations', + terminal_input: 'sent terminal input', + terminal_kill: 'stopped commands', + terminal_read: 'read terminal output', + terminal_run: 'ran commands', + update_deployment_version: 'updated deployment details', + update_workspace_mcp_server: 'updated MCP servers', + user_table: { + label: 'used tables', + parameter: 'operation', + operations: { + ...QUERY_USER_TABLE_OPERATIONS, + ...TABLE_MANAGE_OPERATIONS, + ...TABLE_ROWS_OPERATIONS, + ...TABLE_COLUMNS_OPERATIONS, + ...TABLE_AUTOMATIONS_OPERATIONS, + ...TABLE_ENRICHMENTS_OPERATIONS, + }, + }, + wait: 'waited', + wait_agents: 'waited', + web_crawl: 'read web pages', + web_fetch: 'read web pages', + web_scrape: 'read web pages', + web_search: 'searched the web', + workspaces: { verb: 'created', object: 'workspaces' }, +} + +/** + * CLI commands whose action is not " ": guidance and + * search helpers, connection links, verbs that need a particle, `mkdir` + * (which creates a folder of its resource kind, not the resource itself), and + * `tables upsert` (which inserts a row or updates the one it conflicts with). + */ +const CLI_ACTIVITY_OVERRIDES: Readonly> = { + cli_credentials_connect: 'created connection links', + cli_credentials_reconnect: 'created connection links', + cli_docs_search: 'searched Sim docs', + cli_files_mkdir: { verb: 'created', object: 'file folders' }, + cli_knowledge_mkdir: { verb: 'created', object: 'knowledge folders' }, + cli_tables_mkdir: { verb: 'created', object: 'table folders' }, + cli_tables_upsert: { verb: 'wrote', object: 'table rows' }, + cli_workflows_mkdir: { verb: 'created', object: 'workflow folders' }, + cli_grep: 'searched the workspace', + cli_integrations_list: 'found integration actions', + cli_knowledge_tags_cleanup: 'cleaned up knowledge tags', + cli_reference: 'read the CLI reference', + cli_search_query: 'searched the workspace', + cli_search_read: 'read documents', + cli_to_sandbox: 'saved results', + cli_workflows_rollback: 'rolled back workflows', + cli_workflows_runs_wait: 'waited for workflow runs', +} + +/** + * Plural resource named by a `cli_` tool, keyed by command-path + * prefix; the longest matching prefix wins. The verb comes from the command's + * own display title, so every CLI command shares one resource vocabulary. + */ +const CLI_ACTIVITY_OBJECTS: Readonly> = { + audit_logs: 'audit logs', + billing_logs: 'billing logs', + billing_status: 'billing status', + blocks: 'blocks', + chat_deployments: 'chat deployments', + connector_types: 'connector types', + credentials: 'credentials', + credentials_providers: 'credential providers', + custom_tools: 'custom tools', + files: 'files', + files_folders: 'file folders', + files_share: 'file sharing', + knowledge: 'knowledge bases', + knowledge_chunks: 'knowledge chunks', + knowledge_connectors: 'knowledge connectors', + knowledge_connectors_documents: 'connector documents', + knowledge_documents: 'documents', + knowledge_folders: 'knowledge folders', + knowledge_tags: 'knowledge tags', + logs: 'run logs', + mcp_servers: 'MCP servers', + mcp_servers_tools: 'MCP server tools', + meta: 'platform status', + outputs: 'tool outputs', + search_sources: 'search sources', + secrets: 'secrets', + settings: 'settings', + skills: 'skills', + skills_editors: 'skill editors', + tables: 'tables', + tables_cancel_runs: 'table runs', + tables_columns: 'table columns', + tables_dispatches: 'table runs', + tables_enrichment: 'enrichment runs', + tables_exports: 'table exports', + tables_folders: 'table folders', + tables_groups: 'workflow groups', + tables_imports: 'table imports', + tables_rows: 'table rows', + tables_views: 'table views', + tools: 'integration operations', + workflow: 'workflows', + workflow_blocks: 'workflow blocks', + workflow_deps: 'workflow inputs', + workflow_mcp_servers: 'workflow MCP servers', + workflow_mcp_servers_tools: 'workflow MCP tools', + workflow_trace: 'run traces', + workflows: 'workflows', + workflows_activate: 'workflow versions', + workflows_chat: 'chat deployments', + workflows_deployment: 'deployments', + workflows_deps: 'workflow inputs', + workflows_folders: 'workflow folders', + workflows_runs: 'workflow runs', + workflows_variables: 'workflow variables', + workflows_versions: 'workflow versions', + workspaces: 'workspaces', + workspaces_members: 'workspace members', +} + +const CLI_ACTIVITY_PREFIXES = Object.keys(CLI_ACTIVITY_OBJECTS).sort( + (left, right) => right.length - left.length +) + +/** A `sim_cli` command reads as " ", e.g. "listed tables". */ +function resolveCliActivity(toolName: string): ActivityPhrase | undefined { + if (Object.hasOwn(CLI_ACTIVITY_OVERRIDES, toolName)) return CLI_ACTIVITY_OVERRIDES[toolName] + if (!toolName.startsWith('cli_')) return undefined + const path = toolName.slice('cli_'.length) + const prefix = CLI_ACTIVITY_PREFIXES.find((key) => path === key || path.startsWith(`${key}_`)) + if (!prefix) return undefined + const verb = getToolCompletedTitle(firstWordOf(getToolDisplayTitle(toolName))) + return verb ? { verb: verb.toLowerCase(), object: CLI_ACTIVITY_OBJECTS[prefix] } : undefined +} + +/** Sentence-case a title for use mid-summary without lowercasing a leading acronym. */ +function toPhraseCase(title: string): string { + return /^[A-Z][a-z]/.test(title) ? title.charAt(0).toLowerCase() + title.slice(1) : title +} + +interface ToolActivitySubject { + toolName: string + params?: Record + /** Titles a call the catalog does not know, such as custom and MCP tools. */ + displayTitle?: string + activityDescription?: string +} + +/** + * One call's action phrase: its catalog phrase (an unknown operation falls back to + * the tool's label), else its `sim_cli` resource phrase, else a phrase from its own + * completed title, else a neutral summary. + */ +function resolveToolActivity({ + toolName, + params, + displayTitle, + activityDescription, +}: ToolActivitySubject): ActivityPhrase { + const activity = Object.hasOwn(TOOL_ACTIVITIES, toolName) ? TOOL_ACTIVITIES[toolName] : undefined + if (typeof activity === 'string' || (activity && 'verb' in activity)) return activity + if (activity) { + const suppliedOperation = params?.[activity.parameter] + const operation = + suppliedOperation === undefined ? activity.defaultOperation : suppliedOperation + return typeof operation === 'string' && Object.hasOwn(activity.operations, operation) + ? activity.operations[operation] + : activity.label + } + const cliActivity = resolveCliActivity(toolName) + if (cliActivity) return cliActivity + const title = displayTitle?.trim() + ? getToolStatusDisplayTitle(displayTitle, 'success', toolName, activityDescription) + : '' + if (title) return toPhraseCase(title) + return toolName.startsWith('browser_') ? 'used the browser' : 'used tools' +} + +function activityLabel(activity: ActivityPhrase): string { + return typeof activity === 'string' ? activity : `${activity.verb} ${activity.object}` +} + +/** + * Up to `limit` distinct action phrases for a finished activity's successful + * calls, e.g. `["navigated", "read pages", "clicked elements"]`. + * + * Phrases are chosen by first occurrence in transcript order, so the summary + * reads in the order the work happened and stays stable across replays and as + * more calls finish. Repeated actions collapse into one phrase, so a long run of one kind + * of call cannot crowd out the other kinds. Adjacent chosen phrases that act on + * the same object share it, compacted after the cap so the object stays visible. + */ +export function getToolActivitySummaryActions( + tools: ReadonlyArray, + limit: number +): string[] { + const unique = new Map() + for (const tool of tools) { + const activity = resolveToolActivity(tool) + const label = activityLabel(activity) + if (!unique.has(label)) unique.set(label, activity) + if (unique.size === limit) break + } + const visible = [...unique.values()] + return visible.map((activity, index) => { + const next = visible[index + 1] + return typeof activity !== 'string' && + typeof next !== 'string' && + next?.object === activity.object + ? activity.verb + : activityLabel(activity) + }) +} diff --git a/apps/sim/lib/mothership/tools/tool-display.test.ts b/apps/sim/lib/mothership/tools/tool-display.test.ts index e03d13a8df6..33b51f8d649 100644 --- a/apps/sim/lib/mothership/tools/tool-display.test.ts +++ b/apps/sim/lib/mothership/tools/tool-display.test.ts @@ -12,10 +12,12 @@ import { type ToolCatalogEntry, UserTableOperationValues, } from '@/lib/mothership/generated/tool-catalog-v1' +import { CLI_TOOL_TITLES } from '@/lib/mothership/tools/cli-tool-display' import { getHiddenToolNames } from '@/lib/mothership/tools/client/hidden-tools' import { getToolCompletedTitle, getToolDisplayTitle, + getToolInProgressTitle, getToolStatusDisplayTitle, getWaitCountdownTitle, humanizeToolName, @@ -1105,3 +1107,37 @@ describe('CLI service display titles', () => { expect(refineStreamingCliToolName('{"args":["settings","organization","invented"]}')).toBeNull() }) }) + +describe('getToolInProgressTitle', () => { + it.each(Object.keys(CLI_TOOL_TITLES))( + 'restores the in-progress title of completed %s', + (name) => { + const title = getToolDisplayTitle(name) + expect(getToolInProgressTitle(getToolCompletedTitle(title) ?? title, 'success')).toBe(title) + } + ) + + it.each(['constructor', 'toString', '__proto__', 'hasOwnProperty'])( + 'never reads an inherited property for a model-written %s description', + (word) => { + const description = `${word} of the invoice parser` + expect(getToolStatusDisplayTitle('Reading file', 'success', 'read', description)).toBe( + description + ) + expect(getToolInProgressTitle('Reading file', 'success', 'read', description)).toBe( + description + ) + expect(getToolStatusDisplayTitle('Reading file', 'cancelled', 'read', description)).toBe( + `Stopped: ${description}` + ) + } + ) + + it('leaves titles without a completed verb unchanged', () => { + expect(getToolInProgressTitle('Workflow Agent', 'success')).toBe('Workflow Agent') + expect(getToolInProgressTitle('Searching files', 'success')).toBe('Searching files') + expect(getToolInProgressTitle('Read the latest inbox emails', 'success')).toBe( + 'Reading the latest inbox emails' + ) + }) +}) diff --git a/apps/sim/lib/mothership/tools/tool-display.ts b/apps/sim/lib/mothership/tools/tool-display.ts index a390b8873d7..f67921ef0c4 100644 --- a/apps/sim/lib/mothership/tools/tool-display.ts +++ b/apps/sim/lib/mothership/tools/tool-display.ts @@ -1568,122 +1568,126 @@ export function cliFlagValues(args: ToolArgs, flag: string): string[] { * Present-participle to past-tense verb map for completed tool titles. Applied * to the leading word only, so "Searching online for X" -> "Searched online * for X" while non-gerund labels ("Run Agent", "Folder action") pass through. + * A `Map`, because titles include model-written text: a leading word such as + * "constructor" must never resolve to an inherited object property. */ -const COMPLETED_VERB_REWRITES: Record = { - Accessing: 'Accessed', - Adding: 'Added', - Applying: 'Applied', - Cancelling: 'Cancelled', - Calling: 'Called', - Checking: 'Checked', - Changing: 'Changed', - Clicking: 'Clicked', - Closing: 'Closed', - Combining: 'Combined', - Comparing: 'Compared', - Completing: 'Completed', - Converting: 'Converted', - Crawling: 'Crawled', - Creating: 'Created', - Deleting: 'Deleted', - Connecting: 'Connected', - Deploying: 'Deployed', - Dragging: 'Dragged', - Inserting: 'Inserted', - Publishing: 'Published', - Unpublishing: 'Unpublished', - Analyzing: 'Analyzed', - Disabling: 'Disabled', - Downloading: 'Downloaded', - Duplicating: 'Duplicated', - Editing: 'Edited', - Enabling: 'Enabled', - Executing: 'Executed', - Extracting: 'Extracted', - Fading: 'Faded', - Finding: 'Found', - Filling: 'Filled', - Gathering: 'Gathered', - Generating: 'Generated', - Going: 'Went', - Fetching: 'Fetched', - Tracing: 'Traced', - Wiring: 'Wired', - Configuring: 'Configured', - Looking: 'Looked', - Rotating: 'Rotated', - Hovering: 'Hovered', - Importing: 'Imported', - Inspecting: 'Inspected', - Listing: 'Listed', - Loading: 'Loaded', - Managing: 'Managed', - Mixing: 'Mixed', - Moving: 'Moved', - Opening: 'Opened', - Overwriting: 'Overwrote', - Preparing: 'Prepared', - Pressing: 'Pressed', - Processing: 'Processed', - Promoting: 'Promoted', - Querying: 'Queried', - Reading: 'Read', - Redeploying: 'Redeployed', - Reloading: 'Reloaded', - Removing: 'Removed', - Renaming: 'Renamed', - Requesting: 'Requested', - Resetting: 'Reset', - Resizing: 'Resized', - Restoring: 'Restored', - Running: 'Ran', - Saving: 'Saved', - Scanning: 'Scanned', - Scraping: 'Scraped', - Scrolling: 'Scrolled', - Searching: 'Searched', - Selecting: 'Selected', - Setting: 'Set', - Sharing: 'Shared', - Steering: 'Steered', - Stopping: 'Stopped', - Summarizing: 'Summarized', - Switching: 'Switched', - Syncing: 'Synced', - Taking: 'Took', - Toggling: 'Toggled', - Trimming: 'Trimmed', - Typing: 'Typed', - Unchecking: 'Unchecked', - Undeploying: 'Undeployed', - Unsharing: 'Unshared', - Updating: 'Updated', - Using: 'Used', - Validating: 'Validated', - Viewing: 'Viewed', - Waiting: 'Waited', - Writing: 'Wrote', - Zooming: 'Zoomed', - Activating: 'Activated', - Browsing: 'Browsed', - Cleaning: 'Cleaned', - Counting: 'Counted', - Delegating: 'Delegated', - Enriching: 'Enriched', - Exporting: 'Exported', - Following: 'Followed', - Granting: 'Granted', - Indexing: 'Indexed', - Reconnecting: 'Reconnected', - Resuming: 'Resumed', - Reverting: 'Reverted', - Revoking: 'Revoked', - Rewriting: 'Rewrote', - Rolling: 'Rolled', - Starting: 'Started', - Unzipping: 'Unzipped', - Uploading: 'Uploaded', -} +const COMPLETED_VERB_REWRITES = new Map( + Object.entries({ + Accessing: 'Accessed', + Adding: 'Added', + Applying: 'Applied', + Cancelling: 'Cancelled', + Calling: 'Called', + Checking: 'Checked', + Changing: 'Changed', + Clicking: 'Clicked', + Closing: 'Closed', + Combining: 'Combined', + Comparing: 'Compared', + Completing: 'Completed', + Converting: 'Converted', + Crawling: 'Crawled', + Creating: 'Created', + Deleting: 'Deleted', + Connecting: 'Connected', + Deploying: 'Deployed', + Dragging: 'Dragged', + Inserting: 'Inserted', + Publishing: 'Published', + Unpublishing: 'Unpublished', + Analyzing: 'Analyzed', + Disabling: 'Disabled', + Downloading: 'Downloaded', + Duplicating: 'Duplicated', + Editing: 'Edited', + Enabling: 'Enabled', + Executing: 'Executed', + Extracting: 'Extracted', + Fading: 'Faded', + Finding: 'Found', + Filling: 'Filled', + Gathering: 'Gathered', + Generating: 'Generated', + Going: 'Went', + Fetching: 'Fetched', + Tracing: 'Traced', + Wiring: 'Wired', + Configuring: 'Configured', + Looking: 'Looked', + Rotating: 'Rotated', + Hovering: 'Hovered', + Importing: 'Imported', + Inspecting: 'Inspected', + Listing: 'Listed', + Loading: 'Loaded', + Managing: 'Managed', + Mixing: 'Mixed', + Moving: 'Moved', + Opening: 'Opened', + Overwriting: 'Overwrote', + Preparing: 'Prepared', + Pressing: 'Pressed', + Processing: 'Processed', + Promoting: 'Promoted', + Querying: 'Queried', + Reading: 'Read', + Redeploying: 'Redeployed', + Reloading: 'Reloaded', + Removing: 'Removed', + Renaming: 'Renamed', + Requesting: 'Requested', + Resetting: 'Reset', + Resizing: 'Resized', + Restoring: 'Restored', + Running: 'Ran', + Saving: 'Saved', + Scanning: 'Scanned', + Scraping: 'Scraped', + Scrolling: 'Scrolled', + Searching: 'Searched', + Selecting: 'Selected', + Setting: 'Set', + Sharing: 'Shared', + Steering: 'Steered', + Stopping: 'Stopped', + Summarizing: 'Summarized', + Switching: 'Switched', + Syncing: 'Synced', + Taking: 'Took', + Toggling: 'Toggled', + Trimming: 'Trimmed', + Typing: 'Typed', + Unchecking: 'Unchecked', + Undeploying: 'Undeployed', + Unsharing: 'Unshared', + Updating: 'Updated', + Using: 'Used', + Validating: 'Validated', + Viewing: 'Viewed', + Waiting: 'Waited', + Writing: 'Wrote', + Zooming: 'Zoomed', + Activating: 'Activated', + Browsing: 'Browsed', + Cleaning: 'Cleaned', + Counting: 'Counted', + Delegating: 'Delegated', + Enriching: 'Enriched', + Exporting: 'Exported', + Following: 'Followed', + Granting: 'Granted', + Indexing: 'Indexed', + Reconnecting: 'Reconnected', + Resuming: 'Resumed', + Reverting: 'Reverted', + Revoking: 'Revoked', + Rewriting: 'Rewrote', + Rolling: 'Rolled', + Starting: 'Started', + Unzipping: 'Unzipped', + Uploading: 'Uploaded', + }) +) /** * Rewrite a resolved display title to its past-tense form for a successfully @@ -1692,17 +1696,51 @@ const COMPLETED_VERB_REWRITES: Record = { * handles the fallback for model-authored and legacy titles. */ export function getToolCompletedTitle(title: string): string | undefined { - const spaceIndex = title.indexOf(' ') - const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex) - const past = COMPLETED_VERB_REWRITES[firstWord] - if (!past) return undefined - return past + title.slice(firstWord.length) + const firstWord = firstWordOf(title) + const past = COMPLETED_VERB_REWRITES.get(firstWord) + return past ? past + title.slice(firstWord.length) : undefined +} + +/** Past-tense to present-participle map, the exact inverse of {@link COMPLETED_VERB_REWRITES}. */ +const ACTIVE_VERB_REWRITES = new Map( + [...COMPLETED_VERB_REWRITES].map(([active, past]) => [past, active]) +) + +/** + * Restore the in-progress wording of a title that {@link getToolCompletedTitle} + * already moved to the past tense, so an open activity can keep describing a + * finished call as ongoing work ("Searched files" -> "Searching files"). + * Titles without a known completed verb pass through unchanged. + */ +function getToolActiveTitle(title: string): string { + const firstWord = firstWordOf(title) + const active = ACTIVE_VERB_REWRITES.get(firstWord) + return active ? active + title.slice(firstWord.length) : title +} + +/** + * The title of a call described as work in progress, the wording an open + * activity uses for its latest call: a succeeded call returns to its + * in-progress verb, and any other status keeps its status-aware title. + */ +export function getToolInProgressTitle( + title: string, + status: string, + toolName?: string, + activityDescription?: string +): string { + return status === 'success' + ? getToolActiveTitle( + getToolStatusDisplayTitle(title, 'executing', toolName, activityDescription) + ) + : getToolStatusDisplayTitle(title, status, toolName, activityDescription) } /** Recognize terminal wording already supplied by the tool store or persisted history. */ const TERMINAL_TITLE_PREFIXES = new Set(['Failed', 'Attempted', 'Skipped', 'Stopped']) -function firstWordOf(title: string): string { +/** The leading word of a title, used to find its verb. */ +export function firstWordOf(title: string): string { const spaceIndex = title.indexOf(' ') return spaceIndex === -1 ? title : title.slice(0, spaceIndex) } @@ -1727,7 +1765,7 @@ function getToolOutcomeTitle( ) { return outcome + title.slice(statedOutcome.length) } - if (COMPLETED_VERB_REWRITES[firstWord]) { + if (COMPLETED_VERB_REWRITES.has(firstWord)) { return `${outcome} ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` } return `${outcome}: ${title}` @@ -1746,7 +1784,7 @@ function getNeutralToolActionTitle(title: string): string { if (action === title) return title const firstWord = firstWordOf(action) const gerund = firstWord.charAt(0).toUpperCase() + firstWord.slice(1) - return COMPLETED_VERB_REWRITES[gerund] ? gerund + action.slice(firstWord.length) : action + return COMPLETED_VERB_REWRITES.has(gerund) ? gerund + action.slice(firstWord.length) : action } /**