'use client' import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type ReactNode, useEffect, useMemo, useState } from 'react' import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer' import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text' import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment' import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' import { AlertCircle, CheckCircle2, ChevronRight, Command, FileText, Globe, LinkIcon, Loader2, Search, Sparkles, Wrench } from '@/lib/icons' import type { LucideIcon } from '@/lib/icons' import { cn } from '@/lib/utils' import { $toolInlineDiffs } from '@/store/tool-diffs' import { $toolViewMode } from '@/store/tool-view' const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const TOOL_SPINNER_INTERVAL_MS = 80 const TOOL_DETAIL_INDENT_CLASS = 'ml-[3.25rem]' type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' type ToolStatus = 'error' | 'running' | 'success' interface ToolPart { args?: unknown isError?: boolean result?: unknown toolCallId?: string toolName: string type: 'tool-call' } interface SearchResultRow { snippet: string title: string url: string } interface ToolView { detail: string detailLabel: string durationLabel?: string icon: LucideIcon imageUrl?: string inlineDiff: string previewTarget?: string rawArgs: string rawResult: string status: ToolStatus subtitle: string title: string tone: ToolTone } interface ToolMeta { done: string icon: LucideIcon pending: string tone: ToolTone } const TOOL_META: Record = { browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' }, browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' }, browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' }, browser_snapshot: { done: 'Captured page snapshot', pending: 'Capturing page snapshot', icon: Globe, tone: 'browser' }, browser_take_screenshot: { done: 'Captured screenshot', pending: 'Capturing screenshot', icon: Sparkles, tone: 'browser' }, browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' }, edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }, execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' }, image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' }, list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' }, read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' }, search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' }, session_search_recall: { done: 'Searched session history', pending: 'Searching session history', icon: Search, tone: 'agent' }, terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' }, todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' }, web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' }, web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' }, write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' } } const TOOL_TONE_CLASS: Record = { agent: 'bg-amber-500/12 text-amber-700 dark:text-amber-300', browser: 'bg-sky-500/12 text-sky-700 dark:text-sky-300', default: 'bg-muted text-muted-foreground', file: 'bg-slate-500/12 text-slate-700 dark:text-slate-300', image: 'bg-rose-500/12 text-rose-700 dark:text-rose-300', terminal: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300', web: 'bg-violet-500/12 text-violet-700 dark:text-violet-300' } function titleForTool(name: string): string { const normalized = name.replace(/^browser_/, '').replace(/^web_/, '') return ( normalized .split('_') .filter(Boolean) .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) .join(' ') || name ) } const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [ { prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' }, { prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' } ] function toolMeta(name: string): ToolMeta { if (TOOL_META[name]) { return TOOL_META[name] } const action = titleForTool(name) const prefix = PREFIX_META.find(p => name.startsWith(p.prefix)) return prefix ? { done: `${prefix.verb} ${action}`, pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`, icon: prefix.icon, tone: prefix.tone } : { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' } } function compactPreview(value: unknown, max = 72): string { const raw = typeof value === 'string' ? value : (parseMaybeObject(value).context as string | undefined) || '' const line = raw.replace(/\s+/g, ' ').trim() return line.length > max ? `${line.slice(0, max - 1)}…` : line } function contextValue(value: unknown): string { const row = parseMaybeObject(value) if (typeof row.context === 'string') { return row.context } if (typeof row.preview === 'string') { return row.preview } return typeof value === 'string' ? value : '' } function prettyJson(value: unknown): string { return typeof value === 'string' ? value : JSON.stringify(value, null, 2) } function parseMaybeObject(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record } if (typeof value !== 'string' || !value.trim()) { return {} } try { const parsed = JSON.parse(value) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} } catch { return {} } } function numberValue(value: unknown): null | number { const n = typeof value === 'number' ? value : Number(value) return Number.isFinite(n) ? n : null } function looksLikeUrl(value: string): boolean { return /^https?:\/\//i.test(value) } function looksLikePath(value: string): boolean { return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) } function isPreviewableTarget(target: string): boolean { return Boolean( target && (/^file:\/\//i.test(target) || /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) || /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) ) } const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i function findFirstUrl(...sources: unknown[]): string { for (const src of sources) { if (typeof src === 'string') { const m = src.match(URL_PATTERN) if (m) { return m[0] } } else if (src && typeof src === 'object') { for (const v of Object.values(src as Record)) { const found = findFirstUrl(v) if (found) { return found } } } } return '' } function hostnameOf(value: string): string { try { const url = new URL(value) return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}` } catch { return value } } function looksRedundant(title: string, detail: string): boolean { if (!detail) { return true } const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim() return norm(title) === norm(detail) } function summarizeBrowserSnapshot(snapshot: string): string { const count = (re: RegExp) => snapshot.match(re)?.length ?? 0 const stats = [ `${count(/button\s+"[^"]+"/g)} buttons`, `${count(/link\s+"[^"]+"/g)} links`, `${count(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)} inputs` ].join(' · ') const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g)) .map(m => m[1].trim()) .filter(Boolean) .slice(0, 4) return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats } function firstStringField(record: Record, keys: readonly string[]): string { for (const key of keys) { const value = record[key] if (typeof value === 'string' && value.trim()) { return value.trim() } } return '' } function extractSearchResults(result: unknown): SearchResultRow[] { const row = parseMaybeObject(result) const list = (Array.isArray(row.results) ? row.results : Array.isArray(row.items) ? row.items : Array.isArray(row.data) ? row.data : []) as unknown[] return list .map(item => { const r = parseMaybeObject(item) return { title: firstStringField(r, ['title', 'name']), url: firstStringField(r, ['url', 'href', 'link']), snippet: firstStringField(r, ['snippet', 'description', 'body']) } }) .filter(hit => hit.title || hit.url) .slice(0, 3) } function toolErrorText(part: ToolPart, result: Record): string { if (part.isError) { return 'Tool returned an error.' } if (typeof result.error === 'string' && result.error.trim()) { return result.error.trim() } if (result.success === false) { return firstStringField(result, ['message', 'reason']) || 'Tool returned success=false.' } const exit = numberValue(result.exit_code) return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : '' } function toolStatus(part: ToolPart, resultRecord: Record): ToolStatus { if (part.result === undefined) { return 'running' } return toolErrorText(part, resultRecord) ? 'error' : 'success' } function durationLabel(resultRecord: Record): string | undefined { const seconds = numberValue(resultRecord.duration_s) if (seconds === null || seconds < 0) { return undefined } return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s` } function toolPreviewTarget(toolName: string, args: Record, result: Record): string { const direct = firstStringField(result, ['preview', 'url', 'target']) || firstStringField(args, ['preview', 'url', 'target', 'path', 'file', 'filepath']) || firstStringField(result, ['path', 'file', 'filepath']) if (direct && (looksLikeUrl(direct) || looksLikePath(direct))) { return direct } if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') { const explicit = firstStringField(args, ['url', 'search_term', 'query']) || firstStringField(result, ['url']) return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result) } return '' } function toolImageUrl(args: Record, result: Record): string { const candidate = firstStringField(result, ['image_url', 'url', 'path', 'image_path']) || firstStringField(args, ['image_url', 'url', 'path']) if (!candidate) { return '' } return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate) ? candidate : '' } function stripAnsi(value: string): string { return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') } function stripInlineDiffChrome(value: string): string { return value ? stripAnsi(value) .replace(/^\s*┊\s*review diff\s*\n/i, '') .trim() : '' } function inlineDiffFromResult(result: unknown): string { const value = parseMaybeObject(result).inline_diff return typeof value === 'string' ? stripInlineDiffChrome(value) : '' } function fallbackDetailText(args: unknown, result: unknown): string { const argContext = contextValue(args) const resultContext = contextValue(result) if (resultContext && resultContext !== argContext) { return resultContext } if (argContext) { return argContext } if (result !== undefined) { return prettyJson(result) } return prettyJson(args) } function toolSubtitle(part: ToolPart, argsRecord: Record, resultRecord: Record): string { const toolName = part.toolName if (toolName === 'browser_navigate') { const url = firstStringField(argsRecord, ['url', 'target']) || firstStringField(resultRecord, ['url']) || findFirstUrl(argsRecord, resultRecord) return url ? hostnameOf(url) : 'Navigated in browser' } if (toolName === 'browser_snapshot') { const snapshot = firstStringField(resultRecord, ['snapshot']) return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot' } if (toolName === 'browser_click') { const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target']) if (!clicked) { return 'Clicked on page' } return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}` } if (toolName === 'browser_fill' || toolName === 'browser_type') { const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target']) const value = firstStringField(argsRecord, ['value', 'text']) return [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') || 'Filled page input' } if (toolName === 'web_search') { const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) return query ? `Query: ${query}` : 'Queried web sources' } if (toolName === 'terminal' || toolName === 'execute_code') { const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord) return command ? compactPreview(command, 120) : 'Executed command' } if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') { const path = firstStringField(argsRecord, ['path', 'file', 'filepath']) return path || fallbackDetailText(argsRecord, resultRecord) } if (toolName === 'web_extract') { const url = firstStringField(argsRecord, ['url']) || firstStringField(resultRecord, ['url']) || findFirstUrl(argsRecord, resultRecord) return url ? hostnameOf(url) : 'Fetched webpage' } return compactPreview(resultRecord, 120) || compactPreview(argsRecord, 120) || fallbackDetailText(argsRecord, resultRecord) } function toolDetailLabel(toolName: string): string { if (toolName === 'web_search') { return 'Search results' } if (toolName === 'browser_snapshot') { return 'Snapshot summary' } if (toolName === 'terminal' || toolName === 'execute_code') { return 'Command output' } return '' } function toolDetailText(part: ToolPart, argsRecord: Record, resultRecord: Record): string { if (part.toolName === 'browser_snapshot') { const snapshot = firstStringField(resultRecord, ['snapshot']) return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord) } if (part.toolName === 'web_search') { const hits = extractSearchResults(part.result) if (hits.length) { return hits .map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')) .join('\n\n') } } if (part.toolName === 'terminal' || part.toolName === 'execute_code') { const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) const lines = Array.isArray(resultRecord.lines) ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') : '' if (output || lines) { return [output, lines].filter(Boolean).join('\n') } } if (part.toolName === 'web_extract') { const summary = firstStringField(resultRecord, ['summary', 'message']) if (summary) { return summary.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim() } } return fallbackDetailText(argsRecord, resultRecord) } function dynamicTitle( part: ToolPart, args: Record, result: Record, fallback: string ): string { const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past) if (part.toolName === 'web_extract') { const url = findFirstUrl(args, result) return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback } if (part.toolName === 'browser_navigate') { const url = findFirstUrl(args, result) return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback } if (part.toolName === 'web_search') { const query = firstStringField(args, ['search_term', 'query']) || contextValue(args) return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback } return fallback } function buildToolView(part: ToolPart, inlineDiff: string): ToolView { const argsRecord = parseMaybeObject(part.args) const resultRecord = parseMaybeObject(part.result) const meta = toolMeta(part.toolName) const status = toolStatus(part, resultRecord) const error = toolErrorText(part, resultRecord) const baseTitle = part.result === undefined ? meta.pending : meta.done const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle) const titleEnriched = title !== baseTitle const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord) const subtitle = titleEnriched && !error ? '' : baseSubtitle return { detail: error || toolDetailText(part, argsRecord, resultRecord), detailLabel: error ? 'Error' : toolDetailLabel(part.toolName), durationLabel: durationLabel(resultRecord), icon: meta.icon, imageUrl: toolImageUrl(argsRecord, resultRecord), inlineDiff, previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord), rawArgs: prettyJson(part.args), rawResult: prettyJson(part.result), status, subtitle, title, tone: meta.tone } } function isToolPart(part: unknown): part is ToolPart { if (!part || typeof part !== 'object') { return false } const row = part as Record return row.type === 'tool-call' && typeof row.toolName === 'string' } function groupToolParts(content: unknown): ToolPart[][] { if (!Array.isArray(content)) { return [] } const groups: ToolPart[][] = [] let current: ToolPart[] = [] for (const part of content) { if (isToolPart(part)) { current.push(part) continue } if (current.length) { groups.push(current) current = [] } } if (current.length) { groups.push(current) } return groups } function groupStatus(parts: ToolPart[]): ToolStatus { if (parts.some(p => p.result === undefined)) { return 'running' } return parts.some(p => toolStatus(p, parseMaybeObject(p.result)) === 'error') ? 'error' : 'success' } function groupTitle(parts: ToolPart[]): string { const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix))) const verb = prefix?.verb || 'Tool' return `${verb} actions · ${parts.length} steps` } const STATUS_DOT_CLASS: Record = { error: 'bg-destructive', running: 'bg-muted-foreground/55 animate-pulse', success: 'bg-emerald-500' } function statusDot(status: ToolStatus): ReactNode { return ( ) } function statusBadge(status: ToolStatus): ReactNode { if (status === 'running') { return ( Running ) } if (status === 'error') { return ( Error ) } return ( Done ) } interface ToolEntryProps { embedded?: boolean part: ToolPart } function ToolEntry({ embedded = false, part }: ToolEntryProps) { const [open, setOpen] = useState(false) const isPending = part.result === undefined const [tick, setTick] = useState(0) const elapsed = useElapsedSeconds(isPending) const toolViewMode = useStore($toolViewMode) const preview = compactPreview(part.args) || compactPreview(part.result) const liveDiffs = useStore($toolInlineDiffs) const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) const view = useMemo(() => buildToolView(part, inlineDiff), [inlineDiff, part]) const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length] useEffect(() => { if (!isPending) { return } const id = window.setInterval(() => setTick(value => value + 1), TOOL_SPINNER_INTERVAL_MS) return () => window.clearInterval(id) }, [isPending]) return (
{open && (
{view.previewTarget && isPreviewableTarget(view.previewTarget) && ( )} {view.imageUrl && (
)} {!looksRedundant(view.title, view.detail) && !looksRedundant(view.subtitle, view.detail) && (
{view.detailLabel && ( {view.detailLabel} )} {view.detail}
)} {toolViewMode === 'technical' && (
{part.result !== undefined && }
)}
)} {view.inlineDiff && }
) } function JsonSection({ label, value }: { label: string; value: string }) { return (
{label}
        {value}
      
) } function ToolGroup({ parts }: { parts: ToolPart[] }) { const [open, setOpen] = useState(parts.some(part => part.result === undefined)) const status = groupStatus(parts) const tailSummary = useMemo(() => { const tail = parts.at(-1) return tail ? buildToolView(tail, '').subtitle : '' }, [parts]) return (
{open && (
{parts.map(part => ( ))}
)}
) } export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: ToolCallMessagePartProps) => { const messageContent = useAuiState(state => state.message.content as unknown) const groups = useMemo(() => groupToolParts(messageContent), [messageContent]) const currentPart: ToolPart = { args, isError, result, toolCallId, toolName, type: 'tool-call' } if (!toolCallId) { return } const group = groups.find(parts => parts.some(part => part.toolCallId === toolCallId)) if (!group || group.length <= 1) { return } if (group[0]?.toolCallId !== toolCallId) { return null } return } function InlineDiff({ text }: { text: string }) { return (
      {text.split('\n').map((line, index) => {
        const added = line.startsWith('+') && !line.startsWith('+++')
        const removed = line.startsWith('-') && !line.startsWith('---')
        const hunk = line.startsWith('@@')
        const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))

        return (
          
            {line || ' '}
          
        )
      })}
    
) }