Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -148,12 +150,11 @@ export function AgentGroup({
)
}
return (
<span
<NarrationText
key={`text-${idx}`}
className='pl-6 text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'
>
{item.content.trim()}
</span>
content={item.content}
isStreaming={isStreaming && idx === items.length - 1}
/>
)
})}
</div>
Expand All @@ -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 (
<span className='pl-6 text-[13px] text-[var(--text-muted)] leading-[18px]'>
{renderInlineMarkdown(revealed.trim())}
</span>
)
}

interface BoundedViewportProps {
children: React.ReactNode
isStreaming: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -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.'])
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<strong key={key} className='font-semibold'>
<em>{renderInlineMarkdown(part.slice(3, -3))}</em>
</strong>
)
}
if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) {
return (
<strong key={key} className='font-semibold'>
{renderInlineMarkdown(part.slice(2, -2))}
</strong>
)
}
if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
return (
<span key={key} className='font-mono text-[12px]'>
{part.slice(1, -1)}
</span>
)
}
if (part.length > 2 && part.startsWith('*') && part.endsWith('*')) {
return <em key={key}>{renderInlineMarkdown(part.slice(1, -1))}</em>
}
Comment thread
waleedlatif1 marked this conversation as resolved.
const link = LINK_TOKEN.exec(part)
if (link) {
return <Fragment key={key}>{renderInlineMarkdown(link[1])}</Fragment>
}
return part
}
Original file line number Diff line number Diff line change
@@ -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 (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
<path
d='M5.5 8.5L7 10L10.5 6.5'
stroke='currentColor'
strokeWidth='1.25'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
import { resolveToolDisplayState } from '../../utils'

export function CircleStop({ className }: { className?: string }) {
return (
Expand All @@ -42,65 +21,19 @@ export function CircleStop({ className }: { className?: string }) {
)
}

function Hyphen({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<path d='M4 8H12' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' />
</svg>
)
}

function CircleOutline({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
</svg>
)
}

function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
const display = resolveToolDisplayState(status)
if (display === 'spinner') {
const Icon = getToolIcon(toolName)
if (Icon) {
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
}
return <CircleOutline className='size-[15px] text-[var(--text-tertiary)]' />
}
if (display === 'cancelled') {
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
}
if (display === 'interrupted') {
return <Hyphen className='size-[15px] text-[var(--text-tertiary)]' />
}
const Icon = getToolIcon(toolName)
if (Icon) {
return <Icon className='size-[15px] text-[var(--text-tertiary)]' />
}
return <CircleCheck className='size-[15px] text-[var(--text-tertiary)]' />
}

interface ToolCallItemProps {
toolName: string
displayTitle: string
status: ToolCallStatus
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
Expand Down Expand Up @@ -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 (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<StatusIcon status={status} toolName={toolName} />
</div>
<div className='flex items-center pl-6'>
{isExecuting ? (
<ShimmerText className='text-[13px] [--shimmer-rest:var(--text-secondary)]'>
{title}
Expand Down
Loading
Loading