"use client"; import { useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Clock, MessageSquarePlus, Search, Square, ArrowRight, ChevronDown, ChevronLeft, Trash2, } from "lucide-react"; import { MikeIcon } from "@/components/chat/mike-icon"; import { streamTabularChat, getTabularChats, getTabularChatMessages, deleteTabularChat, mapTRMessages, type TRChat, type TRCitationAnnotation, } from "@/app/lib/mikeApi"; import type { AssistantEvent, ColumnConfig, Document } from "../shared/types"; import { ModelToggle } from "../assistant/ModelToggle"; import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup"; import { PreResponseWrapper } from "../assistant/PreResponseWrapper"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { getModelProvider, isModelAvailable, type ModelProvider, } from "@/app/lib/modelAvailability"; import type { ApiKeyState } from "@/app/lib/mikeApi"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface TRMessage { role: "user" | "assistant"; content: string; events?: AssistantEvent[]; annotations?: TRCitationAnnotation[]; isStreaming?: boolean; } function parseCourtlistenerEventCases(value: unknown) { if (!Array.isArray(value)) return undefined; return value .map((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) { return null; } const row = item as Record; return { cluster_id: typeof row.cluster_id === "number" ? row.cluster_id : 0, case_name: typeof row.case_name === "string" ? row.case_name : null, citation: typeof row.citation === "string" ? row.citation : null, dateFiled: typeof row.dateFiled === "string" ? row.dateFiled : null, url: typeof row.url === "string" ? row.url : null, }; }) .filter( (item): item is NonNullable => !!item && item.cluster_id > 0, ); } function parseCourtlistenerCaseSearches(value: unknown) { if (!Array.isArray(value)) return undefined; return value .map((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) { return null; } const row = item as Record; return { cluster_id: typeof row.cluster_id === "number" ? row.cluster_id : null, query: typeof row.query === "string" ? row.query : "", total_matches: typeof row.total_matches === "number" ? row.total_matches : 0, case_name: typeof row.case_name === "string" ? row.case_name : null, citation: typeof row.citation === "string" ? row.citation : null, error: typeof row.error === "string" ? row.error : undefined, }; }) .filter((item): item is NonNullable => !!item); } interface Props { reviewId: string; reviewTitle?: string | null; projectName?: string | null; columns: ColumnConfig[]; documents: Document[]; onCitationClick: (colIdx: number, rowIdx: number) => void; onClose: () => void; initialChatId?: string | null; onChatIdChange?: (chatId: string | null) => void; } // --------------------------------------------------------------------------- // Reasoning block // --------------------------------------------------------------------------- const THINKING_PHRASES = [ "Thinking...", "Pondering...", "Analyzing...", "Reasoning...", ]; const REASONING_COLLAPSED_MAX_LINES = 6; const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9; function ReasoningBlock({ text, isStreaming, }: { text: string; isStreaming: boolean; }) { const [isOpen, setIsOpen] = useState(false); const [userToggled, setUserToggled] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false); const [hasMeasured, setHasMeasured] = useState(false); const [phraseIdx, setPhraseIdx] = useState(0); const contentRef = useRef(null); useEffect(() => { if (!isStreaming) return; const interval = setInterval( () => setPhraseIdx((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 (nextOverflowing && !userToggled) setIsOpen(false); }, [text, userToggled]); const showContent = isOpen || isStreaming || isOverflowing || !hasMeasured; const isCollapsed = isOverflowing && !isOpen; return (
{showContent && (
{text}
{isCollapsed && ( <>
)}
{isOverflowing && isOpen && ( )}
)}
); } // --------------------------------------------------------------------------- // DocRead block // --------------------------------------------------------------------------- function DocReadBlock({ label, isStreaming, }: { label: string; isStreaming?: boolean; }) { return (
{isStreaming ? (
) : (
)} {isStreaming ? "Reading" : "Read"} {label}
); } // --------------------------------------------------------------------------- // Citation preprocessing (matches AssistantMessage.tsx pattern) // --------------------------------------------------------------------------- function preprocessTRCitations( text: string, annotations: TRCitationAnnotation[], citationsList: TRCitationAnnotation[], ): string { 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; }); } // --------------------------------------------------------------------------- // ResponseStatus // --------------------------------------------------------------------------- function TRResponseStatus({ isActive }: { isActive: boolean }) { const [showDone, setShowDone] = useState(false); const [doneVisible, setDoneVisible] = useState(false); const wasActiveRef = useRef(false); useEffect(() => { if (wasActiveRef.current && !isActive) { setShowDone(true); setDoneVisible(true); const t = setTimeout(() => setDoneVisible(false), 1500); wasActiveRef.current = isActive; return () => clearTimeout(t); } if (!wasActiveRef.current && isActive) { setShowDone(false); setDoneVisible(false); } wasActiveRef.current = isActive; }, [isActive]); return (
); } // --------------------------------------------------------------------------- // TRAssistantMessage // --------------------------------------------------------------------------- type TREventGroup = | { kind: "pre"; events: AssistantEvent[]; indices: number[] } | { kind: "content"; event: Extract; index: number; }; function TRAssistantMessage({ msg, onCitationClick, }: { msg: TRMessage; onCitationClick: (colIdx: number, rowIdx: number) => void; }) { const annotations = msg.annotations ?? []; const citationsList: TRCitationAnnotation[] = []; // Pre-process all content events const processedTexts: string[] = (msg.events ?? []).map((e) => e.type === "content" ? preprocessTRCitations(e.text, annotations, citationsList) : "", ); const events = msg.events ?? []; // Group consecutive non-content events together so they share a single // PreResponseWrapper. Content events render between wrappers. const groups: TREventGroup[] = []; { 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") return true; } return false; }; const renderPreEvent = (event: AssistantEvent, key: number) => { if (event.type === "reasoning") { return ( ); } if (event.type === "doc_read") { return ( ); } if (event.type === "thinking") { return (
Thinking...
); } return null; }; const renderContent = (text: string, key: number) => (
(

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

    ), ol: ({ node, ...props }) => (
      ), li: ({ node, ...props }) => (
    1. ), strong: ({ node, ...props }) => ( ), code: ({ children }) => { const codeText = String(children); const citMatch = codeText.match(/^§(\d+)§$/); if (citMatch) { const idx = parseInt(citMatch[1]); const cit = citationsList[idx]; if (cit) { return ( ); } } return ( {children} ); }, }} > {text}
); return (
{groups.length > 0 && (
{groups.map((g, gIdx) => { if (g.kind === "content") { return renderContent( processedTexts[g.index], g.index, ); } const subsequentContent = hasContentAfter(gIdx); // "Working" while at least one event in *this* // wrapper is actively streaming. Gaps between real // events are bridged by `pushThinkingPlaceholder` // so this check stays continuously true through // the whole pre-content phase. const wrapperIsStreaming = g.events.some( (event) => "isStreaming" in event && !!event.isStreaming, ); return ( {g.events.map((event, i) => renderPreEvent(event, g.indices[i]), )} ); })}
)}
); } // --------------------------------------------------------------------------- // MessageBubble // --------------------------------------------------------------------------- function MessageBubble({ msg, onCitationClick, }: { msg: TRMessage; onCitationClick: (colIdx: number, rowIdx: number) => void; }) { if (msg.role === "user") { return (
{msg.content}
); } return ; } // --------------------------------------------------------------------------- // Input // --------------------------------------------------------------------------- function TRChatInput({ isLoading, onSubmit, onCancel, model, onModelChange, apiKeys, onHeightChange, }: { isLoading: boolean; onSubmit: (value: string) => void; onCancel: () => void; model: string; onModelChange: (id: string) => void; apiKeys?: ApiKeyState; onHeightChange: (height: number) => void; }) { const [value, setValue] = useState(""); const rootRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { const root = rootRef.current; if (!root) return; const notify = () => { onHeightChange(root.getBoundingClientRect().height); }; notify(); const observer = new ResizeObserver(notify); observer.observe(root); window.addEventListener("resize", notify); return () => { observer.disconnect(); window.removeEventListener("resize", notify); }; }, [onHeightChange]); function resizeTextarea(el: HTMLTextAreaElement) { el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 192)}px`; el.style.overflowY = el.scrollHeight > 192 ? "auto" : "hidden"; } function resetTextarea() { if (!textareaRef.current) return; textareaRef.current.style.height = "auto"; textareaRef.current.style.overflowY = "hidden"; } function handleAction() { if (isLoading) { onCancel(); return; } const trimmed = value.trim(); if (!trimmed) return; setValue(""); resetTextarea(); onSubmit(trimmed); } return (