diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 74317dab9de..9a62f40f7cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -3,8 +3,10 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' import { ShimmerText } from '@/components/ui' +import { useSmoothText } from '@/hooks/use-smooth-text' import type { ToolCallData } from '../../../../types' import { getAgentIcon, isToolDone } from '../../utils' +import { renderInlineMarkdown } from './inline-markdown' import { ToolCallItem } from './tool-call-item' /** @@ -148,12 +150,11 @@ export function AgentGroup({ ) } return ( - - {item.content.trim()} - + content={item.content} + isStreaming={isStreaming && idx === items.length - 1} + /> ) })} @@ -165,6 +166,27 @@ export function AgentGroup({ ) } +interface NarrationTextProps { + content: string + /** This row is the group's live tail — pace its reveal like top-level text. */ + isStreaming: boolean +} + +/** + * A narration (thinking/text) row inside an agent group. The live tail row is + * paced with {@link useSmoothText} so streamed chunks reveal word-by-word + * instead of popping in, matching the top-level text treatment. + */ +function NarrationText({ content, isStreaming }: NarrationTextProps) { + const revealed = useSmoothText(content, isStreaming) + + return ( + + {renderInlineMarkdown(revealed.trim())} + + ) +} + interface BoundedViewportProps { children: React.ReactNode isStreaming: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx new file mode 100644 index 00000000000..7cd670a95c4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { isValidElement } from 'react' +import { describe, expect, it } from 'vitest' +import { renderInlineMarkdown } from './inline-markdown' + +function flattenText(node: React.ReactNode): string { + if (typeof node === 'string') return node + if (Array.isArray(node)) return node.map(flattenText).join('') + if (isValidElement<{ children?: React.ReactNode }>(node)) return flattenText(node.props.children) + return '' +} + +function findByType(parts: React.ReactNode[], type: string): React.ReactNode { + return parts.find((p) => isValidElement(p) && p.type === type) +} + +describe('renderInlineMarkdown', () => { + it('renders **bold** spans as strong elements', () => { + const parts = renderInlineMarkdown('The failing block is **ModalDenied** (a Slack block).') + const bold = findByType(parts, 'strong') + expect(bold).toBeDefined() + expect(flattenText(bold)).toBe('ModalDenied') + expect(flattenText(parts)).toBe('The failing block is ModalDenied (a Slack block).') + }) + + it('renders `code` spans as mono elements', () => { + const parts = renderInlineMarkdown('check the `webhook` payload') + expect(flattenText(findByType(parts, 'span'))).toBe('webhook') + expect(flattenText(parts)).toBe('check the webhook payload') + }) + + it('renders *italic* spans as em elements', () => { + const parts = renderInlineMarkdown('this is *important* context') + expect(flattenText(findByType(parts, 'em'))).toBe('important') + }) + + it('renders ***bold-italic*** as nested strong and em', () => { + const parts = renderInlineMarkdown('a ***wrapped*** word') + const bold = findByType(parts, 'strong') + expect(bold).toBeDefined() + expect(flattenText(parts)).toBe('a wrapped word') + }) + + it('renders links as their label text', () => { + const parts = renderInlineMarkdown('see [the docs](https://sim.ai/docs) for more') + expect(flattenText(parts)).toBe('see the docs for more') + }) + + it('keeps emphasis markers inside code spans verbatim', () => { + const parts = renderInlineMarkdown('pass `*args` and `**kwargs` through') + const codeTexts = parts + .filter((p) => isValidElement(p) && p.type === 'span') + .map((p) => flattenText(p)) + expect(codeTexts).toEqual(['*args', '**kwargs']) + expect(flattenText(parts)).toBe('pass *args and **kwargs through') + }) + + it('renders nested markers inside emphasis and link labels', () => { + expect( + flattenText(renderInlineMarkdown('read [**the docs**](https://sim.ai/docs) first')) + ).toBe('read the docs first') + expect(flattenText(renderInlineMarkdown('use *`glob`* patterns'))).toBe('use glob patterns') + }) + + it('leaves unterminated markers verbatim', () => { + expect(renderInlineMarkdown('a **dangling marker')).toEqual(['a **dangling marker']) + expect(renderInlineMarkdown('a `dangling tick')).toEqual(['a `dangling tick']) + }) + + it('does not italicize bare asterisks in math-like text', () => { + expect(renderInlineMarkdown('2 * 3 * 4')).toEqual(['2 * 3 * 4']) + }) + + it('never reclassifies plain text the tokenizer rejected', () => { + expect(renderInlineMarkdown('* x *')).toEqual(['* x *']) + expect(renderInlineMarkdown('** spaced bullets **')).toEqual(['** spaced bullets **']) + }) + + it('passes plain text through untouched', () => { + expect(renderInlineMarkdown('no markup here.')).toEqual(['no markup here.']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx new file mode 100644 index 00000000000..591745ef329 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx @@ -0,0 +1,56 @@ +import { Fragment, type ReactNode } from 'react' + +const INLINE_TOKEN = + /(\*{3}[^\s*](?:[^*\n]*[^\s*])?\*{3}|\*\*[^\s*](?:[^*\n]*[^\s*])?\*\*|\*[^\s*](?:[^*\n]*[^\s*])?\*|`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\))/g + +const LINK_TOKEN = /^\[([^\]\n]+)\]\([^\s)]+\)$/ + +/** + * Minimal inline-markdown renderer for agent-group narration rows. Supports + * `**bold**`, `*italic*`, `***bold-italic***`, `` `code` `` spans, and + * `[label](url)` links (rendered as their label — narration is prose, not + * navigation). Emphasis contents and link labels are rendered recursively so + * nested markers resolve; code spans stay verbatim. Everything else, + * including unterminated markers, renders as-is. Full Streamdown rendering is + * intentionally avoided here — these rows re-render on every streaming frame. + * + * Splitting on a single capturing group alternates plain text (even indices) + * and matched tokens (odd indices), so index parity is the exact + * discriminator — plain text that merely resembles a marker (e.g. `* x *`, + * rejected by the tokenizer's boundary rules) is never reclassified. + */ +export function renderInlineMarkdown(text: string): ReactNode[] { + return text.split(INLINE_TOKEN).map((part, i) => (i % 2 === 1 ? renderToken(part, i) : part)) +} + +function renderToken(part: string, key: number): ReactNode { + if (part.length > 6 && part.startsWith('***') && part.endsWith('***')) { + return ( + + {renderInlineMarkdown(part.slice(3, -3))} + + ) + } + if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) { + return ( + + {renderInlineMarkdown(part.slice(2, -2))} + + ) + } + if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ) + } + if (part.length > 2 && part.startsWith('*') && part.endsWith('*')) { + return {renderInlineMarkdown(part.slice(1, -1))} + } + const link = LINK_TOKEN.exec(part) + if (link) { + return {renderInlineMarkdown(link[1])} + } + return part +} 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 59d9bf69dd7..90776627f57 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,30 +1,9 @@ import { useMemo } from 'react' import { ShimmerText } from '@/components/ui' import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display' import type { ToolCallStatus } from '../../../../types' -import { getToolIcon, resolveToolDisplayState } from '../../utils' - -function CircleCheck({ className }: { className?: string }) { - return ( - - - - - ) -} +import { resolveToolDisplayState } from '../../utils' export function CircleStop({ className }: { className?: string }) { return ( @@ -42,58 +21,6 @@ export function CircleStop({ className }: { className?: string }) { ) } -function Hyphen({ className }: { className?: string }) { - return ( - - - - ) -} - -function CircleOutline({ className }: { className?: string }) { - return ( - - - - ) -} - -function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) { - const display = resolveToolDisplayState(status) - if (display === 'spinner') { - const Icon = getToolIcon(toolName) - if (Icon) { - return - } - return - } - if (display === 'cancelled') { - return - } - if (display === 'interrupted') { - return - } - const Icon = getToolIcon(toolName) - if (Icon) { - return - } - return -} - interface ToolCallItemProps { toolName: string displayTitle: string @@ -101,6 +28,12 @@ interface ToolCallItemProps { streamingArgs?: string } +/** + * A single tool-call row inside an agent group: shimmer while executing, a + * static label once terminal. For `workspace_file` the title is derived live + * from the streaming args; because that path bypasses the completed-title + * rewrite in `toToolData`, the past-tense flip is applied here on success. + */ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: ToolCallItemProps) { const liveWorkspaceFileTitle = useMemo(() => { if (toolName !== WorkspaceFile.id || !streamingArgs) return null @@ -132,13 +65,14 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: }, [toolName, streamingArgs]) const isExecuting = resolveToolDisplayState(status) === 'spinner' - const title = liveWorkspaceFileTitle || displayTitle + const liveTitle = liveWorkspaceFileTitle || displayTitle + const title = + status === 'success' && liveWorkspaceFileTitle + ? (getToolCompletedTitle(liveTitle) ?? liveTitle) + : liveTitle return ( -
-
- -
+
{isExecuting ? ( {title} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index b6a93bfbb34..20f778622db 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 @@ -266,3 +266,123 @@ describe('shouldSmoothTextSegment', () => { ) }) }) + +describe('completed tool titles', () => { + function queryLogsCall(status: 'executing' | 'success' | 'error', displayTitle?: string) { + return { + type: 'tool_call' as const, + toolCall: { id: 't1', name: 'query_logs', status, displayTitle }, + timestamp: 1, + } + } + + function firstToolTitle(blocks: ContentBlock[]): string { + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const tool = group.items.find((i) => i.type === 'tool') + if (!tool || tool.type !== 'tool') throw new Error('expected tool') + return tool.data.displayTitle + } + + it('rewrites query_logs to past tense on success', () => { + expect(firstToolTitle([queryLogsCall('success')])).toBe('Queried logs') + }) + + it('preserves the enriched workflow name in the past-tense title', () => { + expect(firstToolTitle([queryLogsCall('success', 'Querying logs for Invoice Bot')])).toBe( + 'Queried logs for Invoice Bot' + ) + }) + + it('keeps present tense while executing and on error', () => { + expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs') + expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs') + }) +}) + +describe('narration text seams', () => { + it('inserts a space between glued consecutive blocks', () => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'S1', 'main'), + { + type: 'subagent_thinking', + content: 'that triggered it.', + spanId: 'S1', + subagent: 'research', + timestamp: 2, + }, + { + type: 'subagent_text', + content: 'The failing block is X.', + spanId: 'S1', + subagent: 'research', + timestamp: 3, + }, + ] + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const text = group.items.find((i) => i.type === 'text') + if (!text || text.type !== 'text') throw new Error('expected text') + expect(text.content).toBe('that triggered it. The failing block is X.') + }) + + it('never inserts a space into a segment split mid-word or mid-URL', () => { + const seam = (first: string, second: string): string => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'S1', 'main'), + { type: 'subagent_text', content: first, spanId: 'S1', subagent: 'research', timestamp: 2 }, + { + type: 'subagent_text', + content: second, + spanId: 'S1', + subagent: 'research', + timestamp: 3, + }, + ] + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const text = group.items.find((i) => i.type === 'text') + if (!text || text.type !== 'text') throw new Error('expected text') + return text.content + } + + expect(seam('the fox jum', 'ps over')).toBe('the fox jumps over') + expect(seam('see https://example', '/path for details')).toBe( + 'see https://example/path for details' + ) + expect(seam('日本語のテキストが分割', 'されても壊れない')).toBe( + '日本語のテキストが分割されても壊れない' + ) + expect(seam('released in v2.', '1 last week')).toBe('released in v2.1 last week') + expect(seam('pi is 3.', '14 roughly')).toBe('pi is 3.14 roughly') + }) + + it('does not double-space when the seam already has whitespace', () => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'S1', 'main'), + { + type: 'subagent_text', + content: 'first sentence. ', + spanId: 'S1', + subagent: 'research', + timestamp: 2, + }, + { + type: 'subagent_text', + content: 'second sentence.', + spanId: 'S1', + subagent: 'research', + timestamp: 3, + }, + ] + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const text = group.items.find((i) => i.type === 'text') + if (!text || text.type !== 'text') throw new Error('expected text') + expect(text.content).toBe('first sentence. second sentence.') + }) +}) 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 e4286bf5e22..dadd78aab6e 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 @@ -5,7 +5,11 @@ import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-ca import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { getToolDisplayTitle, humanizeToolName } from '@/lib/copilot/tools/tool-display' +import { + getToolCompletedTitle, + getToolDisplayTitle, + humanizeToolName, +} from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import type { ContentBlock, OptionItem, ToolCallData } from '../../types' import { SUBAGENT_LABELS } from '../../types' @@ -101,8 +105,12 @@ function getOverrideDisplayTitle(tc: NonNullable): str function toToolData(tc: NonNullable): ToolCallData { const overrideDisplayTitle = getOverrideDisplayTitle(tc) - const displayTitle = + const resolvedTitle = overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params) + const displayTitle = + tc.status === 'success' + ? (getToolCompletedTitle(resolvedTitle) ?? resolvedTitle) + : resolvedTitle return { id: tc.id, @@ -129,13 +137,34 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { } } -function appendTextItem(group: AgentGroupSegment, content: string): void { +type NarrationChannel = 'thinking' | 'assistant' + +/** + * Appends narration content to a group, merging into the previous text item. + * When a thinking run and a text run meet, their contents can glue together + * without any whitespace at the seam. The merge repairs only that semantic + * channel transition, and only at an unambiguous sentence boundary — trailing + * punctuation meeting a fresh alphanumeric start. Same-channel continuations + * (streamed chunks of one run, resume legs) are concatenated verbatim, so a + * token split like `v2.` + `1` is never mutated. `lastChannelByGroup` is the + * caller's per-parse tracker of each group's most recent narration channel. + */ +function appendTextItem( + group: AgentGroupSegment, + content: string, + channel: NarrationChannel, + lastChannelByGroup: Map +): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { - lastItem.content += content + const isChannelSeam = lastChannelByGroup.get(group) !== channel + const needsSpace = + isChannelSeam && /[.!?;:]$/.test(lastItem.content) && /^[A-Za-z0-9]/.test(content) + lastItem.content += (needsSpace ? ' ' : '') + content } else { group.items.push({ type: 'text', content }) } + lastChannelByGroup.set(group, channel) } /** @@ -149,6 +178,7 @@ function appendTextItem(group: AgentGroupSegment, content: string): void { function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsBySpanId = new Map() + const lastNarrationChannel = new Map() // Stable per-run counters for React keys. The Nth top-level text run / Nth // mothership group keeps the same key across re-parses (text runs and groups // are append-only at the top level), so React never remounts the streaming @@ -255,7 +285,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { } if (!g) continue g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem( + g, + block.content, + block.type === 'subagent_thinking' ? 'thinking' : 'assistant', + lastNarrationChannel + ) continue } @@ -271,7 +306,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId) if (g) { g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem(g, block.content, 'assistant', lastNarrationChannel) continue } } @@ -400,6 +435,7 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsByKey = new Map() + const lastNarrationChannel = new Map() let activeGroupKey: string | null = null const groupKey = (name: string, parentToolCallId: string | undefined) => @@ -471,12 +507,12 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const g = findGroupForSubagentChunk(block.parentToolCallId) if (!g) continue g.isDelegating = false - const lastItem = g.items[g.items.length - 1] - if (lastItem?.type === 'text') { - lastItem.content += block.content - } else { - g.items.push({ type: 'text', content: block.content }) - } + appendTextItem( + g, + block.content, + block.type === 'subagent_thinking' ? 'thinking' : 'assistant', + lastNarrationChannel + ) continue } @@ -494,12 +530,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId)) if (g) { g.isDelegating = false - const lastItem = g.items[g.items.length - 1] - if (lastItem?.type === 'text') { - lastItem.content += block.content - } else { - g.items.push({ type: 'text', content: block.content }) - } + appendTextItem(g, block.content, 'assistant', lastNarrationChannel) continue } } 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 6a06125c07a..767575849b9 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 @@ -71,11 +71,6 @@ export function getAgentIcon(name: string): IconComponent { return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp } -export function getToolIcon(name: string): IconComponent | undefined { - const icon = TOOL_ICONS[name as keyof typeof TOOL_ICONS] - return icon === Blimp ? undefined : icon -} - export type MessagePhase = 'streaming' | 'revealing' | 'settled' interface DeriveMessagePhaseArgs { diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts new file mode 100644 index 00000000000..353750417a2 --- /dev/null +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getToolCompletedTitle, + getToolDisplayTitle, + humanizeToolName, +} from '@/lib/copilot/tools/tool-display' + +describe('humanizeToolName', () => { + it('title-cases snake_case names', () => { + expect(humanizeToolName('manage_folder')).toBe('Manage Folder') + }) + + it('keeps canonical acronym casing', () => { + expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server') + expect(humanizeToolName('deploy_api')).toBe('Deploy API') + expect(humanizeToolName('oauth_request_access')).toBe('OAuth Request Access') + }) +}) + +describe('getToolDisplayTitle natural-language coverage', () => { + it('gives gerund titles to tools that previously fell through to humanize', () => { + expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API') + expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') + expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link') + expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') + }) + + it('falls back to running code for function_execute without a title', () => { + expect(getToolDisplayTitle('function_execute')).toBe('Running code') + expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( + 'Crunching numbers' + ) + }) +}) + +describe('getToolCompletedTitle', () => { + it('flips a leading gerund to past tense', () => { + expect(getToolCompletedTitle('Querying logs')).toBe('Queried logs') + expect(getToolCompletedTitle('Querying logs for Invoice Bot')).toBe( + 'Queried logs for Invoice Bot' + ) + expect(getToolCompletedTitle('Searching online for pricing')).toBe( + 'Searched online for pricing' + ) + expect(getToolCompletedTitle('Creating workflow')).toBe('Created workflow') + expect(getToolCompletedTitle('Running workflow')).toBe('Ran workflow') + expect(getToolCompletedTitle('Reading file')).toBe('Read file') + }) + + it('returns undefined for non-gerund titles', () => { + expect(getToolCompletedTitle('Run Agent')).toBeUndefined() + expect(getToolCompletedTitle('Folder action')).toBeUndefined() + expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5ee25a69959..099503901d1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -10,7 +10,7 @@ import { stripVersionSuffix } from '@sim/utils/string' * enrichment for the run_* tools; every other surface (server persistence, * transcript replay, fallback rendering) calls `getToolDisplayTitle` directly. * - * Icons are likewise client-owned — see `getToolIcon` in the message-content + * Icons are likewise client-owned — see `getAgentIcon` in the message-content * utils. Nothing about tool presentation lives on the Go side anymore. */ @@ -87,6 +87,57 @@ const TOOL_TITLES: Record = { generate_audio: 'Generating audio', ffmpeg: 'Processing media', manage_folder: 'Folder action', + check_deployment_status: 'Checking deployment status', + complete_scheduled_task: 'Completing scheduled task', + create_file: 'Creating file', + create_file_folder: 'Creating folder', + create_workspace_mcp_server: 'Creating MCP server', + delete_file: 'Deleting file', + delete_file_folder: 'Deleting folder', + delete_workflow: 'Deleting workflow', + delete_workspace_mcp_server: 'Deleting MCP server', + deploy_api: 'Deploying API', + deploy_chat: 'Deploying chat', + deploy_custom_block: 'Deploying custom block', + deploy_mcp: 'Deploying MCP server', + diff_workflows: 'Comparing workflows', + download_to_workspace_file: 'Downloading file', + function_execute: 'Running code', + generate_api_key: 'Generating API key', + get_block_outputs: 'Getting block outputs', + get_block_upstream_references: 'Getting block references', + get_deployed_workflow_state: 'Getting deployed workflow', + get_deployment_log: 'Getting deployment logs', + get_platform_actions: 'Getting platform actions', + get_scheduled_task_logs: 'Getting scheduled task logs', + get_workflow_data: 'Getting workflow data', + get_workflow_run_options: 'Getting run options', + list_file_folders: 'Listing folders', + list_integration_tools: 'Listing integrations', + list_user_workspaces: 'Listing workspaces', + list_workspace_mcp_servers: 'Listing MCP servers', + load_deployment: 'Loading deployment', + materialize_file: 'Preparing file', + move_file: 'Moving file', + move_file_folder: 'Moving folder', + move_workflow: 'Moving workflow', + oauth_get_auth_link: 'Getting authorization link', + oauth_request_access: 'Requesting access', + promote_to_live: 'Promoting to live', + redeploy: 'Redeploying', + rename_file: 'Renaming file', + rename_file_folder: 'Renaming folder', + rename_workflow: 'Renaming workflow', + restore_resource: 'Restoring resource', + run_block: 'Running block', + search_documentation: 'Searching documentation', + search_patterns: 'Searching patterns', + set_block_enabled: 'Toggling block', + set_environment_variables: 'Setting environment variables', + set_global_workflow_variables: 'Setting workflow variables', + update_deployment_version: 'Updating deployment', + update_scheduled_task_history: 'Updating task history', + update_workspace_mcp_server: 'Updating MCP server', // Subagent trigger tools, when surfaced as a tool call. workflow: 'Workflow Agent', run: 'Run Agent', @@ -101,6 +152,16 @@ const TOOL_TITLES: Record = { superagent: 'Executing action', } +/** Acronyms that must keep their canonical casing when humanized. */ +const ACRONYM_CASING: Record = { + mcp: 'MCP', + api: 'API', + oauth: 'OAuth', + url: 'URL', + id: 'ID', + ai: 'AI', +} + /** * Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage * Folder"), matching the legacy client humanizer so labels never render blank. @@ -108,7 +169,9 @@ const TOOL_TITLES: Record = { export function humanizeToolName(name: string): string { const words = stripVersionSuffix(name).split('_').filter(Boolean) if (words.length === 0) return name - return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') + return words + .map((word) => ACRONYM_CASING[word] ?? word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') } /** @@ -221,3 +284,65 @@ export function getToolDisplayTitle(name: string, args?: Record return TOOL_TITLES[name] ?? humanizeToolName(name) } + +/** + * 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. + */ +const COMPLETED_VERB_REWRITES: Record = { + Accessing: 'Accessed', + Adding: 'Added', + Applying: 'Applied', + Checking: 'Checked', + Comparing: 'Compared', + Completing: 'Completed', + Crawling: 'Crawled', + Creating: 'Created', + Deleting: 'Deleted', + Deploying: 'Deployed', + Downloading: 'Downloaded', + Editing: 'Edited', + Executing: 'Executed', + Finding: 'Found', + Gathering: 'Gathered', + Generating: 'Generated', + Getting: 'Got', + Listing: 'Listed', + Loading: 'Loaded', + Managing: 'Managed', + Moving: 'Moved', + Opening: 'Opened', + Preparing: 'Prepared', + Processing: 'Processed', + Promoting: 'Promoted', + Querying: 'Queried', + Reading: 'Read', + Redeploying: 'Redeployed', + Renaming: 'Renamed', + Requesting: 'Requested', + Restoring: 'Restored', + Running: 'Ran', + Scraping: 'Scraped', + Searching: 'Searched', + Setting: 'Set', + Toggling: 'Toggled', + Updating: 'Updated', + Validating: 'Validated', + Writing: 'Wrote', +} + +/** + * Rewrite a resolved display title to its past-tense form for a successfully + * completed tool call (e.g. "Querying logs for X" -> "Queried logs for X"). + * Operates on the already-resolved title so enriched and persisted titles both + * work. Returns undefined when the title has no leading gerund rewrite — the + * caller keeps the original. + */ +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) +}