"use client"; import { useId, useRef, useEffect, useState } from "react"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkMath from "remark-math"; import remarkGfm from "remark-gfm"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import { Copy, Check, ChevronDown, Download, File, FileText, Loader2, Scale, } from "lucide-react"; import { MikeIcon } from "@/components/chat/mike-icon"; import { displayCitationQuote, formatCitationPage } from "../shared/types"; import type { AssistantEvent, CitationAnnotation, EditAnnotation, } from "../shared/types"; import { EditCard, applyOptimisticResolution } from "./EditCard"; import { PreResponseWrapper } from "../shared/PreResponseWrapper"; import { supabase } from "@/lib/supabase"; const RESPONSE_GLASS_SURFACE = "rounded-xl border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl"; const RESPONSE_GLASS_ANNOTATION = "inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950"; function toolCallLabel(name: string): string { if (name === "generate_docx") return "Creating document..."; if (name === "edit_document") return "Editing document..."; if (name === "read_document") return "Reading document..."; if (name === "fetch_documents") return "Reading documents..."; if (name === "find_in_document") return "Searching document..."; if (name === "replicate_document") return "Copying document..."; if (name === "read_workflow") return "Loading workflow..."; if (name === "list_workflows") return "Loading workflows..."; if (name === "list_documents") return "Loading documents..."; if (name === "courtlistener_search_case_law") return "Searching case law..."; if (name === "courtlistener_get_cases") return "Fetching cases..."; if (name === "courtlistener_find_in_case") return "Searching case..."; if (name === "courtlistener_read_case") return "Reading case..."; if (name === "courtlistener_verify_citations") return "Verifying citations..."; return name ? `Running ${name}...` : "Working..."; } /** * Card rendered above the per-edit EditCards when a message produced * multiple tracked-change proposals. Lets the user resolve every pending * edit in one click by firing the per-edit accept/reject endpoint for each * pending annotation and forwarding each response to `onResolved` so the * parent can bump the viewer version, persist override URLs, etc. * * This intentionally doesn't apply the optimistic DOM mutation that * EditCard does — bulk operations touch many edits at once and the real * re-render from the latest version will reconcile within a second or so. */ function BulkEditActions({ pending, filenameByDocId, onViewClick, onResolveStart, onResolved, onError, }: { pending: { annotation: EditAnnotation; filename: string; }[]; filenameByDocId: Map; onViewClick?: (ann: EditAnnotation, filename: string) => void; onResolveStart?: (args: { editId: string; documentId: string; verb: "accept" | "reject"; }) => void; onResolved?: (args: { editId: string; documentId: string; status: "accepted" | "rejected"; versionId: string | null; downloadUrl: string | null; }) => void; onError?: (args: { editId: string; documentId: string; versionId: string | null; message: string; }) => void; }) { const [busy, setBusy] = useState<"accept" | "reject" | null>(null); const [progress, setProgress] = useState<{ done: number; total: number; } | null>(null); if (pending.length === 0) return null; const handleAll = async (verb: "accept" | "reject") => { if (busy) return; setBusy(verb); setProgress({ done: 0, total: pending.length }); try { const { data: { session }, } = await supabase.auth.getSession(); const token = session?.access_token; const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; // Sequential so the per-document version counter advances in a // predictable order and the viewer doesn't race between bumps. let done = 0; for (const { annotation } of pending) { onResolveStart?.({ editId: annotation.edit_id, documentId: annotation.document_id, verb, }); // Optimistically mutate the DOM so the viewer reflects the // resolution immediately. Revert if the backend call fails. let revert: (() => void) | null = null; try { revert = applyOptimisticResolution(annotation, verb); } catch (e) { console.error( "[BulkEditActions] optimistic update threw", e, ); } try { const resp = await fetch( `${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : undefined, }, ); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = (await resp.json()) as { ok: boolean; status?: "accepted" | "rejected"; version_id: string | null; download_url: string | null; }; const nextStatus = data.status ?? (verb === "accept" ? "accepted" : "rejected"); onResolved?.({ editId: annotation.edit_id, documentId: annotation.document_id, status: nextStatus, versionId: data.version_id, downloadUrl: data.download_url, }); } catch (e) { console.error("[BulkEditActions] resolve failed", e); try { revert?.(); } catch (revertErr) { console.error( "[BulkEditActions] revert threw", revertErr, ); } onError?.({ editId: annotation.edit_id, documentId: annotation.document_id, versionId: annotation.version_id ?? null, message: verb === "accept" ? "Couldn't save one or more accepts." : "Couldn't save one or more rejects.", }); } done++; setProgress({ done, total: pending.length }); } } finally { setBusy(null); setProgress(null); } }; // Optional: show a tiny "View first" action so bulk doesn't lose the // in-viewer scroll-to behaviour entirely. const first = pending[0]; return (
{progress && ( {progress.done}/{progress.total} )} {onViewClick && first && ( )}
); } /** * Wraps the bulk accept/reject card and the per-edit EditCards in a single * minimisable container. The bulk actions and summary stay visible in the * header; the individual cards collapse via the chevron toggle. */ function EditCardsSection({ pending, filenameByDocId, cards, resolvedCount, onViewClick, onResolveStart, onResolved, onError, }: { pending: { annotation: EditAnnotation; filename: string; }[]; filenameByDocId: Map; cards: React.ReactNode[]; resolvedCount: number; onViewClick?: (ann: EditAnnotation, filename: string) => void; onResolveStart?: (args: { editId: string; documentId: string; verb: "accept" | "reject"; }) => void; onResolved?: (args: { editId: string; documentId: string; status: "accepted" | "rejected"; versionId: string | null; downloadUrl: string | null; }) => void; onError?: (args: { editId: string; documentId: string; versionId: string | null; message: string; }) => void; }) { const [isOpen, setIsOpen] = useState(true); if (cards.length === 0) return null; const docCount = filenameByDocId.size; const summary = pending.length > 0 ? docCount > 1 ? `${pending.length} tracked changes across ${docCount} documents` : `${pending.length} tracked ${pending.length === 1 ? "change" : "changes"}` : docCount > 1 ? `${resolvedCount} resolved tracked changes across ${docCount} documents` : `${resolvedCount} resolved tracked ${resolvedCount === 1 ? "change" : "changes"}`; return (
{/* Row 1: summary + chevron */}

{summary}

{/* Row 2: bulk action buttons */} {pending.length > 0 && (
)} {/* Row 3: collapsible cards list */} {isOpen && (
{cards}
)} {!isOpen &&
}
); } // --------------------------------------------------------------------------- // ResponseStatus // --------------------------------------------------------------------------- type StatusState = "active" | "error" | null; function ResponseStatus({ status }: { status: StatusState }) { const [showDone, setShowDone] = useState(false); const [doneVisible, setDoneVisible] = useState(false); const wasActiveRef = useRef(false); const isActive = status === "active"; const isError = status === "error"; useEffect(() => { if (wasActiveRef.current && !isActive) { setShowDone(true); setDoneVisible(true); const t = setTimeout(() => setDoneVisible(false), 1500); return () => clearTimeout(t); } else if (!wasActiveRef.current && isActive) { setShowDone(false); setDoneVisible(false); } wasActiveRef.current = isActive; }, [isActive]); return (
); } function eventErrorMessage(event: AssistantEvent): string | null { if (event.type === "error") return event.message; if ("error" in event && typeof event.error === "string" && event.error) { return event.error; } return null; } // --------------------------------------------------------------------------- // Event block components // --------------------------------------------------------------------------- const THINKING_PHRASES = [ "Thinking...", "Pondering...", "Analyzing...", "Reviewing...", "Reasoning...", ]; const REASONING_COLLAPSED_MAX_LINES = 6; const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9; function ReasoningBlock({ text, isStreaming, showConnector, }: { text: string; isStreaming: boolean; showConnector?: boolean; }) { const [isContentOpen, setIsContentOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [userToggledContent, setUserToggledContent] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false); const [hasMeasured, setHasMeasured] = useState(false); const [thinkingIndex, setThinkingIndex] = useState(0); const contentRef = useRef(null); useEffect(() => { if (!isStreaming) return; const interval = setInterval(() => { setThinkingIndex((i) => (i + 1) % THINKING_PHRASES.length); }, 2000); return () => clearInterval(interval); }, [isStreaming]); useEffect(() => { const el = contentRef.current; if (!el) return; const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24; const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES; const nextOverflowing = el.scrollHeight > maxHeight + 2; setIsOverflowing(nextOverflowing); setHasMeasured(true); if (!userToggledContent) setIsContentOpen(isStreaming); if (!nextOverflowing) setIsExpanded(false); }, [isStreaming, text, userToggledContent]); const showContent = isContentOpen || isStreaming || !hasMeasured; const isCollapsed = isContentOpen && isOverflowing && !isExpanded; return (
{showConnector && (
)} {showContent && (
( ), }} > {text}
{isCollapsed && ( <>
)}
{isOverflowing && isContentOpen && isExpanded && ( )}
)}
); } function DocReadBlock({ filename, onClick, showConnector, isStreaming, }: { filename: string; onClick?: () => void; showConnector?: boolean; isStreaming?: boolean; }) { return (
{showConnector && (
)} {isStreaming ? (
) : (
)}
{isStreaming ? "Reading" : "Read"} {" "} {isStreaming ? ( {filename}... ) : onClick ? ( ) : ( {filename} )}
); } function DocFindBlock({ filename, query, totalMatches, isStreaming, showConnector, }: { filename: string; query: string; totalMatches: number; isStreaming?: boolean; showConnector?: boolean; }) { const label = isStreaming ? "Finding" : "Found"; const matchSuffix = isStreaming ? "" : ` (${totalMatches} ${totalMatches === 1 ? "match" : "matches"})`; return (
{showConnector && (
)} {isStreaming ? (
) : (
0 ? "bg-green-400" : "bg-gray-300"}`} /> )}
{label}{" "} “{query}”{matchSuffix} in {filename} {isStreaming && "..."}
); } function DocCreatedBlock({ filename, showConnector, isStreaming, }: { filename: string; showConnector?: boolean; isStreaming?: boolean; }) { return (
{showConnector && (
)} {isStreaming ? (
) : (
)}
{isStreaming ? "Creating" : "Created"} {" "} {isStreaming ? `${filename}...` : filename}
); } function DocReplicatedBlock({ filename, count, showConnector, isStreaming, hasError, }: { filename: string; /** * How many consecutive replicates of this same source got collapsed * into this block. ≥ 1; only rendered when > 1. */ count: number; showConnector?: boolean; isStreaming?: boolean; hasError?: boolean; }) { const label = isStreaming ? "Replicating" : "Replicated"; const suffix = !isStreaming && count > 1 ? ` ${count} times` : isStreaming ? "..." : ""; return (
{showConnector && (
)} {isStreaming ? (
) : (
)}
{label}{" "} {filename} {suffix}
); } function DocDownloadBlock({ filename, download_url, onOpen, isReloading = false, versionNumber, }: { filename: string; download_url: string; onOpen?: () => void; isReloading?: boolean; versionNumber?: number | null; }) { const hasVersion = typeof versionNumber === "number" && Number.isFinite(versionNumber) && versionNumber > 0; const extMatch = filename.match(/\.(\w+)$/); const ext = extMatch ? extMatch[1].toUpperCase() : "FILE"; const rawBasename = extMatch ? filename.slice(0, -extMatch[0].length) : filename; // Strip any legacy "[Edited V3]" suffix that may still be baked into // older saved download filenames — the version is surfaced as a // separate tag now. const basename = rawBasename.replace(/\s*\[Edited V\d+\]\s*$/, "").trim(); // Only backend-relative URLs are accepted. The download fetch carries // the user's bearer token, so any absolute URL from tool output is // refused to keep the token from leaking off-origin. const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; const isSafeHref = download_url.startsWith("/"); const href = isSafeHref ? `${API_BASE}${download_url}` : null; const [busy, setBusy] = useState(false); const handleDownload = async (e?: { stopPropagation?: () => void; preventDefault?: () => void; }) => { e?.stopPropagation?.(); e?.preventDefault?.(); if (busy || isReloading || !href) return; setBusy(true); try { const { data: { session }, } = await supabase.auth.getSession(); const token = session?.access_token; const resp = await fetch(href, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } finally { setBusy(false); } }; const spinning = busy || isReloading; const body = (

{basename}

{hasVersion && ( V{versionNumber} )}

{ext}

); const downloadIcon = spinning ? (
) : ( ); if (onOpen) { return (
{downloadIcon}
); } if (spinning) { return (
{body} {downloadIcon}
); } return (
{downloadIcon}
); } function WorkflowAppliedBlock({ title, showConnector, onClick, }: { title: string; showConnector?: boolean; onClick?: () => void; }) { return (
{showConnector && (
)}
Applied Workflow{" "} {onClick ? ( ) : ( {title} )}
); } type CourtListenerBlockItem = { caseName: string | null; citation: string | null; dateFiled?: string | null; url?: string | null; query?: string; totalMatches?: number; hasError?: boolean; }; function CourtListenerBlock({ label, detail, isStreaming, hasError, showConnector, items, }: { label: string; detail?: string; isStreaming?: boolean; hasError?: boolean; showConnector?: boolean; items?: CourtListenerBlockItem[]; }) { const [isOpen, setIsOpen] = useState(false); const hasItems = !!items && items.length > 0; return (
{showConnector && (
)}
{isStreaming ? (
) : (
)}
{hasItems ? ( ) : ( <> {label} {detail ? {detail} : null} {isStreaming ? ... : null} )}
{isOpen && hasItems && (
    {items!.map((item, idx) => { const label = [item.caseName, item.citation] .filter(Boolean) .join(", "); const primary = label || item.url || "Unknown case"; const searchText = item.query ? `Searched for "${item.query}" in ${primary}${ typeof item.totalMatches === "number" ? ` (${item.totalMatches} ${ item.totalMatches === 1 ? "match" : "matches" })` : "" }` : null; return (
  • {item.url ? ( {searchText ?? primary} ) : searchText ? ( {searchText} ) : ( {primary} )}
  • ); })}
)}
); } function DocEditedBlock({ filename, showConnector, isStreaming, hasError, }: { filename: string; showConnector?: boolean; isStreaming?: boolean; hasError?: boolean; }) { return (
{showConnector && (
)} {isStreaming ? (
) : hasError ? (
) : (
)}
{isStreaming ? "Editing" : hasError ? "Edit failed" : "Edited"} {" "} {isStreaming ? `${filename}...` : filename}
); } // --------------------------------------------------------------------------- // Citation preprocessing // --------------------------------------------------------------------------- function preprocessCitations( text: string, annotations: CitationAnnotation[], citationsList: CitationAnnotation[], ): string { // Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by annotations return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr, offset) => { const refs = (refsStr as string) .split(",") .map((s: string) => parseInt(s.trim(), 10)); const tokens = refs.flatMap((ref: number) => { const ann = annotations.find((a) => a.ref === ref); if (!ann) return []; const idx = citationsList.length; citationsList.push(ann); return [`\`§${idx}§\`\u200B`]; }); return tokens.length > 0 ? tokens.join("") : full; }); } // --------------------------------------------------------------------------- // Markdown renderer (shared config) // --------------------------------------------------------------------------- function internalCaseHref( value: string | number | null | undefined, ): string | null { if (typeof value === "number") return `us-case-${value}`; if (!value) return null; const match = value.match(/^us-case-(\d+)$/); return match ? `us-case-${match[1]}` : null; } function MarkdownContent({ text, citationsList, caseCitations, caseOpinions, onCitationClick, onCaseClick, divRef, }: { text: string; citationsList: CitationAnnotation[]; caseCitations: Map< string, Extract >; caseOpinions: Map< number, Extract["case"] >; onCitationClick?: (c: CitationAnnotation) => void; onCaseClick?: ( c: Extract, ) => void; divRef?: React.RefObject; }) { function findCaseCitation(href: string) { return caseCitations.get(internalCaseHref(href) ?? ""); } return (
/^us-case-\d+$/.test(url) ? url : defaultUrlTransform(url) } components={{ table: ({ node, ...props }) => (
), thead: ({ node, ...props }) => ( ), tbody: ({ node, ...props }) => ( ), tr: ({ node, ...props }) => , th: ({ node, ...props }) => (
), td: ({ node, ...props }) => ( ), h1: ({ node, ...props }) => (

), h2: ({ node, ...props }) => (

), h3: ({ node, ...props }) => (

), h4: ({ node, ...props }) => (

), p: ({ node, ...props }) => { const parent = (node as any)?.parent; if (parent?.type === "listItem") { return (

); } return

; }, ul: ({ node, ...props }) => (

    ), ol: ({ node, ...props }) => (
      ), li: ({ node, ...props }) => (
    1. ), strong: ({ node, ...props }) => ( ), em: ({ node, ...props }) => ( ), code: ({ node, children, ...props }) => { const text = String(children); const citMatch = text.match(/^§(\d+)§$/); if (citMatch) { const idx = parseInt(citMatch[1]); const annotation = citationsList[idx]; if (annotation) { const tooltipText = `${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`; return ( ); } } return ( {children} ); }, blockquote: ({ node, ...props }) => (
      ), a: ({ node, href, children, ...props }) => { if (href) { const isInternalCaseHref = !!internalCaseHref(href); const citation = findCaseCitation(href); if (citation && onCaseClick) { return ( ); } if (citation) { return ( {children} ); } if (isInternalCaseHref) { return ( {children} ); } return ( {children} ); } return ( {children} ); }, hr: ({ node, ...props }) => (
      ), }} > {text} ); } // --------------------------------------------------------------------------- // Citations block // --------------------------------------------------------------------------- type CitationSourceRow = { key: string; label: string; source: CitationAnnotation; entries: { annotation: CitationAnnotation; index: number }[]; }; function citationSourceKey(annotation: CitationAnnotation): string { if (annotation.kind === "case") { return `case:${annotation.cluster_id}`; } return `document:${annotation.document_id}`; } function citationSourceLabel(annotation: CitationAnnotation): string { if (annotation.kind === "case") { const caseName = annotation.case_name?.trim(); const citation = annotation.citation?.trim(); if (caseName && citation) return `${caseName}, ${citation}`; return caseName || citation || `Case ${annotation.cluster_id}`; } return annotation.filename; } function documentExtension(filename: string): string { return filename.split(".").pop()?.toLowerCase() ?? ""; } function CitationSourceIcon({ annotation, }: { annotation: CitationAnnotation; }) { if (annotation.kind === "case") { return ; } const ext = documentExtension(annotation.filename); if (ext === "pdf") return ; return ; } function buildCitationSourceRows( citations: CitationAnnotation[], ): CitationSourceRow[] { const rows = new Map(); citations.forEach((annotation, index) => { const key = citationSourceKey(annotation); const existing = rows.get(key); if (existing) { existing.entries.push({ annotation, index }); return; } rows.set(key, { key, label: citationSourceLabel(annotation), source: annotation, entries: [{ annotation, index }], }); }); return Array.from(rows.values()); } function escapeHtmlText(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function ensureTerminalPeriod(value: string): string { return /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`; } function buildCitationAppendix(citations: CitationAnnotation[]) { if (citations.length === 0) return { html: "", text: "" }; let previousSourceKey: string | null = null; const entries = citations.map((annotation, index) => { const sourceKey = citationSourceKey(annotation); const label = sourceKey === previousSourceKey ? "Id." : citationSourceLabel(annotation); previousSourceKey = sourceKey; return { number: index + 1, label, quote: displayCitationQuote(annotation).trim(), }; }); const textLines = [ "", "Citations", ...entries.map((entry) => { const quote = entry.quote ? ` "${entry.quote}"` : ""; return `${entry.number} ${ensureTerminalPeriod(entry.label)}${quote}`; }), ]; const html = [ `
      `, `

      Citations

      `, ...entries.map((entry) => { const label = escapeHtmlText(ensureTerminalPeriod(entry.label)); const quote = entry.quote ? ` "${escapeHtmlText(entry.quote)}"` : ""; return `

      ${entry.number} ${label}${quote}

      `; }), `
      `, ].join(""); return { html, text: textLines.join("\n") }; } function CitationsBlock({ citationsList, onCitationClick, onOpenSource, canOpenSource, showWhenEmpty = false, isLoading = false, }: { citationsList: CitationAnnotation[]; onCitationClick?: (citation: CitationAnnotation) => void; onOpenSource?: (citation: CitationAnnotation) => void; canOpenSource?: (citation: CitationAnnotation) => boolean; showWhenEmpty?: boolean; isLoading?: boolean; }) { const rows = buildCitationSourceRows(citationsList); if (rows.length === 0 && !showWhenEmpty) return null; return (

      Citations

      {isLoading && ( )}
      {rows.map((row) => { const sourceIsClickable = !!onOpenSource && (canOpenSource?.(row.source) ?? true); return (
      {row.entries.map( ({ annotation, index }) => ( ), )}
      ); })}
      ); } // --------------------------------------------------------------------------- // Stream smoothing // --------------------------------------------------------------------------- /** * Hide jitter from arrival of streamed text chunks by revealing characters at * a smooth, rate-paced clip rather than rendering every chunk verbatim. * * Returns a prefix of `text` whose length grows over time toward the full * length. When `active` is false (stream ended, message replayed from * history, etc.), snaps to the full text immediately. * * Rate adapts to backlog: small backlogs reveal at a 40 cps floor; large * backlogs catch up within ~0.4s, so the smoothing never lags noticeably * behind the server. */ function useSmoothedReveal(text: string, active: boolean): string { const [revealedInt, setRevealedInt] = useState(text.length); const revealedFloat = useRef(text.length); useEffect(() => { if (!active) { revealedFloat.current = text.length; setRevealedInt(text.length); return; } // Defensive clamp in case the text was edited / replaced shorter. if (revealedFloat.current > text.length) { revealedFloat.current = text.length; setRevealedInt(text.length); } let lastTick = performance.now(); let raf = 0; let cancelled = false; const step = (now: number) => { if (cancelled) return; const dt = Math.max(0, (now - lastTick) / 1000); lastTick = now; const target = text.length; const prev = revealedFloat.current; if (prev < target) { const backlog = target - prev; const cps = Math.max(40, backlog / 0.4); const next = Math.min(target, prev + cps * dt); revealedFloat.current = next; const nextInt = Math.floor(next); setRevealedInt((cur) => (cur === nextInt ? cur : nextInt)); } raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => { cancelled = true; cancelAnimationFrame(raf); }; }, [text.length, active]); return text.slice(0, Math.min(revealedInt, text.length)); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- interface Props { content: string; events?: AssistantEvent[]; isStreaming?: boolean; isError?: boolean; /** Human-readable error text rendered alongside the red Mike icon. */ errorMessage?: string; annotations?: CitationAnnotation[]; citationStatus?: "started" | "partial" | "final"; onCitationClick?: (citation: CitationAnnotation) => void; onOpenCitationSource?: (citation: CitationAnnotation) => void; onCaseClick?: ( citation: Extract, ) => void; minHeight?: string; onWorkflowClick?: (workflowId: string) => void; onEditViewClick?: (ann: EditAnnotation, filename: string) => void; /** * Opens the editor panel for a document without auto-highlighting any * specific edit. Used by the download card click — opening a doc to * read/download shouldn't jump the viewer to the first edit. */ onOpenDocument?: (args: { documentId: string; filename: string; versionId: string | null; versionNumber: number | null; }) => void; /** * Fires immediately when the user clicks Accept / Reject (single card * or the bulk "Accept all" / "Reject all"), before the backend call. * Parents use this to flip download cards / editor viewers into a * "saving" state for the duration of the round-trip. */ onEditResolveStart?: (args: { editId: string; documentId: string; verb: "accept" | "reject"; }) => void; onEditResolved?: (args: { editId: string; documentId: string; status: "accepted" | "rejected"; versionId: string | null; downloadUrl: string | null; }) => void; onEditError?: (args: { editId: string; documentId: string; versionId: string | null; message: string; }) => void; isDocReloading?: (documentId: string) => boolean; /** * True while an accept/reject request for this specific edit is in * flight. Used to disable just that edit's Accept/Reject controls * (sibling edits on the same doc stay clickable). */ isEditReloading?: (editId: string) => boolean; /** * External override for individual edit statuses. When present, an * EditCard looks up its edit_id here and treats the mapped value * ("accepted" / "rejected") as authoritative — used so bulk-resolved * edits flip their per-card UI without per-card clicks. */ resolvedEditStatuses?: Record; } export function AssistantMessage({ content: _content, events, isStreaming = false, isError = false, errorMessage, annotations = [], citationStatus, onCitationClick, onOpenCitationSource, onCaseClick, minHeight = "0px", onWorkflowClick, onEditViewClick, onOpenDocument, onEditResolveStart, onEditResolved, onEditError, isDocReloading, isEditReloading, resolvedEditStatuses, }: Props) { const messageKey = useId(); const contentDivRef = useRef(null); const [isCopied, setIsCopied] = useState(false); // Per-document override of the download URL, set as Accept/Reject resolves // each tracked change and produces a new version. const [resolvedOverrides, setResolvedOverrides] = useState< Record >({}); const handleEditResolved = (args: { editId: string; documentId: string; status: "accepted" | "rejected"; versionId: string | null; downloadUrl: string | null; }) => { if (args.downloadUrl) { setResolvedOverrides((prev) => ({ ...prev, [args.documentId]: args.downloadUrl as string, })); } onEditResolved?.(args); }; const eventErrorMessages = (events ?? []) .map(eventErrorMessage) .filter((message): message is string => !!message); const topLevelErrorMessage = errorMessage ?? ( (events ?? []).find((event) => event.type === "error") as | Extract | undefined )?.message ?? null; const effectiveErrorMessage = topLevelErrorMessage ?? eventErrorMessages[0] ?? null; const hasError = isError || !!effectiveErrorMessage; const status: StatusState = hasError ? "error" : isStreaming ? "active" : null; const isRenderableEvent = (event: AssistantEvent) => event.type !== "error" && event.type !== "case_citation" && event.type !== "case_opinions"; // Find the last content event so its raw text can be smoothed before // citation preprocessing — slicing already-preprocessed text would risk // chopping a `§N§` citation token in half. const lastContentIdx = events ? events.reduce( (last, e, idx) => (e.type === "content" ? idx : last), -1, ) : -1; const lastContentEvent = events && lastContentIdx >= 0 ? (events[lastContentIdx] as Extract< AssistantEvent, { type: "content" } >) : null; // Only smooth while the content event is still the visible tail. The // moment the model emits a follow-up (tool call, reasoning, another // content block), that content's text is frozen on the server — keeping // it half-revealed below would make a tool-call wrapper appear under // prose that still looks like it's typing. const lastRenderableIdx = events ? events.reduce( (last, e, idx) => (isRenderableEvent(e) ? idx : last), -1, ) : -1; const contentIsTail = lastContentEvent !== null && lastContentIdx === lastRenderableIdx; const smoothedLastText = useSmoothedReveal( lastContentEvent?.text ?? "", isStreaming && contentIsTail, ); // Pre-process citations for all content events. Each [N] marker resolves // to exactly one annotation (models are instructed to use shared refs // only for cross-page continuations via the [[PAGE_BREAK]] sentinel). const citationsList: CitationAnnotation[] = []; const caseCitations = new Map< string, Extract >(); const caseOpinions = new Map< number, Extract["case"] >(); const processedTexts: string[] = []; if (events) { for (let i = 0; i < events.length; i++) { const event = events[i]; if (event.type === "case_citation") { const hrefKey = internalCaseHref(event.cluster_id); if (hrefKey) caseCitations.set(hrefKey, event); } else if (event.type === "case_opinions") { caseOpinions.set(event.cluster_id, event.case); } processedTexts.push( event.type === "content" ? preprocessCitations( i === lastContentIdx ? smoothedLastText : event.text, annotations, citationsList, ) : "", ); } } const handleOpenCitationSource = (citation: CitationAnnotation) => { if (onOpenCitationSource) { onOpenCitationSource(citation); return; } if (citation.kind === "case" || !onOpenDocument) return; onOpenDocument({ documentId: citation.document_id, filename: citation.filename, versionId: citation.version_id ?? null, versionNumber: citation.version_number ?? null, }); }; const canOpenCitationSource = (citation: CitationAnnotation) => !!onOpenCitationSource || (citation.kind !== "case" && !!onOpenDocument); const citationBlockList = citationStatus ? annotations : citationsList; const showCitationBlock = !!citationStatus || (!isStreaming && citationsList.length > 0); const handleCopy = async () => { try { let html = ""; let plainText = ""; if (contentDivRef.current) { const clone = contentDivRef.current.cloneNode( true, ) as HTMLElement; clone.querySelectorAll("[data-citation-ref]").forEach((el) => { const ref = el.getAttribute("data-citation-ref"); if (!ref) return; const sup = document.createElement("sup"); sup.textContent = ref; el.replaceWith(sup); }); html = clone.innerHTML; plainText = clone.textContent || ""; } const appendix = buildCitationAppendix(citationBlockList); html += appendix.html; plainText += appendix.text; const item = new ClipboardItem({ "text/html": new Blob([html], { type: "text/html" }), "text/plain": new Blob([plainText], { type: "text/plain" }), }); await navigator.clipboard.write([item]); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); } catch { // ignore } }; // Walk events in chronological order and group consecutive non-content // events into their own PreResponseWrapper. Content events render // between wrappers, so reasoning/tool chatter that arrives after the // model has already streamed some prose gets its own wrapper. type EventGroup = | { kind: "pre"; events: AssistantEvent[]; indices: number[] } | { kind: "content"; event: Extract; index: number; }; const groups: EventGroup[] = []; if (events) { let current: Extract | null = null; events.forEach((e, i) => { if (!isRenderableEvent(e)) return; if (e.type === "content") { if (current) { groups.push(current); current = null; } groups.push({ kind: "content", event: e, index: i }); } else { if (!current) current = { kind: "pre", events: [], indices: [] }; current.events.push(e); current.indices.push(i); } }); if (current) groups.push(current); } const hasContentAfter = (groupIdx: number): boolean => { for (let i = groupIdx + 1; i < groups.length; i++) { const g = groups[i]; if (g.kind === "content" && g.event.text.length > 0) return true; } return false; }; const renderEvent = ( event: AssistantEvent, i: number, allEvents: AssistantEvent[], globalIdx: number, ) => { const nextEvent = allEvents[i + 1]; const showConnector = nextEvent !== undefined && nextEvent.type !== "content"; if (event.type === "content") { const isLastContent = globalIdx === lastContentIdx; const processed = processedTexts[globalIdx]; return (
      ); } if (event.type === "reasoning") { return ( ); } if (event.type === "tool_call_start") { return (
      {showConnector && (
      )}
      {toolCallLabel(event.name)}
      ); } if (event.type === "thinking") { return (
      {showConnector && (
      )}
      Thinking...
      ); } if (event.type === "doc_read") { const ann = annotations.find( (a) => a.kind !== "case" && a.filename === event.filename, ); return ( onCitationClick(ann) : undefined } showConnector={showConnector} /> ); } if (event.type === "doc_find") { return ( ); } if (event.type === "doc_created") { return ( ); } if (event.type === "doc_replicated") { // The backend now does N copies in one tool call and reports // count + copies on a single event, so no consecutive-event // aggregation needed. return ( ); } if (event.type === "doc_edited") { return ( ); } if (event.type === "workflow_applied") { return ( onWorkflowClick(event.workflow_id) : undefined } /> ); } if (event.type === "courtlistener_search_case_law") { const count = event.result_count ?? 0; const detail = event.isStreaming ? event.query ? `for "${event.query}"` : undefined : event.error ? event.error : `${count} ${count === 1 ? "result" : "results"}${event.query ? ` for "${event.query}"` : ""}`; return ( ); } if (event.type === "courtlistener_get_cases") { const caseCount = event.case_count ?? event.cluster_ids.length; const displayLabel = `${caseCount} ${ caseCount === 1 ? "case" : "cases" }`; const detail = event.error ? event.error : undefined; const items: CourtListenerBlockItem[] = event.cases?.map((caseItem) => ({ caseName: caseItem.case_name, citation: caseItem.citation, url: caseItem.url ?? null, })) ?? event.cluster_ids.map((clusterId) => { const citation = caseCitations.get(`us-case-${clusterId}`); return { caseName: citation?.case_name ?? null, citation: citation?.citation ?? `Cluster ${clusterId}`, url: citation?.url ?? null, }; }); return ( 0 ? items : undefined} /> ); } if (event.type === "courtlistener_find_in_case") { const searches = event.searches ?? []; if (searches.length > 0) { const matches = event.total_matches ?? searches.reduce( (sum, search) => sum + (search.total_matches ?? 0), 0, ); const caseIds = new Set( searches.map( (search) => search.cluster_id ?? `${search.case_name ?? ""}|${search.citation ?? ""}`, ), ); const caseCount = caseIds.size || searches.length; const searchLabel = `${searches.length} ${ searches.length === 1 ? "search" : "searches" } in ${caseCount} ${caseCount === 1 ? "case" : "cases"}`; const detail = event.isStreaming ? undefined : event.error ? event.error : `(${matches} ${matches === 1 ? "match" : "matches"})`; const items: CourtListenerBlockItem[] = searches.map( (search) => ({ caseName: search.case_name ?? null, citation: search.citation ?? (search.cluster_id ? `Cluster ${search.cluster_id}` : null), url: null, query: search.query, totalMatches: search.total_matches ?? 0, hasError: !!search.error, }), ); return ( 0 ? items : undefined} /> ); } const matches = event.total_matches ?? 0; const caseLabel = [event.case_name, event.citation].filter(Boolean).join(", ") || (event.cluster_id ? `cluster ${event.cluster_id}` : "case"); const detail = event.isStreaming ? event.query ? `for "${event.query}" in ${caseLabel}` : caseLabel : event.error ? event.error : `${matches} ${matches === 1 ? "match" : "matches"}${event.query ? ` for "${event.query}"` : ""} in ${caseLabel}`; return ( ); } if (event.type === "courtlistener_read_case") { const count = event.opinion_count ?? 0; const caseLabel = [event.case_name, event.citation].filter(Boolean).join(", ") || "case"; const detail = event.isStreaming ? undefined : event.error ? event.error : count > 0 ? `(${count} ${count === 1 ? "opinion" : "opinions"})` : undefined; return ( ); } if (event.type === "courtlistener_verify_citations") { const citations = event.citation_count ?? 0; const matches = event.match_count ?? 0; const citationLabel = `${citations} ${citations === 1 ? "citation" : "citations"}`; const detail = event.isStreaming ? undefined : event.error ? event.error : `(${matches} ${matches === 1 ? "match" : "matches"})`; // Adjacent `case_citation` events are emitted between the start // and final verify_citations events (one per matched citation) — // collect them so the user can expand to see resolved cases. const items: CourtListenerBlockItem[] = []; if (events) { for (let j = globalIdx + 1; j < events.length; j++) { const e = events[j]; if (e.type !== "case_citation") break; items.push({ caseName: e.case_name, citation: e.citation, url: e.url || null, }); } } return ( 0 ? items : undefined} /> ); } return null; }; return (
      {events && events.length > 0 ? (
      {groups.map((g, gIdx) => { if (g.kind === "content") { const isLastContent = g.index === lastContentIdx; return (
      ); } const subsequentContent = hasContentAfter(gIdx); const wrapperIsStreaming = g.events.some( (event) => "isStreaming" in event && !!event.isStreaming, ); return ( {g.events.map((event, i) => renderEvent( event, i, g.events, g.indices[i], ), )} ); })} {/* Bulk accept/reject + per-edit cards — below the response content, only after streaming stops, rendered above the download card. */} {!isStreaming && (() => { const editedEvents = events.filter( (e) => e.type === "doc_edited" && !e.isStreaming, ) as Extract< AssistantEvent, { type: "doc_edited" } >[]; const pending: { annotation: EditAnnotation; filename: string; }[] = []; const filenameByDocId = new Map< string, string >(); // Effective status = external override if any, else the annotation's DB status. const statusOf = (ann: EditAnnotation) => resolvedEditStatuses?.[ann.edit_id] ?? ann.status; for (const e of editedEvents) { filenameByDocId.set( e.document_id, e.filename, ); for (const ann of e.annotations) { if (statusOf(ann) === "pending") { pending.push({ annotation: ann, filename: e.filename, }); } } } const cards = editedEvents.flatMap((e) => e.annotations.map((ann) => ( onEditViewClick?.(a, e.filename) } onResolveStart={onEditResolveStart} onResolved={handleEditResolved} onError={onEditError} /> )), ); const resolvedCount = editedEvents.reduce( (acc, e) => acc + e.annotations.filter( (a) => statusOf(a) !== "pending", ).length, 0, ); // If there's only one edit total, skip the // minimisable wrapper / bulk-actions UI and // render the bare EditCard — no value in // bulk controls for a single item. if (cards.length <= 1) { return cards; } return ( ); })()}
      ) : null} {topLevelErrorMessage && (

      {topLevelErrorMessage}

      )} {/* Download card for each edited doc — only after streaming stops, and deduped per document (keep the latest edit). */} {events && !isStreaming && (() => { const edited = events.filter( ( e, ): e is Extract< AssistantEvent, { type: "doc_edited" } > => e.type === "doc_edited" && !e.isStreaming && !!e.download_url, ); const latestByDoc = new Map< string, (typeof edited)[number] >(); for (const e of edited) latestByDoc.set(e.document_id, e); return Array.from(latestByDoc.values()).map((e) => (
      onOpenDocument({ documentId: e.document_id, filename: e.filename, versionId: e.version_id ?? null, versionNumber: e.version_number ?? null, }) : onEditViewClick && e.annotations[0] ? () => onEditViewClick( e.annotations[0], e.filename, ) : undefined } isReloading={ isDocReloading?.(e.document_id) ?? false } />
      )); })()} {/* Download cards for created docs — generated docs now persist as first-class documents, so clicking opens them in the DocPanel (like edited docs). */} {events && !isStreaming && events.some( (e) => e.type === "doc_created" && e.download_url, ) && (
      {( events.filter( (e) => e.type === "doc_created" && e.download_url, ) as Extract< AssistantEvent, { type: "doc_created" } >[] ).map((e, i) => { const documentId = e.document_id; const versionId = e.version_id ?? null; const versionNumber = e.version_number ?? null; const canOpen = !!onOpenDocument && !!documentId; return ( onOpenDocument!({ documentId: documentId!, filename: e.filename, versionId, versionNumber, }) : undefined } /> ); })}
      )} {showCitationBlock && ( )} {/* Copy button */}
      {!isStreaming && ( )}
      ); }