"use client"; import { useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; import type { AssistantEvent, Citation, EditAnnotation } from "../shared/types"; import { EditCard } from "./EditCard"; import { PreResponseWrapper } from "./PreResponseWrapper"; import { ResponseStatus, type StatusState } from "./message/ResponseStatus"; import { eventErrorMessage, toolCallLabel } from "./message/eventUtils"; import { preprocessCitations, internalCaseHref } from "./message/citationUtils"; import { useSmoothedReveal } from "./message/useSmoothedReveal"; import { MarkdownContent } from "./message/MarkdownContent"; import { CitationsBlock, buildCitationAppendix } from "./message/CitationSources"; import { EditCardsSection } from "./message/EditCardsSection"; import { AskInputsBlock, CourtListenerBlock, DocCreatedBlock, DocDownloadBlock, DocEditedBlock, DocFindBlock, DocReadBlock, DocReplicatedBlock, EventBlock, ReasoningBlock, WorkflowAppliedBlock, type CourtListenerBlockItem, } from "./message/EventBlocks"; interface Props { events?: AssistantEvent[]; isStreaming?: boolean; isError?: boolean; /** Human-readable error text rendered alongside the red Mike icon. */ errorMessage?: string; citations?: Citation[]; citationStatus?: "started" | "partial" | "final"; onCitationClick?: (citation: Citation) => void; onOpenCitationSource?: (citation: Citation) => void; onCaseClick?: ( citation: Extract, ) => void; minHeight?: string; onWorkflowClick?: (workflowId: string) => void; onEditViewClick?: ( ann: EditAnnotation, filename: string, changeNumber?: number, ) => 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({ events, isStreaming = false, isError = false, errorMessage, citations = [], citationStatus, onCitationClick, onOpenCitationSource, onCaseClick, minHeight = "0px", onWorkflowClick, onEditViewClick, onOpenDocument, onEditResolveStart, onEditResolved, onEditError, isDocReloading, isEditReloading, resolvedEditStatuses, }: Props) { 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 !== "ask_inputs_response" && 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 citation (models are instructed to use shared refs // only for cross-page continuations via the [[PAGE_BREAK]] sentinel). const inlineCitationTargets: Citation[] = []; 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, citations, inlineCitationTargets, ) : "", ); } } const handleOpenCitationSource = (citation: Citation) => { 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: Citation) => !!onOpenCitationSource || (citation.kind !== "case" && !!onOpenDocument); const showCitationBlock = !!citationStatus || (!isStreaming && citations.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(citations); 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 askInputsResponseFor = (askInputsIdx: number) => { if (!events) return undefined; for (let i = askInputsIdx + 1; i < events.length; i++) { const candidate = events[i]; if (candidate.type === "ask_inputs") return undefined; if (candidate.type === "ask_inputs_response") return candidate; } return undefined; }; const hasPendingAskInput = (group: Extract) => group.events.some( (event, index) => event.type === "ask_inputs" && !askInputsResponseFor(group.indices[index]), ); 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 === "reasoning") { return ( ); } if (event.type === "tool_call_start") { return ( {toolCallLabel(event.name)} ); } if (event.type === "thinking") { return ( Thinking... ); } if (event.type === "mcp_tool_call") { const isError = event.status === "error"; const label = event.connector_name ? `${event.connector_name}: ${event.tool_name}` : toolCallLabel(event.openai_tool_name); return ( {event.isStreaming ? "Using connector..." : label} {isError && event.error && (

{event.error}

)}
); } if (event.type === "doc_read") { const ann = citations.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 === "ask_inputs") { const response = askInputsResponseFor(globalIdx); return ( ); } 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 pendingAskInput = hasPendingAskInput(g); const wrapperIsStreaming = g.events.some( (event) => "isStreaming" in event && !!event.isStreaming, ) || pendingAskInput; 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, }); } } } let cardIndex = 0; const cards = editedEvents.flatMap((e) => e.annotations.map((ann) => { const changeNumber = ++cardIndex; return ( onEditViewClick?.( a, e.filename, changeNumber, ) } 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 && ( )}
); }