/** * AgentTurnBubble Component * ADR-092: Telegram-style agent message bubble with reasoning chains and tool steps * * Renders multiple agent messages (thinking, tool_call, tool_result, final text) * as a single compact Telegram-style bubble. * * Visual structure (updated): * ┌─────────────────────────────────────────┐ * │ 🧠 Reasoning │ * │ │ Analyzing the request. I need to... │ * │ │ [Show full →] │ * │ │ * │ 🔧 Used 3 tools ▼ │ * │ ├─ $ npm test --run ✓ │ * │ │ Preview: 12 tests passed... │ * │ ├─ Read: src/api/users.ts ✓ │ * │ └─ Edit: src/api/users.ts ✓ │ * │ │ * │ 🧠 Reasoning │ * │ │ Now I understand the fix... │ * │ │ * │ 🔧 Used 2 tools ▼ │ * │ ├─ ... │ * │ │ * │ Final markdown response here... │ * │ 14:32│ * └─────────────────────────────────────────┘ */ import React, { useState } from 'react'; import { Wrench, Terminal, ChevronDown, ChevronRight, Brain, CheckCircle2, XCircle, Loader2, Eye, PenLine, Globe, Database, Search, Sparkles, } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; import { MarkdownPreview, type CheckboxClickInfo, type CheckboxUser } from '@/shared/components/MarkdownPreview'; import type { ChatMessageItem } from './ChatConversationView'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ToolStep { kind: 'tool'; toolName: string; args?: Record; result?: unknown; success: boolean; } interface ThinkingStep { kind: 'thinking'; content: string; } type Step = ToolStep | ThinkingStep; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const TOOL_RESULT_TRUNCATE = 500; const TOOL_RESULT_FULL_LENGTH = 5000; function truncateText(text: string, maxLen: number): string { return text.length <= maxLen ? text : text.slice(0, maxLen) + '...'; } /** Insert zero-width spaces after / and around | so long paths/commands can wrap */ function softBreakText(text: string): string { return text.replace(/\//g, '/\u200B').replace(/\|/g, '\u200B|\u200B'); } /** * Extract tool name from message content or toolResults. * Handles multiple formats: * - AgentLoopService: content="Bash", toolResults={tool:"Bash",args:{...}} * - Claude Code mirror: content='{"tool":"Bash","input":{...}}', toolResults=null * - Legacy: plain text with "calling: toolName" pattern */ function parseToolName(content: string, toolResults?: ChatMessageItem['toolResults']): string { // 1. Try toolResults first (most reliable) if (toolResults) { if (Array.isArray(toolResults)) { if (toolResults[0]?.tool) return toolResults[0].tool; } else if (typeof toolResults === 'object' && 'tool' in toolResults) { return (toolResults as Record).tool as string; } } // 2. Try JSON parsing try { const parsed = JSON.parse(content); if (parsed.tool) return parsed.tool; if (parsed.name) return parsed.name; } catch { /* not JSON */ } // 3. Regex pattern const match = content.match(/(?:tool|function|calling)[:\s]+(\w+)/i); if (match) return match[1]; // 4. Fallback: use first line (likely plain tool name like "Bash") const firstLine = content.split('\n')[0].trim(); return firstLine.length > 40 ? firstLine.slice(0, 40) + '...' : firstLine; } /** * Extract tool arguments from message content or toolResults. */ function parseToolArgs(content: string, toolResults?: ChatMessageItem['toolResults']): Record | undefined { // 1. Try toolResults first if (toolResults) { if (Array.isArray(toolResults)) { if (toolResults[0]?.args) return toolResults[0].args; } else if (typeof toolResults === 'object' && 'args' in toolResults) { return (toolResults as Record).args as Record; } } // 2. Try JSON parsing try { const parsed = JSON.parse(content); if (parsed.args) return parsed.args as Record; if (parsed.input) return parsed.input as Record; } catch { /* not JSON */ } return undefined; } function parseToolResult(content: string): { result: unknown; success: boolean } { try { const parsed = JSON.parse(content); return { result: parsed.result ?? parsed, success: !parsed.error }; } catch { return { result: content, success: !content.toLowerCase().includes('error') }; } } function formatResult(result: unknown): string { if (typeof result === 'string') return result; try { return JSON.stringify(result, null, 2); } catch { return String(result); } } /** Map tool name to a descriptive icon */ function getToolIcon(toolName: string) { const name = toolName.toLowerCase(); if (name === 'bash' || name === 'shell' || name === 'execute') { return ; } if (name === 'read' || name === 'readfile' || name === 'cat') { return ; } if (name === 'write' || name === 'writefile' || name === 'edit') { return ; } if (name === 'grep' || name === 'search' || name === 'glob' || name === 'find') { return ; } if (name === 'webfetch' || name === 'websearch' || name === 'web') { return ; } if (name === 'sql' || name === 'query' || name === 'database') { return ; } if (name === 'task' || name === 'agent') { return ; } return ; } // --------------------------------------------------------------------------- // Section types & grouping // --------------------------------------------------------------------------- interface ThinkingSection { kind: 'thinking'; content: string; } interface ToolGroupSection { kind: 'tool_group'; tools: ToolStep[]; } type Section = ThinkingSection | ToolGroupSection; /** * Groups steps into sections: consecutive tool calls → one ToolGroupSection, * thinking → ThinkingSection. Preserves chronological order. */ function groupStepsIntoSections(steps: Step[]): Section[] { const sections: Section[] = []; let currentTools: ToolStep[] = []; const flushTools = () => { if (currentTools.length > 0) { sections.push({ kind: 'tool_group', tools: [...currentTools] }); currentTools = []; } }; for (const step of steps) { if (step.kind === 'thinking') { flushTools(); sections.push({ kind: 'thinking', content: step.content }); } else { currentTools.push(step); } } flushTools(); return sections; } // --------------------------------------------------------------------------- // Sub-components: ThinkingBlock + ToolGroupAccordion // --------------------------------------------------------------------------- /** Renders thinking/reasoning content as a visible block (NOT hidden in accordion) */ const ThinkingBlock: React.FC<{ content: string }> = ({ content }) => { const [expanded, setExpanded] = useState(content.length <= 500); return (
Reasoning
{content}
{content.length > 500 && ( )}
); }; /** Renders a group of consecutive tool calls as a collapsible accordion with result previews */ const ToolGroupAccordion: React.FC<{ tools: ToolStep[] }> = ({ tools }) => { const [expanded, setExpanded] = useState(false); const [expandedResults, setExpandedResults] = useState>({}); if (tools.length === 0) return null; const toggleResult = (idx: number) => { setExpandedResults((prev) => ({ ...prev, [idx]: !prev[idx] })); }; return (
{/* Summary row */} {/* Expanded tool list */} {expanded && (
{tools.map((step, idx) => { const isBash = step.toolName === 'Bash' || step.toolName === 'bash'; const bashCmd = isBash && step.args?.command ? String(step.args.command) : null; const isResultExpanded = expandedResults[idx] || false; const resultText = step.result !== undefined ? formatResult(step.result) : ''; // Build tool description for preview let toolDescription = step.toolName; if (isBash && bashCmd) { toolDescription = `$ ${truncateText(bashCmd, 120)}`; } else if (step.toolName === 'Read' && step.args?.file_path) { toolDescription = `Read: ${String(step.args.file_path)}`; } else if (step.toolName === 'Edit' && step.args?.file_path) { toolDescription = `Edit: ${String(step.args.file_path)}`; } else if (step.toolName === 'Write' && step.args?.file_path) { toolDescription = `Write: ${String(step.args.file_path)}`; } else if (step.toolName === 'Grep' && step.args?.pattern) { toolDescription = `Grep: ${String(step.args.pattern)}`; } else if (step.toolName === 'Glob' && step.args?.pattern) { toolDescription = `Glob: ${String(step.args.pattern)}`; } return (
{/* Tool header with name/command + status icon */}
{getToolIcon(step.toolName)} {softBreakText(toolDescription)} {step.success ? ( ) : ( )}
{/* Result preview (always show first 200 chars) + expand to full */} {resultText && (
                      {isResultExpanded
                        ? truncateText(resultText, TOOL_RESULT_FULL_LENGTH)
                        : truncateText(resultText, 200)}
                    
{resultText.length > 200 && ( )}
)}
); })}
)}
); }; // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- export interface AgentTurnBubbleProps { messages: ChatMessageItem[]; isProcessing?: boolean; onCheckboxClick?: (info: CheckboxClickInfo) => void; /** New: callback for interactive checkbox toggle that persists to DB + sends system message */ onCheckboxToggle?: (messageId: number | string, content: string, checkboxIndex: number) => void; currentUser?: CheckboxUser; className?: string; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export const AgentTurnBubble: React.FC = ({ messages, isProcessing = false, onCheckboxClick, onCheckboxToggle, currentUser, className, }) => { // --- Parse messages into steps + final text --- const steps: Step[] = []; let finalText: ChatMessageItem | null = null; let toolCount = 0; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; const ct = msg.contentType; if (ct === 'thinking') { steps.push({ kind: 'thinking', content: msg.content }); } else if (ct === 'tool_call') { toolCount++; const toolName = parseToolName(msg.content, msg.toolResults); const args = parseToolArgs(msg.content, msg.toolResults); let result: unknown; let success = true; // Look ahead for paired tool_result if (i + 1 < messages.length && messages[i + 1].contentType === 'tool_result') { const resultMsg = messages[i + 1]; const parsed = parseToolResult(resultMsg.content); result = parsed.result; success = parsed.success; i++; // skip paired result } else { // Check if toolResults on this message already contains a result (AgentLoopService format) const tr = msg.toolResults; if (tr) { const trObj = Array.isArray(tr) ? tr[0] : (typeof tr === 'object' ? tr : null); if (trObj && 'result' in (trObj as Record)) { const r = (trObj as Record).result; result = r; const rStr = formatResult(r); success = !rStr.toLowerCase().includes('error'); } } } steps.push({ kind: 'tool', toolName, args, result, success }); } else if (ct === 'tool_result') { // Orphaned tool_result — try to attach to previous tool step const prevStep = steps.length > 0 ? steps[steps.length - 1] : null; if (prevStep && prevStep.kind === 'tool' && prevStep.result === undefined) { // Attach to previous tool step that has no result const parsed = parseToolResult(msg.content); prevStep.result = parsed.result; prevStep.success = parsed.success; } else { // Truly orphaned toolCount++; const parsed = parseToolResult(msg.content); steps.push({ kind: 'tool', toolName: 'tool', result: parsed.result, success: parsed.success }); } } else if (ct === 'text' || !ct) { finalText = msg; } } // Legacy toolResults on single message if (messages.length === 1 && messages[0].toolResults?.length && toolCount === 0) { for (const tr of messages[0].toolResults) { toolCount++; const resultStr = formatResult(tr.result); steps.push({ kind: 'tool', toolName: tr.tool, args: tr.args, result: tr.result, success: !resultStr.toLowerCase().includes('error'), }); } } const hasSteps = steps.length > 0; const hasFinalText = finalText && finalText.content && !finalText.is_deleted; // Group steps into sections: thinking blocks shown as visible text, // tool groups shown as collapsible accordions const sections = hasSteps ? groupStepsIntoSections(steps) : []; return (
{/* --- Sections: Thinking blocks (visible) + Tool groups (accordion) --- */} {sections.length > 0 && (
{sections.map((section, idx) => { if (section.kind === 'thinking') { return ( ); } return ( ); })} {/* Separator between steps and final text */} {hasFinalText && (
)}
)} {/* --- Final text (markdown) --- */} {hasFinalText && (
onCheckboxToggle(finalText!.id, finalText!.content, info.index) : onCheckboxClick } currentUser={currentUser} />
)} {/* --- Deleted message --- */} {finalText?.is_deleted && (
Message deleted
)} {/* --- Processing indicator with tool name + count --- */} {isProcessing && !hasFinalText && (
{toolCount > 0 ? ( {steps.filter(s => s.kind === 'tool').slice(-1)[0]?.toolName || 'tool'} {toolCount} {toolCount === 1 ? 'tool' : 'tools'} ) : ( Думает... )}
)} {/* --- Empty state: streaming dots --- */} {!isProcessing && !hasFinalText && !hasSteps && messages.length === 1 && (
)}
); };