Spaces:
Running
Running
| import { useState, useCallback, Suspense, lazy, forwardRef } from "react"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| import remarkMath from "remark-math"; | |
| import rehypeKatex from "rehype-katex"; | |
| import "katex/dist/katex.min.css"; | |
| import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; | |
| import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; | |
| import SourceCard from "./SourceCard"; | |
| // Lazy-load MermaidBlock β deferred so mermaid.js doesn't bloat the initial bundle. | |
| const MermaidBlock = lazy(() => import("./MermaidBlock")); | |
| // ReactMarkdown renders fenced code blocks as <pre><code>...</code></pre>. | |
| // If we override only `code`, ReactMarkdown wraps the whole thing in a <p>, | |
| // giving <p><pre>...</pre></p> β invalid HTML (pre can't be inside p). | |
| // | |
| // Fix: override `pre` to render just its children (no wrapper), so | |
| // SyntaxHighlighter's own <pre> is the only one. Then in `code` we check | |
| // whether it has a language class (block code) or not (inline code). | |
| const mdComponents = { | |
| // Wrap tables in a scrollable container so wide tables don't wrap cells | |
| table({ children }) { | |
| return <div style={{ overflowX: "auto", margin: "12px 0" }}><table style={{ margin: 0 }}>{children}</table></div>; | |
| }, | |
| // Strip the <pre> wrapper β SyntaxHighlighter adds its own | |
| pre({ children }) { | |
| return <>{children}</>; | |
| }, | |
| code({ className, children, ...props }) { | |
| const lang = /language-(\w+)/.exec(className || "")?.[1]; | |
| if (lang === "diagram" || lang === "mermaid") { | |
| // Agent drew a diagram β render as SVG via mermaid.js. | |
| // We intercept both "diagram" (our custom tag) and "mermaid" (model's natural tag). | |
| return ( | |
| <Suspense fallback={ | |
| <div style={{ padding: "12px 0", color: "var(--muted)", fontSize: 12 }}> | |
| <span className="spinner" style={{ marginRight: 8 }} /> Rendering diagram⦠| |
| </div> | |
| }> | |
| <MermaidBlock mermaid={String(children).replace(/\n$/, "")} /> | |
| </Suspense> | |
| ); | |
| } | |
| if (lang) { | |
| // Block code with a language tag β syntax-highlighted | |
| return ( | |
| <SyntaxHighlighter | |
| language={lang} | |
| style={oneDark} | |
| customStyle={{ fontSize: 13, background: '#06060F', borderRadius: 8, border: '1px solid rgba(255,255,255,0.07)', borderLeft: '2px solid rgba(91,143,249,0.50)', margin: '10px 0' }} | |
| > | |
| {String(children).replace(/\n$/, "")} | |
| </SyntaxHighlighter> | |
| ); | |
| } | |
| // Inline code β plain <code> | |
| return <code className={className} {...props}>{children}</code>; | |
| }, | |
| }; | |
| // Thought bubble β shows the LLM's reasoning before a tool call. | |
| // isActive = this is the currently-streaming thought (last item while streaming). | |
| // Past thoughts (agent already moved on) collapse to a one-liner β click to expand. | |
| function AgentThought({ text, isActive }) { | |
| const [expanded, setExpanded] = useState(false); | |
| // Shared node β same β β structure as tool steps so both rows align | |
| const node = ( | |
| <div className="agent-step-node"> | |
| <span className="agent-step-dot thought-dot" /> | |
| <span className="agent-step-arrow">β</span> | |
| </div> | |
| ); | |
| // While streaming, show the full thought text (no collapse, no chevron) | |
| if (isActive) { | |
| return ( | |
| <div className="agent-thought"> | |
| {node} | |
| {/* agent-thought-body mirrors agent-step-body margin so text aligns with tool step content */} | |
| <div className="agent-thought-body"> | |
| <div className="agent-thought-text">{text}</div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Past thought β one-line collapsed by default, click to expand | |
| const preview = text.length > 120 ? text.slice(0, 120) + "β¦" : text; | |
| return ( | |
| <div | |
| className={`agent-thought agent-thought-past${expanded ? " agent-thought-open" : ""}`} | |
| onClick={() => setExpanded(v => !v)} | |
| > | |
| {node} | |
| <div className="agent-thought-body"> | |
| <div className="agent-thought-header"> | |
| <div className="agent-thought-text"> | |
| {expanded ? text : preview} | |
| </div> | |
| <span className="agent-thought-chevron"> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |
| {expanded ? <path d="m4 6 4 4 4-4"/> : <path d="m6 4 4 4-4 4"/>} | |
| </svg> | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Convert tool+input into a short human-readable label shown in the step header. | |
| // Reads like a sentence fragment so the trace feels like watching the agent think, | |
| // not like reading a JSON dump. | |
| function formatStepQuery(tool, input) { | |
| if (!input) return ""; | |
| switch (tool) { | |
| case "search_code": return input.query || JSON.stringify(input); | |
| case "search_symbol": return input.symbol_name || JSON.stringify(input); | |
| case "list_files": return input.path ? `${input.repo}/${input.path}` : (input.repo || JSON.stringify(input)); | |
| case "find_callers": return input.function_name || JSON.stringify(input); | |
| case "get_file_chunk": return input.filepath | |
| ? `${input.filepath} (L${input.start_line}β${input.end_line})` | |
| : JSON.stringify(input); | |
| case "read_file": return input.filepath || JSON.stringify(input); | |
| case "note": return input.key ? `${input.key}: ${input.value}` : JSON.stringify(input); | |
| case "recall_notes": return "checking notes"; | |
| case "trace_calls": return input.symbol_name || JSON.stringify(input); | |
| default: return input.query || input.name || JSON.stringify(input); | |
| } | |
| } | |
| // Individual agent step β renders as a node in the connected timeline chain. | |
| // | |
| // Collapsed by default once a step is no longer the active one. | |
| // isActive = this step is currently executing (isLast && streaming). | |
| // Clicking a completed (non-active) step toggles its output open/closed. | |
| function AgentStep({ step, isLast, icon, streaming }) { | |
| const isActive = isLast && streaming; | |
| const isPending = !step.output && isActive; | |
| // manualExpand lets users re-open a completed step; resets when step becomes active again | |
| const [manualExpand, setManualExpand] = useState(false); | |
| const showOutput = isActive || manualExpand; | |
| const isLong = step.output && step.output.length > 300; | |
| const [outputExpanded, setOutputExpanded] = useState(false); | |
| const toggle = () => { | |
| if (!isActive) setManualExpand(v => !v); | |
| }; | |
| return ( | |
| <div className={`agent-step ${step.output ? "done" : "pending"}${isLast ? " last" : ""}${!showOutput && step.output ? " collapsed" : ""}`}> | |
| {/* Node dot on the vertical line + arrow connector */} | |
| <div className="agent-step-node"> | |
| <span className="agent-step-dot" /> | |
| <span className="agent-step-arrow">β</span> | |
| </div> | |
| {/* Step body */} | |
| <div className="agent-step-body"> | |
| <div | |
| className="agent-step-header" | |
| onClick={toggle} | |
| style={{ cursor: !isActive && step.output ? "pointer" : "default" }} | |
| > | |
| <span className="agent-step-icon">{icon}</span> | |
| <span className="agent-step-tool">{step.tool}</span> | |
| <span className="agent-step-query"> | |
| {formatStepQuery(step.tool, step.input)} | |
| </span> | |
| {isPending && <span className="spinner" style={{ marginLeft: "auto", flexShrink: 0, width: 10, height: 10 }} />} | |
| {!isActive && step.output && ( | |
| <span className="agent-step-chevron" style={{ marginLeft: "auto", opacity: 0.4 }}> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |
| {manualExpand ? <path d="m4 6 4 4 4-4"/> : <path d="m6 4 4 4-4 4"/>} | |
| </svg> | |
| </span> | |
| )} | |
| </div> | |
| {showOutput && step.output && ( | |
| <> | |
| <div | |
| className={`agent-step-output${outputExpanded ? " expanded" : isLong ? " clipped" : ""}`} | |
| onClick={() => isLong && !outputExpanded && setOutputExpanded(true)} | |
| > | |
| {step.output} | |
| </div> | |
| {isLong && !outputExpanded && ( | |
| <button className="agent-step-expand" onClick={() => setOutputExpanded(true)}> | |
| Show full output β | |
| </button> | |
| )} | |
| {isLong && outputExpanded && ( | |
| <button className="agent-step-expand" onClick={() => setOutputExpanded(false)}> | |
| Collapse β | |
| </button> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ToolCallTrace shows the agent's reasoning steps as a connected timeline β | |
| // visually similar to how Claude Code shows "Agent β Bash β Read" with | |
| // vertical lines connecting each step. | |
| // | |
| // DURING streaming: always expanded so user can watch the agent think live. | |
| // AFTER completion: collapsible via the toggle header. | |
| function ToolCallTrace({ steps, streaming, iterations, model }) { | |
| const [expanded, setExpanded] = useState(true); | |
| if (!steps || steps.length === 0) return null; | |
| // Tool name β icon SVG for clean visual scanning (no emoji) | |
| const toolIcon = { | |
| search_code: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5"/><path d="M10 10l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>, | |
| search_symbol: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M3 4h10M3 8h7M3 12h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><circle cx="12" cy="11" r="2.5" stroke="currentColor" strokeWidth="1.3"/><path d="M14 13l1.5 1.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>, | |
| list_files: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.5"/><path d="M4 6h8M4 9h5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>, | |
| get_file_chunk: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="3" y="1" width="8" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/><path d="M5 5h4M5 7h3M9 1v3h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>, | |
| read_file: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="2" y="1" width="9" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><path d="M4 5h5M4 7h5M4 9h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/><path d="M11 8l3 3-3 3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>, | |
| find_callers: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><circle cx="4" cy="4" r="2" stroke="currentColor" strokeWidth="1.4"/><circle cx="12" cy="12" r="2" stroke="currentColor" strokeWidth="1.4"/><path d="M6 4h2a2 2 0 012 2v2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>, | |
| note: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M3 2h10a1 1 0 011 1v8l-3 3H3a1 1 0 01-1-1V3a1 1 0 011-1z" stroke="currentColor" strokeWidth="1.4"/><path d="M11 11v3l3-3h-3z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round"/><path d="M4 5h6M4 7h6M4 9h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>, | |
| recall_notes: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.4"/><path d="M8 5v3.5l2.5 1.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>, | |
| trace_calls: <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><circle cx="3" cy="8" r="2" stroke="currentColor" strokeWidth="1.4"/><circle cx="13" cy="4" r="2" stroke="currentColor" strokeWidth="1.4"/><circle cx="13" cy="12" r="2" stroke="currentColor" strokeWidth="1.4"/><path d="M5 8h3M8 8L11 5M8 8l3 3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>, | |
| }; | |
| const defaultIcon = <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="5" stroke="currentColor" strokeWidth="1.5"/><path d="M8 5v3l2 1" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>; | |
| // Compute the index of the last non-thought step so we can pass isLast correctly | |
| const lastToolIdx = steps.reduce((acc, s, i) => s.type !== "thought" ? i : acc, -1); | |
| const stepsEl = ( | |
| <div className="agent-trace-steps"> | |
| {/* Vertical connector line running the full height */} | |
| <div className="agent-trace-line" /> | |
| {steps.map((step, i) => { | |
| if (step.type === "thought") { | |
| // A thought is "active" (shown in full) only while it's the last item | |
| // and the agent is still streaming β once the agent emits a tool call | |
| // after it, the thought is "past" and collapses to a one-liner. | |
| const isActiveThought = streaming && i === steps.length - 1; | |
| return <AgentThought key={i} text={step.text} isActive={isActiveThought} />; | |
| } | |
| return ( | |
| <AgentStep | |
| key={i} | |
| step={step} | |
| isLast={i === lastToolIdx} | |
| icon={toolIcon[step.tool] || defaultIcon} | |
| streaming={streaming} | |
| /> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| return ( | |
| <div className={`agent-trace${streaming ? " live" : ""}`}> | |
| <button | |
| className="agent-trace-toggle" | |
| onClick={() => !streaming && setExpanded(v => !v)} | |
| aria-expanded={expanded} | |
| style={{ cursor: streaming ? "default" : "pointer" }} | |
| > | |
| {/* "Agent" node β the root of the chain */} | |
| <span className="agent-trace-root-dot" /> | |
| <span className="agent-trace-root-label">Agent</span> | |
| {streaming | |
| ? <span className="spinner" style={{ marginLeft: 6, width: 10, height: 10 }} /> | |
| : (() => { | |
| // Use backend's authoritative iteration count when available. | |
| // steps.length = tool calls only; iterations = full ReAct turns | |
| // (includes the final answer turn, so it's always >= steps.length). | |
| const count = iterations ?? steps.length; | |
| const label = iterations ? "iteration" : "tool call"; | |
| return ( | |
| <> | |
| <span className="agent-trace-count">{count} {label}{count !== 1 ? "s" : ""}</span> | |
| {model && ( | |
| <span style={{ opacity: 0.4, fontStyle: "italic", fontSize: 10.5, marginLeft: 6 }} | |
| title={`Model: ${model}`}> | |
| {model.split("/").pop()} | |
| </span> | |
| )} | |
| </> | |
| ); | |
| })() | |
| } | |
| {!streaming && ( | |
| <span className="agent-trace-chevron"> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |
| {expanded ? <path d="m4 6 4 4 4-4"/> : <path d="m6 4 4 4-4 4"/>} | |
| </svg> | |
| </span> | |
| )} | |
| </button> | |
| {expanded && stepsEl} | |
| {/* When collapsed, show the first thought as a one-line summary so users can still see the agent's reasoning intent */} | |
| {!expanded && !streaming && (() => { | |
| const firstThought = steps.find(s => s.type === "thought"); | |
| if (!firstThought) return null; | |
| return ( | |
| <div className="agent-trace-thought-summary" title={firstThought.text}> | |
| "{firstThought.text.length > 120 ? firstThought.text.slice(0, 120) + "β¦" : firstThought.text}" | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| ); | |
| } | |
| // ConfidenceBadge β rendered after model-based grading completes. | |
| // high = all claims confirmed in sources β green check (shown in pipeline bar only) | |
| // medium = mostly supported, minor extrapolation β amber warning | |
| // low = claims not backed by sources β red warning | |
| const CONFIDENCE_CONFIG = { | |
| high: { color: "#10b981", bg: "rgba(16,185,129,0.10)", icon: "β", label: "High confidence" }, | |
| medium: { color: "#f59e0b", bg: "rgba(245,158,11,0.10)", icon: "β", label: "Medium confidence" }, | |
| low: { color: "#ef4444", bg: "rgba(239,68,68,0.10)", icon: "β ", label: "Low confidence" }, | |
| }; | |
| function ConfidenceBadge({ grade }) { | |
| const cfg = CONFIDENCE_CONFIG[grade.confidence] || CONFIDENCE_CONFIG.medium; | |
| return ( | |
| <div style={{ | |
| display: "inline-flex", alignItems: "flex-start", gap: 6, | |
| marginTop: 8, | |
| padding: "5px 10px", | |
| background: cfg.bg, | |
| border: `1px solid ${cfg.color}33`, | |
| borderRadius: 6, | |
| fontSize: 11.5, | |
| fontFamily: "var(--mono)", | |
| color: cfg.color, | |
| maxWidth: "100%", | |
| }}> | |
| <span style={{ flexShrink: 0, marginTop: 1 }}>{cfg.icon} {cfg.label}</span> | |
| {grade.note && ( | |
| <span style={{ color: "var(--text-2)", fontFamily: "inherit", fontSize: 11 }}> | |
| β {grade.note} | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Copy-answer button β appears on hover over the assistant message. | |
| // Copies the raw markdown text so developers can paste it into docs/code. | |
| function CopyAnswerButton({ content }) { | |
| const [copied, setCopied] = useState(false); | |
| const handleCopy = useCallback(() => { | |
| navigator.clipboard.writeText(content).then(() => { | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 1800); | |
| }); | |
| }, [content]); | |
| return ( | |
| <button | |
| className="copy-answer-btn" | |
| onClick={handleCopy} | |
| title={copied ? "Copied!" : "Copy answer"} | |
| aria-label="Copy answer to clipboard" | |
| > | |
| {copied | |
| ? <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> | |
| : <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25z"/></svg> | |
| } | |
| </button> | |
| ); | |
| } | |
| const Message = forwardRef(function Message({ msg, onDiagramThis, onRetry, showRepo = false }, ref) { | |
| const isUser = msg.role === "user"; | |
| return ( | |
| <div ref={ref} className={`message ${msg.role}`}> | |
| {isUser ? ( | |
| <div className="bubble">{msg.content}</div> | |
| ) : ( | |
| <> | |
| {/* Assistant avatar β β¦ for agent responses, code icon for RAG. | |
| This matches the β¦ badge on agent sessions in the sidebar, | |
| making the visual language consistent: β¦ = agent mode. */} | |
| <div className="message-avatar assistant" aria-hidden="true"> | |
| {msg.mode === "agent" | |
| ? /* Agent mode β β¦ sparkle signals autonomous reasoning / ReAct loop */ | |
| <span style={{ fontSize: 14, lineHeight: 1, color: "white", opacity: 0.9 }}>β¦</span> | |
| : /* RAG mode β compass brand mark, signals "Cartographer navigating the code" */ | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none"> | |
| <path d="M12 2 L14.5 7 L12 12 L9.5 7 Z" fill="white" opacity="0.95"/> | |
| <path d="M12 22 L13.5 17 L12 12 L10.5 17 Z" fill="white" opacity="0.45"/> | |
| <path d="M22 12 L17 10.5 L12 12 L17 13.5 Z" fill="white" opacity="0.45"/> | |
| <path d="M2 12 L7 10.5 L12 12 L7 13.5 Z" fill="white" opacity="0.45"/> | |
| <circle cx="12" cy="12" r="1.5" fill="white"/> | |
| </svg> | |
| } | |
| </div> | |
| {/* All assistant content in a column wrapper */} | |
| <div className="message-content"> | |
| {/* Mode tag β always shown on assistant responses so chat history is scannable. | |
| Uses msg.mode (set explicitly at creation, never mutated) so async callbacks | |
| updating queryType/phase/model can never flip the label to the wrong mode. */} | |
| {/* Mode tag β RAG only. Agent already has the ToolCallTrace header showing | |
| "Agent Β· N iterations Β· model", so a second label would be redundant. */} | |
| {msg.mode === "rag" && ( | |
| <div className="msg-mode-tag"> | |
| <span className="msg-mode-icon">β</span> | |
| <span className="msg-mode-label">RAG</span> | |
| {msg.queryType && ( | |
| <span className="msg-mode-detail">Β· {msg.queryType}</span> | |
| )} | |
| {msg.model && ( | |
| <span className="msg-mode-model" title={`Model: ${msg.model}`}> | |
| Β· {msg.model.split("/").pop()} | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| {/* Agent reasoning trace */} | |
| {msg.toolCalls && msg.toolCalls.length > 0 && ( | |
| <ToolCallTrace steps={msg.toolCalls} streaming={msg.streaming} iterations={msg.iterations} model={msg.model} /> | |
| )} | |
| {/* "Thinkingβ¦" shown before first tool call in agent mode */} | |
| {msg.streaming && msg.currentTool === null && !msg.content && (!msg.toolCalls || msg.toolCalls.length === 0) && !msg.phase && ( | |
| <div className="agent-thinking"> | |
| <span className="spinner" role="status" aria-label="Thinking" /> | |
| Thinking⦠| |
| </div> | |
| )} | |
| {/* RAG retrieval phase indicator β makes the invisible retrieval step visible. | |
| "searching" = waiting for vector search to return sources. | |
| "generating" = sources received, LLM is now streaming the answer. */} | |
| {msg.streaming && msg.phase && ( | |
| <div className={`stream-phase stream-phase--${msg.phase}`}> | |
| <span className="stream-phase-dot" /> | |
| {msg.phase === "searching" | |
| ? "Searching codeβ¦" | |
| : `Found ${msg.sourceCount ?? "?"} source${msg.sourceCount !== 1 ? "s" : ""} Β· Generating answerβ¦` | |
| } | |
| </div> | |
| )} | |
| {/* Rate-limit countdown banner β shown instead of a hard error */} | |
| {msg.rateLimited && ( | |
| <div className="rate-limit-banner"> | |
| <span className="rate-limit-spinner" aria-hidden="true" /> | |
| <span className="rate-limit-text">{msg.content}</span> | |
| {onRetry && msg.retryQuestion && ( | |
| <button | |
| className="rate-limit-retry-btn" | |
| onClick={() => onRetry(msg.retryQuestion)} | |
| > | |
| Retry now | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {/* Answer bubble */} | |
| <div className="bubble" style={{ position: "relative", display: msg.rateLimited ? "none" : undefined }}> | |
| <ReactMarkdown components={mdComponents} remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}> | |
| {msg.content || " "} | |
| </ReactMarkdown> | |
| {/* Show cursor whenever streaming, not just when no tool active */} | |
| {msg.streaming && <span className="cursor" aria-hidden="true" />} | |
| {/* Copy-answer button β visible on hover; lets devs paste the answer */} | |
| {!msg.streaming && msg.content && <CopyAnswerButton content={msg.content} />} | |
| </div> | |
| {/* Pipeline provenance β shows every retrieval stage that fired for this answer. | |
| Positioned HERE (before sources) so it's immediately visible after the answer, | |
| not buried below N source cards. Quality features only shown when they ran. */} | |
| {!msg.streaming && msg.queryType && !msg.iterations && ( | |
| <div className="pipeline-provenance"> | |
| {msg.pipeline?.hyde && ( | |
| <> | |
| <span className="pipeline-stage pipeline-quality" title="Hypothetical Document Embeddings: a code snippet was generated from your question and used for retrieval instead of the raw query text"> | |
| HyDE | |
| </span> | |
| <span className="pipeline-sep">β</span> | |
| </> | |
| )} | |
| {msg.pipeline?.expanded > 0 && ( | |
| <> | |
| <span className="pipeline-stage pipeline-quality" title={`Query Expansion: ${msg.pipeline.expanded} alternative phrasings were searched and merged with RRF`}> | |
| +{msg.pipeline.expanded} expansions | |
| </span> | |
| <span className="pipeline-sep">β</span> | |
| </> | |
| )} | |
| <span className="pipeline-stage" title={`${msg.queryType === "hybrid" ? "Hybrid: dense semantic vectors + BM25 keyword search, fused with Reciprocal Rank Fusion" : msg.queryType === "semantic" ? "Dense semantic search: nearest-neighbour lookup in 768-dim embedding space" : "BM25 keyword search: exact and fuzzy term matching"}`}> | |
| <svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4.5"/><path d="M10 10l3.5 3.5"/></svg> | |
| {msg.queryType} search | |
| </span> | |
| <span className="pipeline-sep">β</span> | |
| <span | |
| className="pipeline-stage" | |
| title={msg.pipeline?.reranker === "cohere" ? "Cohere rerank-v3.5: API cross-encoder re-scores every candidate against your question for maximum precision" : "Local ms-marco cross-encoder: re-scores candidates locally without an API call"} | |
| > | |
| {msg.pipeline?.reranker === "cohere" ? "cohere re-ranked" : "re-ranked"} | |
| </span> | |
| <span className="pipeline-sep">β</span> | |
| {msg.pipeline?.parent_docs > 0 && ( | |
| <> | |
| <span className="pipeline-stage pipeline-quality" title={`Parent-document retrieval: ${msg.pipeline.parent_docs} function chunk${msg.pipeline.parent_docs !== 1 ? "s" : ""} expanded to enclosing class for richer LLM context`}> | |
| β {msg.pipeline.parent_docs} expanded | |
| </span> | |
| <span className="pipeline-sep">β</span> | |
| </> | |
| )} | |
| <span className="pipeline-stage" title={`${msg.sources?.length ?? 0} code chunk${(msg.sources?.length ?? 0) !== 1 ? "s" : ""} were retrieved and passed as context to the LLM`}>{msg.sources?.length ?? 0} source{(msg.sources?.length ?? 0) !== 1 ? "s" : ""}</span> | |
| <span className="pipeline-sep">β</span> | |
| <span className="pipeline-stage" title="The LLM generated this answer using only the retrieved sources as context β it cannot see code outside these chunks">generated</span> | |
| {msg.model && ( | |
| <> | |
| <span className="pipeline-sep">Β·</span> | |
| <span | |
| className="pipeline-stage" | |
| style={{ opacity: 0.45, fontStyle: "italic" }} | |
| title={`Model: ${msg.model}`} | |
| > | |
| {msg.model.split("/").pop()} | |
| </span> | |
| </> | |
| )} | |
| {msg.grade && msg.grade.confidence !== "unknown" && ( | |
| <> | |
| <span className="pipeline-sep">β</span> | |
| <span className={`pipeline-stage pipeline-grade-${msg.grade.confidence}`}> | |
| {msg.grade.confidence === "high" ? "β" : msg.grade.confidence === "low" ? "β " : "β"} {msg.grade.confidence} | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| {/* Badges + Sources β query type shown as sources header for context */} | |
| {/* (agent iteration count is shown in the ToolCallTrace header above) */} | |
| {msg.sources && msg.sources.length > 0 && !msg.streaming && ( | |
| <div className="sources"> | |
| <div className="sources-header"> | |
| {msg.sources.length} source{msg.sources.length > 1 ? "s" : ""} | |
| {msg.queryType && !msg.iterations && ( | |
| <span className="query-type-badge" style={{ marginLeft: 8 }}>{msg.queryType}</span> | |
| )} | |
| </div> | |
| {msg.sources.map((s, i) => ( | |
| <SourceCard key={i} source={s} index={i + 1} showRepo={showRepo} /> | |
| ))} | |
| {/* "Diagram this β" button β switches to diagram tab with focused-files context */} | |
| {onDiagramThis && ( | |
| <button | |
| className="diagram-this-btn" | |
| onClick={() => onDiagramThis(msg.sources)} | |
| title="Switch to Diagram tab and highlight the files cited in this answer" | |
| > | |
| Diagram this β | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {/* Query type badge when no sources (e.g. factual answer with no retrieved chunks) */} | |
| {!msg.streaming && !msg.iterations && msg.queryType && !(msg.sources?.length > 0) && ( | |
| <span className="query-type-badge">{msg.queryType}</span> | |
| )} | |
| {/* Standalone confidence badge for medium/low β shows the note text */} | |
| {msg.grade && msg.grade.confidence !== "unknown" && msg.grade.confidence !== "high" && ( | |
| <ConfidenceBadge grade={msg.grade} /> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| }); | |
| export default Message; | |