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 } /**