import { Children, cloneElement, isValidElement, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import type { Chat, Message } from '@/api/types'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { ToolCallCard } from './ToolCallCard'; import { CodeBlock } from './CodeBlock'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; // Match path-shaped substrings ending in `.ext`. Additionally require a `/` // in the match to reduce false positives in prose (e.g. plain `foo.ts` won't // match, but `src/foo.ts` will). False positives at the edges are accepted // per Sam's design decision (2026-05-14). const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g; function isPathLike(s: string): boolean { return s.includes('/'); } function emitOpenFile(path: string): void { sessionEvents.emit({ type: 'open_file_in_browser', path }); } // Split a plain string into a flat array of strings and clickable button // nodes for path-shaped substrings. If no matches, returns the original // string verbatim (no array wrapping). function linkifyPaths(text: string, keyPrefix: string): ReactNode { const out: ReactNode[] = []; let lastIdx = 0; let idx = 0; for (const match of text.matchAll(PATH_REGEX)) { const matchedText = match[0]; const start = match.index ?? 0; if (!isPathLike(matchedText)) continue; if (start > lastIdx) out.push(text.slice(lastIdx, start)); out.push( ); lastIdx = start + matchedText.length; idx += 1; } if (out.length === 0) return text; if (lastIdx < text.length) out.push(text.slice(lastIdx)); return out; } // Walk react-markdown children, linkifying string text nodes. Children of // nodes (CodeBlock and inline code) are left untouched — the regex // shouldn't run inside code spans. function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode { const arr = Children.toArray(children); return arr.map((child, i) => { if (typeof child === 'string') { return ( {linkifyPaths(child, `${keyPrefix}-${i}`)} ); } if (isValidElement(child)) { const el = child as ReactElement<{ children?: ReactNode }>; // Skip inline/block code — paths in code spans aren't link targets. if (el.type === 'code' || el.type === CodeBlock) return child; const grandchildren = el.props.children; if (grandchildren === undefined) return child; return cloneElement(el, { key: el.key ?? `linkified-${i}`, children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`), }); } return child; }); } interface Props { message: Message; sessionChats?: Chat[]; } function MarkdownBody({ content }: { content: string }) { return ( <>{children}, code: (props) => { const { children, className, ...rest } = props as { children?: unknown; className?: string; }; const text = String(children ?? '').replace(/\n$/, ''); const langMatch = /language-([\w-]+)/.exec(className ?? ''); const isBlock = !!langMatch || text.includes('\n'); if (isBlock) { return ; } return ( {children as React.ReactNode} ); }, a: ({ children, href }) => ( {children} ), ul: ({ children }) => (
    {children}
), ol: ({ children }) => (
    {children}
), li: ({ children }) =>
  • {linkifyChildren(children)}
  • , p: ({ children }) => (

    {linkifyChildren(children)}

    ), h1: ({ children }) =>

    {children}

    , h2: ({ children }) =>

    {children}

    , h3: ({ children }) =>

    {children}

    , blockquote: ({ children }) => (
    {children}
    ), table: ({ children }) => (
    {children}
    ), th: ({ children }) => ( {children} ), td: ({ children }) => ( {linkifyChildren(children)} ), }} > {content}
    ); } 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, }: { message: Message; }) { 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); 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 { 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 fork() { if (forking || message.status !== 'complete') return; setForking(true); try { const chat = await api.chats.fork(message.chat_id, { messageId: message.id }); sessionEvents.emit({ type: 'open_chat_in_active_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 { 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); } } const isAssistant = message.role === 'assistant'; const canRegen = isAssistant && message.status !== 'streaming'; const canFork = message.status === 'complete'; const canDelete = message.status !== 'streaming'; return ( <>
    {isAssistant && ( )}
    { 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. ); } 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}
    )}
    ); } export function MessageBubble({ message, sessionChats }: Props) { if (message.kind === 'compact') { return ; } if (message.role === 'tool') { return ; } if (message.role === 'user') { return (
    {message.content}
    ); } const isStreaming = message.status === 'streaming'; const failed = message.status === 'failed'; const hasContent = message.content.length > 0; const hasToolCalls = (message.tool_calls?.length ?? 0) > 0; return (
    {message.tool_calls?.map((tc) => ( ))} {(hasContent || (!hasToolCalls && isStreaming)) && (
    {hasContent ? : null} {isStreaming && ( )}
    )} {failed && (
    message failed
    )} {!isStreaming && } {!isStreaming && (hasContent || hasToolCalls) && ( )}
    ); }