"use client"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import { createPortal } from "react-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { MoreHorizontal, Pencil, Plus, Search, Square, ArrowRight, ChevronDown, Trash2, X, } from "lucide-react"; import { MikeIcon } from "@/app/components/chat/mike-icon"; import { streamTabularChat, getTabularChats, getTabularChatMessages, deleteTabularChat, renameTabularChat, 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 { DocReadBlock, EventBlock, ReasoningBlock, } from "../assistant/message/EventBlocks"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { getModelProvider, isModelAvailable, type ModelProvider, } from "@/app/lib/modelAvailability"; import type { ApiKeyState } from "@/app/lib/mikeApi"; import { APP_SURFACE_ACTIVE_CLASS, APP_SURFACE_HOVER_CLASS, LIQUID_PANEL_SURFACE_CLASS, } from "@/app/components/ui/liquid-surface"; import { LiquidDropdownButton, LiquidDropdownSurface, } from "@/app/components/ui/liquid-dropdown"; import { cn } from "@/app/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; } // --------------------------------------------------------------------------- // 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) { // eslint-disable-next-line react-hooks/set-state-in-effect -- timed 'Done' flash on the active->idle transition 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, index: number, allEvents: AssistantEvent[], key: number, ) => { const nextEvent = allEvents[index + 1]; const showConnector = nextEvent !== undefined && nextEvent.type !== "content"; 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, i, g.events, 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 (