Skip to content

Commit 8d7d590

Browse files
authored
feat(chat): natural-language tool titles, past-tense completion, and smooth agent-group narration (#5660)
* feat(chat): natural-language tool titles, past-tense completion, and smooth agent-group narration * improvement(chat): drop per-row status icons on subagent tool calls * fix(chat): restrict narration seam space to sentence boundaries * improvement(chat): promote inline comments to TSDoc, support bold-italic in narration markdown * fix(chat): gate narration seam repair on channel transitions, render nested inline markdown recursively * improvement(chat): flip Gathering thoughts to past tense on completion * fix(chat): classify inline-markdown tokens by split parity, require non-space emphasis boundaries
1 parent 3d6c159 commit 8d7d590

9 files changed

Lines changed: 535 additions & 110 deletions

File tree

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
44
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
55
import { ShimmerText } from '@/components/ui'
6+
import { useSmoothText } from '@/hooks/use-smooth-text'
67
import type { ToolCallData } from '../../../../types'
78
import { getAgentIcon, isToolDone } from '../../utils'
9+
import { renderInlineMarkdown } from './inline-markdown'
810
import { ToolCallItem } from './tool-call-item'
911

1012
/**
@@ -148,12 +150,11 @@ export function AgentGroup({
148150
)
149151
}
150152
return (
151-
<span
153+
<NarrationText
152154
key={`text-${idx}`}
153-
className='pl-6 text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'
154-
>
155-
{item.content.trim()}
156-
</span>
155+
content={item.content}
156+
isStreaming={isStreaming && idx === items.length - 1}
157+
/>
157158
)
158159
})}
159160
</div>
@@ -165,6 +166,27 @@ export function AgentGroup({
165166
)
166167
}
167168

169+
interface NarrationTextProps {
170+
content: string
171+
/** This row is the group's live tail — pace its reveal like top-level text. */
172+
isStreaming: boolean
173+
}
174+
175+
/**
176+
* A narration (thinking/text) row inside an agent group. The live tail row is
177+
* paced with {@link useSmoothText} so streamed chunks reveal word-by-word
178+
* instead of popping in, matching the top-level text treatment.
179+
*/
180+
function NarrationText({ content, isStreaming }: NarrationTextProps) {
181+
const revealed = useSmoothText(content, isStreaming)
182+
183+
return (
184+
<span className='pl-6 text-[13px] text-[var(--text-muted)] leading-[18px]'>
185+
{renderInlineMarkdown(revealed.trim())}
186+
</span>
187+
)
188+
}
189+
168190
interface BoundedViewportProps {
169191
children: React.ReactNode
170192
isStreaming: boolean
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { isValidElement } from 'react'
5+
import { describe, expect, it } from 'vitest'
6+
import { renderInlineMarkdown } from './inline-markdown'
7+
8+
function flattenText(node: React.ReactNode): string {
9+
if (typeof node === 'string') return node
10+
if (Array.isArray(node)) return node.map(flattenText).join('')
11+
if (isValidElement<{ children?: React.ReactNode }>(node)) return flattenText(node.props.children)
12+
return ''
13+
}
14+
15+
function findByType(parts: React.ReactNode[], type: string): React.ReactNode {
16+
return parts.find((p) => isValidElement(p) && p.type === type)
17+
}
18+
19+
describe('renderInlineMarkdown', () => {
20+
it('renders **bold** spans as strong elements', () => {
21+
const parts = renderInlineMarkdown('The failing block is **ModalDenied** (a Slack block).')
22+
const bold = findByType(parts, 'strong')
23+
expect(bold).toBeDefined()
24+
expect(flattenText(bold)).toBe('ModalDenied')
25+
expect(flattenText(parts)).toBe('The failing block is ModalDenied (a Slack block).')
26+
})
27+
28+
it('renders `code` spans as mono elements', () => {
29+
const parts = renderInlineMarkdown('check the `webhook` payload')
30+
expect(flattenText(findByType(parts, 'span'))).toBe('webhook')
31+
expect(flattenText(parts)).toBe('check the webhook payload')
32+
})
33+
34+
it('renders *italic* spans as em elements', () => {
35+
const parts = renderInlineMarkdown('this is *important* context')
36+
expect(flattenText(findByType(parts, 'em'))).toBe('important')
37+
})
38+
39+
it('renders ***bold-italic*** as nested strong and em', () => {
40+
const parts = renderInlineMarkdown('a ***wrapped*** word')
41+
const bold = findByType(parts, 'strong')
42+
expect(bold).toBeDefined()
43+
expect(flattenText(parts)).toBe('a wrapped word')
44+
})
45+
46+
it('renders links as their label text', () => {
47+
const parts = renderInlineMarkdown('see [the docs](https://sim.ai/docs) for more')
48+
expect(flattenText(parts)).toBe('see the docs for more')
49+
})
50+
51+
it('keeps emphasis markers inside code spans verbatim', () => {
52+
const parts = renderInlineMarkdown('pass `*args` and `**kwargs` through')
53+
const codeTexts = parts
54+
.filter((p) => isValidElement(p) && p.type === 'span')
55+
.map((p) => flattenText(p))
56+
expect(codeTexts).toEqual(['*args', '**kwargs'])
57+
expect(flattenText(parts)).toBe('pass *args and **kwargs through')
58+
})
59+
60+
it('renders nested markers inside emphasis and link labels', () => {
61+
expect(
62+
flattenText(renderInlineMarkdown('read [**the docs**](https://sim.ai/docs) first'))
63+
).toBe('read the docs first')
64+
expect(flattenText(renderInlineMarkdown('use *`glob`* patterns'))).toBe('use glob patterns')
65+
})
66+
67+
it('leaves unterminated markers verbatim', () => {
68+
expect(renderInlineMarkdown('a **dangling marker')).toEqual(['a **dangling marker'])
69+
expect(renderInlineMarkdown('a `dangling tick')).toEqual(['a `dangling tick'])
70+
})
71+
72+
it('does not italicize bare asterisks in math-like text', () => {
73+
expect(renderInlineMarkdown('2 * 3 * 4')).toEqual(['2 * 3 * 4'])
74+
})
75+
76+
it('never reclassifies plain text the tokenizer rejected', () => {
77+
expect(renderInlineMarkdown('* x *')).toEqual(['* x *'])
78+
expect(renderInlineMarkdown('** spaced bullets **')).toEqual(['** spaced bullets **'])
79+
})
80+
81+
it('passes plain text through untouched', () => {
82+
expect(renderInlineMarkdown('no markup here.')).toEqual(['no markup here.'])
83+
})
84+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Fragment, type ReactNode } from 'react'
2+
3+
const INLINE_TOKEN =
4+
/(\*{3}[^\s*](?:[^*\n]*[^\s*])?\*{3}|\*\*[^\s*](?:[^*\n]*[^\s*])?\*\*|\*[^\s*](?:[^*\n]*[^\s*])?\*|`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\))/g
5+
6+
const LINK_TOKEN = /^\[([^\]\n]+)\]\([^\s)]+\)$/
7+
8+
/**
9+
* Minimal inline-markdown renderer for agent-group narration rows. Supports
10+
* `**bold**`, `*italic*`, `***bold-italic***`, `` `code` `` spans, and
11+
* `[label](url)` links (rendered as their label — narration is prose, not
12+
* navigation). Emphasis contents and link labels are rendered recursively so
13+
* nested markers resolve; code spans stay verbatim. Everything else,
14+
* including unterminated markers, renders as-is. Full Streamdown rendering is
15+
* intentionally avoided here — these rows re-render on every streaming frame.
16+
*
17+
* Splitting on a single capturing group alternates plain text (even indices)
18+
* and matched tokens (odd indices), so index parity is the exact
19+
* discriminator — plain text that merely resembles a marker (e.g. `* x *`,
20+
* rejected by the tokenizer's boundary rules) is never reclassified.
21+
*/
22+
export function renderInlineMarkdown(text: string): ReactNode[] {
23+
return text.split(INLINE_TOKEN).map((part, i) => (i % 2 === 1 ? renderToken(part, i) : part))
24+
}
25+
26+
function renderToken(part: string, key: number): ReactNode {
27+
if (part.length > 6 && part.startsWith('***') && part.endsWith('***')) {
28+
return (
29+
<strong key={key} className='font-semibold'>
30+
<em>{renderInlineMarkdown(part.slice(3, -3))}</em>
31+
</strong>
32+
)
33+
}
34+
if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) {
35+
return (
36+
<strong key={key} className='font-semibold'>
37+
{renderInlineMarkdown(part.slice(2, -2))}
38+
</strong>
39+
)
40+
}
41+
if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
42+
return (
43+
<span key={key} className='font-mono text-[12px]'>
44+
{part.slice(1, -1)}
45+
</span>
46+
)
47+
}
48+
if (part.length > 2 && part.startsWith('*') && part.endsWith('*')) {
49+
return <em key={key}>{renderInlineMarkdown(part.slice(1, -1))}</em>
50+
}
51+
const link = LINK_TOKEN.exec(part)
52+
if (link) {
53+
return <Fragment key={key}>{renderInlineMarkdown(link[1])}</Fragment>
54+
}
55+
return part
56+
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx

Lines changed: 14 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,9 @@
11
import { useMemo } from 'react'
22
import { ShimmerText } from '@/components/ui'
33
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
4+
import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display'
45
import type { ToolCallStatus } from '../../../../types'
5-
import { getToolIcon, resolveToolDisplayState } from '../../utils'
6-
7-
function CircleCheck({ className }: { className?: string }) {
8-
return (
9-
<svg
10-
width='16'
11-
height='16'
12-
viewBox='0 0 16 16'
13-
fill='none'
14-
xmlns='http://www.w3.org/2000/svg'
15-
className={className}
16-
>
17-
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
18-
<path
19-
d='M5.5 8.5L7 10L10.5 6.5'
20-
stroke='currentColor'
21-
strokeWidth='1.25'
22-
strokeLinecap='round'
23-
strokeLinejoin='round'
24-
/>
25-
</svg>
26-
)
27-
}
6+
import { resolveToolDisplayState } from '../../utils'
287

298
export function CircleStop({ className }: { className?: string }) {
309
return (
@@ -42,65 +21,19 @@ export function CircleStop({ className }: { className?: string }) {
4221
)
4322
}
4423

45-
function Hyphen({ className }: { className?: string }) {
46-
return (
47-
<svg
48-
width='16'
49-
height='16'
50-
viewBox='0 0 16 16'
51-
fill='none'
52-
xmlns='http://www.w3.org/2000/svg'
53-
className={className}
54-
>
55-
<path d='M4 8H12' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' />
56-
</svg>
57-
)
58-
}
59-
60-
function CircleOutline({ className }: { className?: string }) {
61-
return (
62-
<svg
63-
width='16'
64-
height='16'
65-
viewBox='0 0 16 16'
66-
fill='none'
67-
xmlns='http://www.w3.org/2000/svg'
68-
className={className}
69-
>
70-
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
71-
</svg>
72-
)
73-
}
74-
75-
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
76-
const display = resolveToolDisplayState(status)
77-
if (display === 'spinner') {
78-
const Icon = getToolIcon(toolName)
79-
if (Icon) {
80-
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
81-
}
82-
return <CircleOutline className='size-[15px] text-[var(--text-tertiary)]' />
83-
}
84-
if (display === 'cancelled') {
85-
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
86-
}
87-
if (display === 'interrupted') {
88-
return <Hyphen className='size-[15px] text-[var(--text-tertiary)]' />
89-
}
90-
const Icon = getToolIcon(toolName)
91-
if (Icon) {
92-
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
93-
}
94-
return <CircleCheck className='size-[15px] text-[var(--text-tertiary)]' />
95-
}
96-
9724
interface ToolCallItemProps {
9825
toolName: string
9926
displayTitle: string
10027
status: ToolCallStatus
10128
streamingArgs?: string
10229
}
10330

31+
/**
32+
* A single tool-call row inside an agent group: shimmer while executing, a
33+
* static label once terminal. For `workspace_file` the title is derived live
34+
* from the streaming args; because that path bypasses the completed-title
35+
* rewrite in `toToolData`, the past-tense flip is applied here on success.
36+
*/
10437
export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: ToolCallItemProps) {
10538
const liveWorkspaceFileTitle = useMemo(() => {
10639
if (toolName !== WorkspaceFile.id || !streamingArgs) return null
@@ -132,13 +65,14 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }:
13265
}, [toolName, streamingArgs])
13366

13467
const isExecuting = resolveToolDisplayState(status) === 'spinner'
135-
const title = liveWorkspaceFileTitle || displayTitle
68+
const liveTitle = liveWorkspaceFileTitle || displayTitle
69+
const title =
70+
status === 'success' && liveWorkspaceFileTitle
71+
? (getToolCompletedTitle(liveTitle) ?? liveTitle)
72+
: liveTitle
13673

13774
return (
138-
<div className='flex items-center gap-[8px] pl-[24px]'>
139-
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
140-
<StatusIcon status={status} toolName={toolName} />
141-
</div>
75+
<div className='flex items-center pl-6'>
14276
{isExecuting ? (
14377
<ShimmerText className='text-[13px] [--shimmer-rest:var(--text-secondary)]'>
14478
{title}

0 commit comments

Comments
 (0)