import { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History, AlertCircle } from 'lucide-react'; import { toast } from 'sonner'; import type { Chat, ErrorReason, Message } from '@/api/types'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events'; import { shortenModelName } from '@/lib/modelName'; import { CapHitSentinel } from './CapHitSentinel'; import { DoomLoopSentinel } from './DoomLoopSentinel'; import { MarkdownRenderer } from './MarkdownRenderer'; import { Button } from '@/components/ui/button'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; // v1.10 booterm: tiny subscription hook for the mounted-terminals registry. // Used by the right-click "Send to terminal" submenu so it always reflects // currently-open terminal panes without prop drilling from Workspace. function useTerminals(): TerminalRegistration[] { const [list, setList] = useState(() => terminalsRegistry.list()); useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []); return list; } // Wrap a message body with a right-click context menu offering Copy and // "Send to terminal → ". Send is disabled when nothing is // selected or no terminal panes are open; clicking a target emits a // sendToTerminal event that TerminalPane subscribes to (filtered by pane_id). function SendToTerminalMenu({ children }: { children: ReactNode }) { const [selection, setSelection] = useState(''); const terminals = useTerminals(); const hasSelection = selection.length > 0; const canSend = hasSelection && terminals.length > 0; return ( { if (open) { const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : ''; setSelection(sel); } }} > {children} { void navigator.clipboard.writeText(selection).catch((err) => { toast.error(err instanceof Error ? err.message : 'copy failed'); }); }} > Copy Send to terminal {terminals.length === 0 ? ( No terminal panes open ) : ( terminals.map((t) => ( sendToTerminal.emit({ pane_id: t.paneId, text: selection })} > {t.label} )) )} ); } // v1.8.2: human labels for the machine-readable error reasons that ride on // failed assistant messages via metadata.kind === 'error'. Kept short so the // inline render under "message failed" stays a single muted line. const ERROR_REASON_LABELS: Record = { llm_provider_error: 'LLM provider error', tool_execution_failed: 'Tool execution failed', summary_after_cap_failed: 'Summary after tool budget hit failed', }; // v1.14.x-html-artifact-panes: MarkdownBody and its path-linkifier helpers // moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact // panes can render assistant content with the same Shiki + remark-gfm setup. export interface MessageActions { onRegenerate?: (chatId: string, messageId: string) => Promise; onResend?: (chatId: string, content: string) => Promise; onFork?: (chatId: string, messageId: string) => Promise; onDelete?: (chatId: string, messageId: string) => Promise; // write-edit-robustness #4 (BooCoder only): reset the worktree to this // message's pre-turn checkpoint and trim the transcript past it. BooChat // passes no such callback → the "Restore to here" control never renders. onRestoreCheckpoint?: (chatId: string, messageId: string) => Promise; } interface Props { message: Message; sessionChats?: Chat[]; capHitInfo?: { position: number; isLatest: boolean }; actions?: MessageActions; /** Hide actions that don't apply (fork, delete). */ hideActions?: ('fork' | 'delete')[]; /** * write-edit-robustness #4: this assistant message has a worktree checkpoint * → render "Restore to here" (only when `actions.onRestoreCheckpoint` is also * provided). CoderMessageList sets this from the checkpoint set. */ hasCheckpoint?: boolean; /** * write-edit-robustness #4: suppress the restore control during an active * turn (mirrors composer gating). Defaults to enabled. */ restoreDisabled?: boolean; } function StatsLine({ message }: { message: Message }) { const tokens = message.tokens_used; if (typeof tokens !== 'number' || tokens <= 0) return null; const started = message.started_at ? Date.parse(message.started_at) : NaN; const finished = message.finished_at ? Date.parse(message.finished_at) : NaN; let tps: number | null = null; if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) { const seconds = (finished - started) / 1000; if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10; } const ctxUsed = message.ctx_used; const ctxMax = message.ctx_max; const ctxPart = typeof ctxUsed === 'number' ? typeof ctxMax === 'number' && ctxMax > 0 ? `${ctxUsed} / ${ctxMax} ctx` : `${ctxUsed} ctx` : null; const parts: string[] = [`${tokens} tokens`]; if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`); if (ctxPart) parts.push(ctxPart); return (
{parts.join(' · ')}
); } function ActionRow({ message, actions, hiddenSet, hasCheckpoint = false, restoreDisabled = false, }: { message: Message; actions?: MessageActions; hiddenSet: Set; hasCheckpoint?: boolean; restoreDisabled?: boolean; }) { const [justCopied, setJustCopied] = useState(false); const [regenerating, setRegenerating] = useState(false); const [forking, setForking] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); const [restoreOpen, setRestoreOpen] = useState(false); const [restoring, setRestoring] = useState(false); async function copy() { try { await navigator.clipboard.writeText(message.content); setJustCopied(true); setTimeout(() => setJustCopied(false), 1200); } catch (err) { toast.error(err instanceof Error ? err.message : 'copy failed'); } } async function regenerate() { if (regenerating || message.status === 'streaming') return; setRegenerating(true); try { if (actions?.onRegenerate) { await actions.onRegenerate(message.chat_id, message.id); } else { await api.messages.regenerate(message.chat_id, message.id); } } catch (err) { toast.error(err instanceof Error ? err.message : 'regenerate failed'); } finally { setRegenerating(false); } } async function resend() { if (!canResend) return; try { if (actions?.onResend) { await actions.onResend(message.chat_id, message.content!); } else { await api.messages.send(message.chat_id, message.content!); } } catch (err) { toast.error(err instanceof Error ? err.message : 'resend failed'); } } async function fork() { if (forking || message.status !== 'complete') return; setForking(true); try { if (actions?.onFork) { await actions.onFork(message.chat_id, message.id); } else { const chat = await api.chats.fork(message.chat_id, { messageId: message.id }); sessionEvents.emit({ type: 'refetch_messages' }); sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id }); } } catch (err) { toast.error(err instanceof Error ? err.message : 'fork failed'); } finally { setForking(false); } } async function confirmDelete() { if (deleting) return; setDeleting(true); try { if (actions?.onDelete) { await actions.onDelete(message.chat_id, message.id); } else { await api.messages.remove(message.chat_id, message.id); } setDeleteOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : 'delete failed'); } finally { setDeleting(false); } } async function confirmRestore() { if (restoring || !actions?.onRestoreCheckpoint) return; setRestoring(true); try { await actions.onRestoreCheckpoint(message.chat_id, message.id); setRestoreOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : 'restore failed'); } finally { setRestoring(false); } } const isAssistant = message.role === 'assistant'; const isUser = message.role === 'user'; const canRegen = isAssistant && message.status !== 'streaming'; const canResend = isUser && message.status === 'complete' && !!message.content?.trim(); const canFork = message.status === 'complete'; const canDelete = message.status !== 'streaming'; // write-edit-robustness #4: show "Restore to here" only for a completed // assistant message that has a checkpoint AND when the coder wired the // callback. Disabled (but visible) during an active turn. const canRestore = isAssistant && hasCheckpoint && message.status === 'complete' && !!actions?.onRestoreCheckpoint; return ( <>
{canResend && ( )} {isAssistant && ( )} {!hiddenSet.has('fork') && ( )} {!hiddenSet.has('delete') && ( )} {canRestore && ( )}
{ if (!deleting) setDeleteOpen(open); }} > Delete this message and all messages after it? This removes the selected message and every later message in this chat. This cannot be undone. { if (!restoring) setRestoreOpen(open); }} > Restore to this point? This resets the worktree to before this turn, removes every later message in this chat, and resets the agent's session. This cannot be undone. ); } function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) { const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [rerunning, setRerunning] = useState(false); const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/); const headerText = headerMatch ? headerMatch[0] : 'Context compacted'; const summaryText = headerMatch ? message.content.slice(headerMatch[0].length).trim() : message.content; async function handleCopy() { try { await navigator.clipboard.writeText(summaryText); setCopied(true); setTimeout(() => setCopied(false), 1200); toast.success('Summary copied to clipboard'); } catch { toast.error('Copy failed'); } } async function handleShareToChat(chat: Chat) { try { await api.messages.send(chat.id, summaryText); toast.success(`Summary sent to ${chat.name ?? 'New chat'}`); setShareOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to share'); } } async function handleRerun() { if (rerunning) return; setRerunning(true); try { await api.chats.compact(message.chat_id); } catch (err) { toast.error(err instanceof Error ? err.message : 'Re-run failed'); } finally { setRerunning(false); } } const otherChats = (sessionChats ?? []).filter( (c) => c.id !== message.chat_id && c.status === 'open' ); return (
{shareOpen && (
{otherChats.length === 0 ? (
No other chats in this session
) : ( otherChats.map((c) => ( )) )}
)}
{expanded && (
{summaryText}
)}
); } // v1.11 anchored rolling summary. Inserted by services/compaction.ts as a // role='assistant', summary=true row. Distinct from legacy CompactCard // (which renders the kind='compact' system rows produced by v1.10 /compact). // Collapsed by default; header shows the timestamp; body renders the // summary markdown when expanded. Copy button matches CompactCard's affordance. function SummaryCard({ message }: { message: Message }) { const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); // Use finished_at when available (that's when the summary actually landed); // fall back to created_at for any row missing it. Both are ISO strings. const ts = message.finished_at ?? message.created_at; const headerTs = ts ? new Date(ts).toLocaleString() : ''; async function handleCopy() { try { await navigator.clipboard.writeText(message.content); setCopied(true); setTimeout(() => setCopied(false), 1200); toast.success('Summary copied to clipboard'); } catch { toast.error('Copy failed'); } } return (
{expanded && (
)}
); } // Collapsible "Thinking" block for assistant reasoning. Fed by either // reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts // (native inference, persisted from message_parts). Starts COLLAPSED to start // (a quiet chip) — for native BooChat/BooCode and the external agents (opencode, // claude SDK) alike — so the transcript stays tidy; click to expand. The // `streaming` pulse still animates while the turn runs. function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) { const [expanded, setExpanded] = useState(false); return (
{expanded && (
{text}
)}
); } // feature #12: mistake-recovery sentinel. Inserted by the backend as a // role='system', metadata.kind='mistake_recovery' row when the model hit // repeated *different* errors (distinct from doom_loop, which is the same // call repeated). Visual treatment mirrors CapHitSentinel / DoomLoopSentinel // (amber card + alert icon). Non-escalated → recovery guidance was injected // and the turn continues. Escalated → the turn was stopped; if can_continue // is set, offer the same Continue affordance as the cap-hit sentinel. // Loose `!= null` guards per the CLAUDE.md coder-message note (coder rows pass // metadata as undefined, not null). function MistakeRecoverySentinel({ message }: { message: Message }) { const meta = message.metadata; const isMistakeRecovery = meta != null && typeof meta === 'object' && meta.kind === 'mistake_recovery'; const failureKinds = isMistakeRecovery ? meta.failure_kinds : []; const escalated = isMistakeRecovery ? meta.escalated : false; const canContinue = isMistakeRecovery ? meta.can_continue === true : false; const [continuing, setContinuing] = useState(false); async function handleContinue() { if (continuing || !canContinue) return; setContinuing(true); try { await api.chats.continue(message.chat_id, message.id); } catch (err) { toast.error(err instanceof Error ? err.message : 'continue failed'); } finally { setContinuing(false); } } const kindsLabel = Array.isArray(failureKinds) && failureKinds.length > 0 ? failureKinds.join(', ') : null; return (
{escalated ? 'Repeated errors — turn stopped' : 'Recovering from repeated errors'}
{escalated ? 'Repeated errors persisted — stopped the turn.' : kindsLabel ? `Hit repeated different errors (${kindsLabel}) — recovery guidance injected, continuing.` : 'Hit repeated different errors — recovery guidance injected, continuing.'}
{escalated && canContinue && (
)}
); } export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled, }: Props) { const hiddenSet = new Set(hideActions ?? []); // v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact' // branch because summary=true never coexists with kind='compact' (new // compactions emit role='assistant' rows with kind='message'+summary=true). if (message.summary) { return ; } if (message.kind === 'compact') { return ; } // v1.8.2: cap-hit sentinels render as a distinct system bubble with a // Continue button. MessageList's pre-render pass tags each sentinel with // its position; only the latest gets the actionable button. if ( message.role === 'system' && message.metadata?.kind === 'cap_hit' && capHitInfo ) { return ( ); } // v1.11.6: doom-loop sentinel. No Continue affordance — retrying with the // same tools would just re-loop. The card explains what tripped and // suggests next steps (new message angle / switch agents). if (message.role === 'system' && message.metadata?.kind === 'doom_loop') { return ; } // feature #12: mistake-recovery sentinel. Non-escalated rows narrate that // recovery guidance was injected mid-turn; escalated rows report the turn // was stopped and (when can_continue) offer the cap-hit-style Continue. if (message.role === 'system' && message.metadata?.kind === 'mistake_recovery') { return ; } // v1.8.2: tool messages and assistant tool_calls are now rendered by // MessageList via ToolCallLine / ToolCallGroup. Tool-role messages reach // this point only if MessageList didn't consume them (shouldn't happen, // but guard against it by rendering nothing rather than a stale card). if (message.role === 'tool') return null; if (message.role === 'user') { return (
{message.content}
); } const isStreaming = message.status === 'streaming'; const failed = message.status === 'failed'; // v1.13.7: match the MessageList.flatten trim guard so a whitespace-only // assistant turn doesn't render an empty bubble + dangling ActionRow. const hasContent = message.content.trim().length > 0; // model-attribution chip: short label for the model that produced this turn. const modelLabel = shortenModelName(message.model); // Reasoning arrives as a pre-joined string (coder wire) or as parts (native // inference). Read whichever is present; loose ?? chain tolerates the coder // shape where reasoning_parts is undefined (see CLAUDE.md null-guard note). const reasoningText = ( message.reasoning_text ?? message.reasoning_parts?.map((p) => p.text ?? '').join('') ?? '' ).trim(); const hasReasoning = reasoningText.length > 0; // v1.8.2: if metadata stamps an error reason, surface it inline under the // generic "message failed" line. Keeps the user's eye where it already is // rather than introducing a separate banner. const errorMeta = message.metadata != null && message.metadata.kind === 'error' ? message.metadata : null; return (
{hasReasoning && } {(hasContent || isStreaming) && (
{hasContent ? : null} {isStreaming && ( )}
)} {failed && (
message failed {errorMeta && ( {ERROR_REASON_LABELS[errorMeta.error_reason]} {errorMeta.error_text ? ` — ${errorMeta.error_text}` : ''} )}
)} {!isStreaming && (modelLabel || null) && ( {modelLabel} )} {!isStreaming && } {!isStreaming && hasContent && ( )}
); }