"use client"; import { useId, useRef, useEffect, useState } from "react"; import ReactMarkdown 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, Loader2 } from "lucide-react"; import { MikeIcon } from "@/components/chat/mike-icon"; import { displayCitationQuote, formatCitationPage } from "../shared/types"; import type { AssistantEvent, MikeCitationAnnotation, MikeEditAnnotation, } from "../shared/types"; import { EditCard, applyOptimisticResolution } from "./EditCard"; import { PreResponseWrapper } from "../shared/PreResponseWrapper"; import { supabase } from "@/lib/supabase"; /** * 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: MikeEditAnnotation; filename: string; }[]; filenameByDocId: Map; onViewClick?: (ann: MikeEditAnnotation, 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: MikeEditAnnotation; filename: string; }[]; filenameByDocId: Map; cards: React.ReactNode[]; resolvedCount: number; onViewClick?: (ann: MikeEditAnnotation, 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 (
); } // --------------------------------------------------------------------------- // Event block components // --------------------------------------------------------------------------- const THINKING_PHRASES = [ "Thinking...", "Pondering...", "Analyzing...", "Reviewing...", "Reasoning...", ]; function ReasoningBlock({ text, isStreaming, showConnector, }: { text: string; isStreaming: boolean; showConnector?: boolean; }) { const [isOpen, setIsOpen] = useState(false); const [thinkingIndex, setThinkingIndex] = useState(0); useEffect(() => { if (!isStreaming) return; const interval = setInterval(() => { setThinkingIndex((i) => (i + 1) % THINKING_PHRASES.length); }, 2000); return () => clearInterval(interval); }, [isStreaming]); const showContent = isOpen || isStreaming; return (
{showConnector && (
)} {showContent && (
( ), }} > {text}
)}
); } 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} )}
); } 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: MikeCitationAnnotation[], citationsList: MikeCitationAnnotation[], ): string { // Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by annotations return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr) => { 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 MarkdownContent({ text, citationsList, onCitationClick, divRef, }: { text: string; citationsList: MikeCitationAnnotation[]; onCitationClick?: (c: MikeCitationAnnotation) => void; divRef?: React.RefObject; }) { return (
(
), 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 }) => ( {children} ), hr: ({ node, ...props }) => (
      ), }} > {text} ); } // --------------------------------------------------------------------------- // 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?: MikeCitationAnnotation[]; onCitationClick?: (citation: MikeCitationAnnotation) => void; minHeight?: string; onWorkflowClick?: (workflowId: string) => void; onEditViewClick?: (ann: MikeEditAnnotation, 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 = [], onCitationClick, 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; }) => { console.log("[AssistantMessage] handleEditResolved", args); if (args.downloadUrl) { setResolvedOverrides((prev) => ({ ...prev, [args.documentId]: args.downloadUrl as string, })); } onEditResolved?.(args); }; const status: StatusState = isError ? "error" : isStreaming ? "active" : null; // 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: MikeCitationAnnotation[] = []; const processedTexts: string[] = []; if (events) { for (const event of events) { processedTexts.push( event.type === "content" ? preprocessCitations( event.text, annotations, citationsList, ) : "", ); } } const handleCopy = async () => { try { let html = ""; let plainText = ""; if (contentDivRef.current) { const clone = contentDivRef.current.cloneNode( true, ) as HTMLElement; html = clone.innerHTML; plainText = clone.textContent || ""; } 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 } }; const lastContentIdx = events ? events.reduce( (last, e, idx) => (e.type === "content" ? idx : last), -1, ) : -1; // 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 (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 && (
      )}
      Running {event.name ? `${event.name}...` : "tool..."}
      ); } if (event.type === "thinking") { return (
      {showConnector && (
      )}
      Thinking...
      ); } if (event.type === "doc_read") { const ann = annotations.find((a) => 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 } /> ); } 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: MikeEditAnnotation; filename: string; }[] = []; const filenameByDocId = new Map< string, string >(); // Effective status = external override if any, else the annotation's DB status. const statusOf = (ann: MikeEditAnnotation) => 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} {isError && (
      {errorMessage ?? "Sorry, something went wrong."}
      )} {/* 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 } /> ); })}
      )} {/* Copy button */}
      {!isStreaming && ( )}
      ); }