mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Takes frontend/npm run lint from 23 errors / 40 warnings to 0 errors / 40 warnings, then removes continue-on-error from the CI lint step so it gates merges. Errors fixed outright: - @typescript-eslint/no-explicit-any (5, ChatView.tsx): dropped redundant (msg as any) casts — Message already declares files, workflow, and error with the exact shapes the props expect. - react/no-unescaped-entities (1, support/page.tsx): "We'll" -> "We'll". Targeted disables (17, all react-hooks/set-state-in-effect): every site is an intentional effect-driven state pattern — SSR/hydration mount gates (Modal, useSelectedModel), reset-on-prop/identity-change (CaseLawPanel x3, ChatView chat switch, CitationQuotesHeader, ChatHistoryContext logout, useFetchDocxBytes), sync fast paths of async fetch/check effects (CaseLawPanel, MfaLoginGate x2), DOM-measured state (ChatView scroll button, message-visibility restore), and timed UI latches (PreResponseWrapper, TRChatPanel, AskInputPopup auto-submit). Rewriting any of them would change runtime behavior, so each carries a // eslint-disable-next-line with a one-line reason instead of a fix or a repo-wide rule change. CI: lint step is now blocking; comments updated to say the backlog is at zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1931 lines
80 KiB
TypeScript
1931 lines
80 KiB
TypeScript
"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<string, unknown>;
|
|
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<typeof item> =>
|
|
!!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<string, unknown>;
|
|
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<typeof item> => !!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 (
|
|
<div className="w-full h-9 flex items-center mb-2">
|
|
<MikeIcon
|
|
spin={isActive}
|
|
done={showDone && doneVisible}
|
|
mike={!(showDone && doneVisible)}
|
|
size={22}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TRAssistantMessage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type TREventGroup =
|
|
| { kind: "pre"; events: AssistantEvent[]; indices: number[] }
|
|
| {
|
|
kind: "content";
|
|
event: Extract<AssistantEvent, { type: "content" }>;
|
|
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<TREventGroup, { kind: "pre" }> | 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 (
|
|
<ReasoningBlock
|
|
key={key}
|
|
text={event.text}
|
|
isStreaming={!!event.isStreaming && !!msg.isStreaming}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "doc_read") {
|
|
return (
|
|
<DocReadBlock
|
|
key={key}
|
|
filename={event.filename}
|
|
isStreaming={event.isStreaming}
|
|
showConnector={showConnector}
|
|
showFileIcon={false}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "thinking") {
|
|
return (
|
|
<EventBlock
|
|
key={key}
|
|
showConnector={showConnector}
|
|
isStreaming
|
|
>
|
|
<span>Thinking...</span>
|
|
</EventBlock>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderContent = (text: string, key: number) => (
|
|
<div
|
|
key={key}
|
|
className="prose prose-sm max-w-none text-sm leading-relaxed"
|
|
>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
p: ({ node, ...props }) => (
|
|
<p className="mb-2 leading-6" {...props} />
|
|
),
|
|
ul: ({ node, ...props }) => (
|
|
<ul
|
|
className="list-disc list-outside mb-2 pl-4"
|
|
{...props}
|
|
/>
|
|
),
|
|
ol: ({ node, ...props }) => (
|
|
<ol
|
|
className="list-decimal list-outside mb-2 pl-4"
|
|
{...props}
|
|
/>
|
|
),
|
|
li: ({ node, ...props }) => (
|
|
<li className="mb-0.5 leading-6" {...props} />
|
|
),
|
|
strong: ({ node, ...props }) => (
|
|
<strong className="font-semibold" {...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 (
|
|
<button
|
|
onClick={() =>
|
|
onCitationClick(
|
|
cit.col_index,
|
|
cit.row_index,
|
|
)
|
|
}
|
|
title={`${cit.col_name} · ${cit.doc_name.replace(/\.[^.]+$/, "")}`}
|
|
className="mx-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-[10px] font-medium bg-gray-100 text-gray-900 hover:bg-gray-200 transition-colors align-super font-serif"
|
|
>
|
|
{cit.ref}
|
|
</button>
|
|
);
|
|
}
|
|
}
|
|
return (
|
|
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">
|
|
{children}
|
|
</code>
|
|
);
|
|
},
|
|
}}
|
|
>
|
|
{text}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="text-gray-900 font-serif">
|
|
<TRResponseStatus isActive={!!msg.isStreaming} />
|
|
{groups.length > 0 && (
|
|
<div className="flex flex-col gap-2.5">
|
|
{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 (
|
|
<PreResponseWrapper
|
|
key={`p-${g.indices[0]}`}
|
|
stepCount={g.events.length}
|
|
shouldMinimize={subsequentContent}
|
|
isStreaming={wrapperIsStreaming}
|
|
compact
|
|
>
|
|
{g.events.map((event, i) =>
|
|
renderPreEvent(
|
|
event,
|
|
i,
|
|
g.events,
|
|
g.indices[i],
|
|
),
|
|
)}
|
|
</PreResponseWrapper>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MessageBubble
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function MessageBubble({
|
|
msg,
|
|
onCitationClick,
|
|
}: {
|
|
msg: TRMessage;
|
|
onCitationClick: (colIdx: number, rowIdx: number) => void;
|
|
}) {
|
|
if (msg.role === "user") {
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div className="max-w-[90%] rounded-md bg-gray-100 px-3 py-2 text-xs text-gray-800 whitespace-pre-wrap">
|
|
{msg.content}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return <TRAssistantMessage msg={msg} onCitationClick={onCitationClick} />;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<HTMLDivElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(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 (
|
|
<div
|
|
ref={rootRef}
|
|
className={cn(
|
|
"absolute bottom-0 left-0 right-0 z-10 px-3 pb-3",
|
|
"bg-transparent",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"pt-2 pb-1.5 flex flex-col gap-1",
|
|
"rounded-xl border border-white/65 bg-white/60 shadow-[0_4px_10px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl",
|
|
)}
|
|
>
|
|
<textarea
|
|
ref={textareaRef}
|
|
rows={1}
|
|
placeholder="How can I help?"
|
|
value={value}
|
|
onChange={(e) => {
|
|
setValue(e.target.value);
|
|
resizeTextarea(e.target);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleAction();
|
|
}
|
|
}}
|
|
className="w-full resize-none text-sm bg-transparent outline-none placeholder:text-gray-400 leading-6 max-h-48 overflow-hidden border-0 p-0 pl-3 pr-2 pt-0.5"
|
|
/>
|
|
<div className="flex items-center justify-end gap-1.5 pl-1 pr-2">
|
|
<ModelToggle
|
|
value={model}
|
|
onChange={onModelChange}
|
|
apiKeys={apiKeys}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleAction}
|
|
disabled={!isLoading && !value.trim()}
|
|
className={cn(
|
|
"relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-7 w-7 shrink-0 flex items-center justify-center disabled:cursor-default disabled:from-neutral-600 disabled:to-black border border-white/30 active:enabled:scale-95 transition-all duration-150",
|
|
"shadow-[0_5px_14px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.24)]",
|
|
)}
|
|
>
|
|
{isLoading ? (
|
|
<Square
|
|
className="h-3.5 w-3.5"
|
|
fill="currentColor"
|
|
strokeWidth={0}
|
|
/>
|
|
) : (
|
|
<ArrowRight className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// History dropdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function HistoryDropdown({
|
|
chats,
|
|
currentChatId,
|
|
onLoad,
|
|
onRename,
|
|
onDelete,
|
|
}: {
|
|
chats: TRChat[];
|
|
currentChatId: string | null;
|
|
onLoad: (chatId: string) => void;
|
|
onRename: (chatId: string, title: string) => void;
|
|
onDelete: (chatId: string) => void;
|
|
}) {
|
|
const [query, setQuery] = useState("");
|
|
const [menu, setMenu] = useState<{
|
|
chatId: string;
|
|
top: number;
|
|
left: number;
|
|
} | null>(null);
|
|
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const filtered = chats
|
|
.filter((c) => c.id !== currentChatId)
|
|
.filter((c) => {
|
|
const label = c.title ?? "";
|
|
return label.toLowerCase().includes(query.toLowerCase());
|
|
});
|
|
|
|
function commitRename(chatId: string) {
|
|
const trimmed = renameValue.trim();
|
|
setRenamingChatId(null);
|
|
if (trimmed) onRename(chatId, trimmed);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-white/40">
|
|
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
placeholder="Search chats…"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
className="flex-1 text-xs bg-transparent outline-none placeholder:text-gray-400 text-gray-700"
|
|
/>
|
|
</div>
|
|
<div
|
|
className="max-h-48 overflow-y-auto p-1"
|
|
onScroll={() => setMenu(null)}
|
|
>
|
|
{filtered.length === 0 ? (
|
|
<p className="px-2 py-1.5 text-xs text-gray-400">
|
|
{chats.filter((c) => c.id !== currentChatId).length ===
|
|
0
|
|
? "No previous chats."
|
|
: "No matches."}
|
|
</p>
|
|
) : (
|
|
filtered.map((chat) => {
|
|
const label = chat.title ?? "Chat";
|
|
if (renamingChatId === chat.id) {
|
|
return (
|
|
<input
|
|
key={chat.id}
|
|
autoFocus
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) =>
|
|
setRenameValue(e.target.value)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter")
|
|
commitRename(chat.id);
|
|
if (e.key === "Escape")
|
|
setRenamingChatId(null);
|
|
}}
|
|
onBlur={() => commitRename(chat.id)}
|
|
className={`w-full rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none ${APP_SURFACE_ACTIVE_CLASS}`}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
key={chat.id}
|
|
className="group relative flex items-center"
|
|
>
|
|
<LiquidDropdownButton
|
|
onClick={() => onLoad(chat.id)}
|
|
className="w-full min-w-0 rounded-lg px-2 py-1.5 pr-7 text-left truncate"
|
|
>
|
|
{label}
|
|
</LiquidDropdownButton>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const rect =
|
|
e.currentTarget.getBoundingClientRect();
|
|
setMenu((v) =>
|
|
v?.chatId === chat.id
|
|
? null
|
|
: {
|
|
chatId: chat.id,
|
|
top: rect.bottom + 4,
|
|
left: rect.right - 112,
|
|
},
|
|
);
|
|
}}
|
|
title="Chat options"
|
|
className={cn(
|
|
`absolute right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 transition-colors hover:text-gray-700 ${APP_SURFACE_HOVER_CLASS}`,
|
|
menu?.chatId === chat.id
|
|
? "opacity-100"
|
|
: "opacity-0 group-hover:opacity-100",
|
|
)}
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</button>
|
|
{menu?.chatId === chat.id &&
|
|
createPortal(
|
|
<LiquidDropdownSurface
|
|
onMouseDown={(e) =>
|
|
e.stopPropagation()
|
|
}
|
|
className="fixed z-[130] w-28 p-1"
|
|
style={{
|
|
top: menu.top,
|
|
left: menu.left,
|
|
}}
|
|
>
|
|
<LiquidDropdownButton
|
|
onClick={() => {
|
|
setMenu(null);
|
|
setRenameValue(
|
|
chat.title ?? "",
|
|
);
|
|
setRenamingChatId(chat.id);
|
|
}}
|
|
className="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
Rename
|
|
</LiquidDropdownButton>
|
|
<LiquidDropdownButton
|
|
onClick={() => {
|
|
setMenu(null);
|
|
onDelete(chat.id);
|
|
}}
|
|
className="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-red-600 hover:text-red-600 focus:text-red-600"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
Delete
|
|
</LiquidDropdownButton>
|
|
</LiquidDropdownSurface>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Drip helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function findLastContentIndex(events: AssistantEvent[]): number {
|
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
if (events[i].type === "content") return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Header pills (matches PageHeader action group styling)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const HEADER_PILL_CLASS =
|
|
"flex shrink-0 items-center gap-1 rounded-full border border-white/70 bg-app-surface px-1 py-0.5 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl";
|
|
const HEADER_PILL_BUTTON_CLASS = `flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:text-gray-900 ${APP_SURFACE_HOVER_CLASS}`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function TRChatPanel({
|
|
reviewId,
|
|
reviewTitle,
|
|
projectName,
|
|
columns: _columns,
|
|
documents: _documents,
|
|
onCitationClick,
|
|
onClose,
|
|
initialChatId,
|
|
onChatIdChange,
|
|
}: Props) {
|
|
const { profile, updateModelPreference } = useUserProfile();
|
|
const apiKeys = profile?.apiKeys;
|
|
const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview";
|
|
const [apiKeyModalProvider, setApiKeyModalProvider] =
|
|
useState<ModelProvider | null>(null);
|
|
const [chats, setChats] = useState<TRChat[]>([]);
|
|
const [currentChatId, setCurrentChatId] = useState<string | null>(
|
|
initialChatId ?? null,
|
|
);
|
|
const [currentChatTitle, setCurrentChatTitle] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [messages, setMessages] = useState<TRMessage[]>([]);
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
const [minHeight, setMinHeight] = useState("0px");
|
|
const [messagesVisible, setMessagesVisible] = useState(false);
|
|
const [panelWidth, setPanelWidth] = useState(380);
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const [inputHeight, setInputHeight] = useState(96);
|
|
|
|
const resizeStartRef = useRef({ x: 0, width: 380 });
|
|
|
|
useEffect(() => {
|
|
if (!isResizing) return;
|
|
const MIN_WIDTH = 280;
|
|
const MAX_WIDTH = 800;
|
|
function onMove(e: MouseEvent) {
|
|
const delta = resizeStartRef.current.x - e.clientX;
|
|
setPanelWidth(
|
|
Math.min(
|
|
MAX_WIDTH,
|
|
Math.max(MIN_WIDTH, resizeStartRef.current.width + delta),
|
|
),
|
|
);
|
|
}
|
|
function onUp() {
|
|
setIsResizing(false);
|
|
}
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
return () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
}, [isResizing]);
|
|
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
const latestUserMessageRef = useRef<HTMLDivElement>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const historyRef = useRef<HTMLDivElement>(null);
|
|
const hasScrolledRef = useRef(false);
|
|
|
|
// Drip animation refs
|
|
const dripIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const dripTargetRef = useRef<string>("");
|
|
const dripDisplayLenRef = useRef<number>(0);
|
|
const eventsRef = useRef<AssistantEvent[]>([]);
|
|
const DRIP_CHARS = 8;
|
|
|
|
// Load existing chats from DB on mount
|
|
useEffect(() => {
|
|
getTabularChats(reviewId)
|
|
.then(setChats)
|
|
.catch(() => {});
|
|
}, [reviewId]);
|
|
|
|
// Load messages for an initial chat id (e.g. from URL)
|
|
useEffect(() => {
|
|
if (!initialChatId) return;
|
|
setIsLoadingMessages(true);
|
|
getTabularChatMessages(reviewId, initialChatId)
|
|
.then((raw) => setMessages(mapTRMessages(raw) as TRMessage[]))
|
|
.catch(() => {})
|
|
.finally(() => setIsLoadingMessages(false));
|
|
}, [reviewId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Fill in title once chats list arrives
|
|
useEffect(() => {
|
|
if (currentChatId && !currentChatTitle) {
|
|
const chat = chats.find((c) => c.id === currentChatId);
|
|
if (chat) setCurrentChatTitle(chat.title ?? null);
|
|
}
|
|
}, [chats, currentChatId, currentChatTitle]);
|
|
|
|
// Emit currentChatId changes to parent
|
|
const onChatIdChangeRef = useRef(onChatIdChange);
|
|
useEffect(() => {
|
|
onChatIdChangeRef.current = onChatIdChange;
|
|
});
|
|
useEffect(() => {
|
|
onChatIdChangeRef.current?.(currentChatId);
|
|
}, [currentChatId]);
|
|
|
|
useEffect(() => {
|
|
if (messages.length === 0) {
|
|
hasScrolledRef.current = false;
|
|
setMessagesVisible(false);
|
|
} else if (!hasScrolledRef.current) {
|
|
const userMsgCount = messages.filter(
|
|
(m) => m.role === "user",
|
|
).length;
|
|
if (
|
|
userMsgCount >= 2 &&
|
|
latestUserMessageRef.current &&
|
|
messagesContainerRef.current
|
|
) {
|
|
setTimeout(() => {
|
|
const container = messagesContainerRef.current;
|
|
const element = latestUserMessageRef.current;
|
|
if (container && element) {
|
|
container.scrollTo({
|
|
top: element.offsetTop - 44,
|
|
behavior: "instant",
|
|
});
|
|
}
|
|
hasScrolledRef.current = true;
|
|
setMessagesVisible(true);
|
|
}, 100);
|
|
} else {
|
|
hasScrolledRef.current = true;
|
|
setMessagesVisible(true);
|
|
}
|
|
}
|
|
}, [messages]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
const userEl = latestUserMessageRef.current;
|
|
const containerEl = messagesContainerRef.current;
|
|
if (!userEl || !containerEl) return;
|
|
const BOTTOM_PAD = 96;
|
|
const messageContainerTopPadding = 16;
|
|
const messageGap = 16;
|
|
setMinHeight(
|
|
`${Math.max(0, containerEl.clientHeight - BOTTOM_PAD - userEl.offsetHeight - messageContainerTopPadding - messageGap)}px`,
|
|
);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [messages.length, latestUserMessageRef.current]);
|
|
|
|
useEffect(() => {
|
|
if (!historyOpen) return;
|
|
function handleClick(e: MouseEvent) {
|
|
if (
|
|
historyRef.current &&
|
|
!historyRef.current.contains(e.target as Node)
|
|
) {
|
|
setHistoryOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [historyOpen]);
|
|
|
|
// ---- drip ----
|
|
|
|
function stopDrip() {
|
|
if (dripIntervalRef.current !== null) {
|
|
clearInterval(dripIntervalRef.current);
|
|
dripIntervalRef.current = null;
|
|
}
|
|
}
|
|
|
|
function updateLastContentEvent(
|
|
prev: TRMessage[],
|
|
text: string,
|
|
isStreaming?: boolean,
|
|
): TRMessage[] {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role !== "assistant") return prev;
|
|
const evts = last.events ?? [];
|
|
const idx = findLastContentIndex(evts);
|
|
if (idx < 0) return prev;
|
|
const newEvents = [...evts];
|
|
newEvents[idx] = isStreaming
|
|
? { type: "content", text, isStreaming: true }
|
|
: { type: "content", text };
|
|
updated[updated.length - 1] = { ...last, events: newEvents };
|
|
return updated;
|
|
}
|
|
|
|
// Mirror the dripped content text onto eventsRef.current so that any
|
|
// subsequent setMessages built from a refsnapshot (pushEvent,
|
|
// updateMatchingEvent, reasoning_*, etc.) doesn't wipe out the content
|
|
// by replacing it with the stale empty placeholder.
|
|
function syncDripIntoEventsRef(text: string, isStreaming: boolean) {
|
|
const evts = eventsRef.current;
|
|
const idx = findLastContentIndex(evts);
|
|
if (idx < 0) return;
|
|
const newEvents = [...evts];
|
|
newEvents[idx] = isStreaming
|
|
? { type: "content", text, isStreaming: true }
|
|
: { type: "content", text };
|
|
eventsRef.current = newEvents;
|
|
}
|
|
|
|
function flushDrip() {
|
|
stopDrip();
|
|
const target = dripTargetRef.current;
|
|
dripDisplayLenRef.current = target.length;
|
|
syncDripIntoEventsRef(target, false);
|
|
setMessages((prev) => updateLastContentEvent(prev, target));
|
|
}
|
|
|
|
function startDrip() {
|
|
if (dripIntervalRef.current !== null) return;
|
|
dripIntervalRef.current = setInterval(() => {
|
|
const target = dripTargetRef.current;
|
|
const displayLen = dripDisplayLenRef.current;
|
|
if (displayLen >= target.length) return;
|
|
const newLen = Math.min(displayLen + DRIP_CHARS, target.length);
|
|
dripDisplayLenRef.current = newLen;
|
|
const slice = target.slice(0, newLen);
|
|
syncDripIntoEventsRef(slice, true);
|
|
setMessages((prev) => updateLastContentEvent(prev, slice, true));
|
|
}, 16);
|
|
}
|
|
|
|
// ---- event helpers ----
|
|
|
|
// Transient placeholder events that bridge the gap between real SSE
|
|
// events so the PreResponseWrapper doesn't briefly flip to "Completed"
|
|
// when one block ends before the next starts. Anytime a real event
|
|
// arrives (or content begins streaming), drop them first.
|
|
function isStreamingPlaceholder(e: AssistantEvent) {
|
|
return e.type === "thinking" && !!e.isStreaming;
|
|
}
|
|
|
|
function clearStreamingPlaceholders() {
|
|
const before = eventsRef.current;
|
|
const after = before.filter((e) => !isStreamingPlaceholder(e));
|
|
if (after.length === before.length) return;
|
|
eventsRef.current = after;
|
|
const snapshot = [...after];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = { ...last, events: snapshot };
|
|
}
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
function pushThinkingPlaceholder() {
|
|
const events = eventsRef.current;
|
|
const last = events[events.length - 1];
|
|
// Don't stack placeholders back-to-back.
|
|
if (last && isStreamingPlaceholder(last)) return;
|
|
eventsRef.current = [
|
|
...events,
|
|
{ type: "thinking" as const, isStreaming: true },
|
|
];
|
|
const snapshot = [...eventsRef.current];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = { ...last, events: snapshot };
|
|
}
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
function pushEvent(event: AssistantEvent) {
|
|
// Drop any in-flight placeholder unless we're pushing one ourselves.
|
|
let next = eventsRef.current;
|
|
if (event.type !== "thinking") {
|
|
next = next.filter((e) => !isStreamingPlaceholder(e));
|
|
}
|
|
eventsRef.current = [...next, event];
|
|
const snapshot = [...eventsRef.current];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = { ...last, events: snapshot };
|
|
}
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
function updateMatchingEvent(
|
|
predicate: (e: AssistantEvent) => boolean,
|
|
updater: (e: AssistantEvent) => AssistantEvent,
|
|
) {
|
|
const events = eventsRef.current;
|
|
const idx = [...events]
|
|
.map((_, i) => i)
|
|
.reverse()
|
|
.find((i) => predicate(events[i]));
|
|
if (idx === undefined) return false;
|
|
const newEvents = [...events];
|
|
newEvents[idx] = updater(events[idx]);
|
|
eventsRef.current = newEvents;
|
|
const snapshot = [...newEvents];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = { ...last, events: snapshot };
|
|
}
|
|
return updated;
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// ---- chat actions ----
|
|
|
|
function handleNewChat() {
|
|
setCurrentChatId(null);
|
|
setCurrentChatTitle(null);
|
|
setMessages([]);
|
|
setHistoryOpen(false);
|
|
}
|
|
|
|
async function handleDeleteChat(chatId: string) {
|
|
setChats((prev) => prev.filter((c) => c.id !== chatId));
|
|
if (chatId === currentChatId) {
|
|
setCurrentChatId(null);
|
|
setCurrentChatTitle(null);
|
|
setMessages([]);
|
|
}
|
|
try {
|
|
await deleteTabularChat(reviewId, chatId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
async function handleRenameChat(chatId: string, title: string) {
|
|
setChats((prev) =>
|
|
prev.map((c) => (c.id === chatId ? { ...c, title } : c)),
|
|
);
|
|
if (chatId === currentChatId) setCurrentChatTitle(title);
|
|
try {
|
|
await renameTabularChat(reviewId, chatId, title);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
async function handleLoadChat(chatId: string) {
|
|
const chat = chats.find((c) => c.id === chatId);
|
|
setCurrentChatId(chatId);
|
|
setCurrentChatTitle(chat?.title ?? null);
|
|
setMessages([]);
|
|
setHistoryOpen(false);
|
|
setIsLoadingMessages(true);
|
|
try {
|
|
const raw = await getTabularChatMessages(reviewId, chatId);
|
|
setMessages(mapTRMessages(raw) as TRMessage[]);
|
|
} catch {
|
|
/* ignore */
|
|
} finally {
|
|
setIsLoadingMessages(false);
|
|
}
|
|
}
|
|
|
|
function handleCancel() {
|
|
abortRef.current?.abort();
|
|
}
|
|
|
|
async function handleSubmit(trimmed: string) {
|
|
if (!trimmed || isLoading) return;
|
|
if (apiKeys && !isModelAvailable(currentModel, apiKeys)) {
|
|
setApiKeyModalProvider(getModelProvider(currentModel));
|
|
return;
|
|
}
|
|
|
|
// Build messages array for backend (plain text history)
|
|
const history: { role: string; content: string }[] = messages.map(
|
|
(m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
}),
|
|
);
|
|
const allMessages = [...history, { role: "user", content: trimmed }];
|
|
|
|
const userMsg: TRMessage = { role: "user", content: trimmed };
|
|
const assistantMsg: TRMessage = {
|
|
role: "assistant",
|
|
content: "",
|
|
events: [],
|
|
isStreaming: true,
|
|
};
|
|
|
|
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
|
setIsLoading(true);
|
|
|
|
setTimeout(() => {
|
|
const container = messagesContainerRef.current;
|
|
const element = latestUserMessageRef.current;
|
|
if (container && element) {
|
|
container.scrollTo({
|
|
top: element.offsetTop - 44,
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
}, 50);
|
|
|
|
stopDrip();
|
|
dripTargetRef.current = "";
|
|
dripDisplayLenRef.current = 0;
|
|
eventsRef.current = [];
|
|
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
try {
|
|
const response = await streamTabularChat(
|
|
reviewId,
|
|
allMessages,
|
|
currentChatId,
|
|
controller.signal,
|
|
{ reviewTitle, projectName },
|
|
);
|
|
if (!response.body) throw new Error("No response body");
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() ?? "";
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data:")) continue;
|
|
const dataStr = line.slice(5).trim();
|
|
if (dataStr === "[DONE]") continue;
|
|
|
|
try {
|
|
const data = JSON.parse(dataStr);
|
|
|
|
if (data.type === "chat_id") {
|
|
const newId = data.chatId as string;
|
|
setCurrentChatId(newId);
|
|
setChats((prev) =>
|
|
prev.some((c) => c.id === newId)
|
|
? prev
|
|
: [
|
|
{
|
|
id: newId,
|
|
title: null,
|
|
created_at:
|
|
new Date().toISOString(),
|
|
updated_at:
|
|
new Date().toISOString(),
|
|
},
|
|
...prev,
|
|
],
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "chat_title") {
|
|
const { chatId, title } = data as {
|
|
chatId: string;
|
|
title: string;
|
|
};
|
|
setChats((prev) =>
|
|
prev.map((c) =>
|
|
c.id === chatId ? { ...c, title } : c,
|
|
),
|
|
);
|
|
setCurrentChatTitle(title);
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "reasoning_delta") {
|
|
const text = data.text as string;
|
|
const events = eventsRef.current;
|
|
const last = events[events.length - 1];
|
|
if (
|
|
last?.type === "reasoning" &&
|
|
last.isStreaming
|
|
) {
|
|
eventsRef.current = [
|
|
...events.slice(0, -1),
|
|
{
|
|
type: "reasoning" as const,
|
|
text: last.text + text,
|
|
isStreaming: true,
|
|
},
|
|
];
|
|
} else {
|
|
// New reasoning block — drop any bridging
|
|
// placeholder before it so the wrapper
|
|
// doesn't render both.
|
|
const cleaned = events.filter(
|
|
(e) => !isStreamingPlaceholder(e),
|
|
);
|
|
eventsRef.current = [
|
|
...cleaned,
|
|
{
|
|
type: "reasoning" as const,
|
|
text,
|
|
isStreaming: true,
|
|
},
|
|
];
|
|
}
|
|
const snapshot = [...eventsRef.current];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = {
|
|
...last,
|
|
events: snapshot,
|
|
};
|
|
}
|
|
return updated;
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "reasoning_block_end") {
|
|
const events = eventsRef.current;
|
|
const last = events[events.length - 1];
|
|
if (
|
|
last?.type === "reasoning" &&
|
|
last.isStreaming
|
|
) {
|
|
eventsRef.current = [
|
|
...events.slice(0, -1),
|
|
{
|
|
type: "reasoning" as const,
|
|
text: last.text,
|
|
},
|
|
];
|
|
}
|
|
const snapshot = [...eventsRef.current];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = {
|
|
...last,
|
|
events: snapshot,
|
|
};
|
|
}
|
|
return updated;
|
|
});
|
|
pushThinkingPlaceholder();
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "content_delta") {
|
|
const text = data.text as string;
|
|
dripTargetRef.current += text;
|
|
const events = eventsRef.current;
|
|
const lastEvent = events[events.length - 1];
|
|
if (
|
|
lastEvent?.type !== "content" ||
|
|
!lastEvent.isStreaming
|
|
) {
|
|
// Finalize any still-streaming reasoning
|
|
// event AND drop bridging placeholders so
|
|
// the wrapper transitions cleanly into
|
|
// content.
|
|
const finalized = events
|
|
.filter((e) => !isStreamingPlaceholder(e))
|
|
.map((e) =>
|
|
e.type === "reasoning" && e.isStreaming
|
|
? {
|
|
type: "reasoning" as const,
|
|
text: e.text,
|
|
}
|
|
: e,
|
|
);
|
|
eventsRef.current = [
|
|
...finalized,
|
|
{
|
|
type: "content" as const,
|
|
text: "",
|
|
isStreaming: true,
|
|
},
|
|
];
|
|
const snapshot = [...eventsRef.current];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = {
|
|
...last,
|
|
events: snapshot,
|
|
};
|
|
}
|
|
return updated;
|
|
});
|
|
}
|
|
startDrip();
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
data.type === "courtlistener_search_case_law_start"
|
|
) {
|
|
pushEvent({
|
|
type: "courtlistener_search_case_law",
|
|
query: (data.query as string) ?? "",
|
|
isStreaming: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_search_case_law") {
|
|
updateMatchingEvent(
|
|
(e) =>
|
|
e.type ===
|
|
"courtlistener_search_case_law" &&
|
|
e.query === (data.query as string) &&
|
|
!!e.isStreaming,
|
|
() => ({
|
|
type: "courtlistener_search_case_law",
|
|
query: (data.query as string) ?? "",
|
|
result_count:
|
|
typeof data.result_count === "number"
|
|
? (data.result_count as number)
|
|
: 0,
|
|
error:
|
|
typeof data.error === "string"
|
|
? (data.error as string)
|
|
: undefined,
|
|
isStreaming: false,
|
|
}),
|
|
);
|
|
pushThinkingPlaceholder();
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_get_cases_start") {
|
|
pushEvent({
|
|
type: "courtlistener_get_cases",
|
|
cluster_ids: Array.isArray(data.cluster_ids)
|
|
? (data.cluster_ids as unknown[]).filter(
|
|
(value: unknown): value is number =>
|
|
typeof value === "number",
|
|
)
|
|
: [],
|
|
isStreaming: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_get_cases") {
|
|
updateMatchingEvent(
|
|
(e) =>
|
|
e.type === "courtlistener_get_cases" &&
|
|
!!e.isStreaming,
|
|
() => ({
|
|
type: "courtlistener_get_cases",
|
|
cluster_ids: Array.isArray(data.cluster_ids)
|
|
? (
|
|
data.cluster_ids as unknown[]
|
|
).filter(
|
|
(
|
|
value: unknown,
|
|
): value is number =>
|
|
typeof value === "number",
|
|
)
|
|
: [],
|
|
case_count:
|
|
typeof data.case_count === "number"
|
|
? (data.case_count as number)
|
|
: 0,
|
|
opinion_count:
|
|
typeof data.opinion_count === "number"
|
|
? (data.opinion_count as number)
|
|
: 0,
|
|
cases: parseCourtlistenerEventCases(
|
|
data.cases,
|
|
),
|
|
error:
|
|
typeof data.error === "string"
|
|
? (data.error as string)
|
|
: undefined,
|
|
isStreaming: false,
|
|
}),
|
|
);
|
|
pushThinkingPlaceholder();
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_find_in_case_start") {
|
|
const searches = parseCourtlistenerCaseSearches(
|
|
data.searches,
|
|
);
|
|
pushEvent({
|
|
type: "courtlistener_find_in_case",
|
|
cluster_id: searches?.length
|
|
? null
|
|
: typeof data.cluster_id === "number"
|
|
? (data.cluster_id as number)
|
|
: null,
|
|
query: searches?.length
|
|
? ""
|
|
: ((data.query as string) ?? ""),
|
|
searches,
|
|
isStreaming: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_find_in_case") {
|
|
const searches = parseCourtlistenerCaseSearches(
|
|
data.searches,
|
|
);
|
|
updateMatchingEvent(
|
|
(e) =>
|
|
e.type === "courtlistener_find_in_case" &&
|
|
(searches?.length
|
|
? Array.isArray(e.searches)
|
|
: e.cluster_id ===
|
|
(typeof data.cluster_id ===
|
|
"number"
|
|
? (data.cluster_id as number)
|
|
: null) &&
|
|
e.query === (data.query as string)) &&
|
|
!!e.isStreaming,
|
|
() => ({
|
|
type: "courtlistener_find_in_case",
|
|
cluster_id: searches?.length
|
|
? null
|
|
: typeof data.cluster_id === "number"
|
|
? (data.cluster_id as number)
|
|
: null,
|
|
query: searches?.length
|
|
? ""
|
|
: ((data.query as string) ?? ""),
|
|
total_matches:
|
|
typeof data.total_matches === "number"
|
|
? (data.total_matches as number)
|
|
: 0,
|
|
searches,
|
|
case_name:
|
|
typeof data.case_name === "string"
|
|
? (data.case_name as string)
|
|
: null,
|
|
citation:
|
|
typeof data.citation === "string"
|
|
? (data.citation as string)
|
|
: null,
|
|
error:
|
|
typeof data.error === "string"
|
|
? (data.error as string)
|
|
: undefined,
|
|
isStreaming: false,
|
|
}),
|
|
);
|
|
pushThinkingPlaceholder();
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_read_case_start") {
|
|
pushEvent({
|
|
type: "courtlistener_read_case",
|
|
cluster_id:
|
|
typeof data.cluster_id === "number"
|
|
? (data.cluster_id as number)
|
|
: null,
|
|
isStreaming: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_read_case") {
|
|
updateMatchingEvent(
|
|
(e) =>
|
|
e.type === "courtlistener_read_case" &&
|
|
e.cluster_id ===
|
|
(typeof data.cluster_id === "number"
|
|
? (data.cluster_id as number)
|
|
: null) &&
|
|
!!e.isStreaming,
|
|
() => ({
|
|
type: "courtlistener_read_case",
|
|
cluster_id:
|
|
typeof data.cluster_id === "number"
|
|
? (data.cluster_id as number)
|
|
: null,
|
|
case_name:
|
|
typeof data.case_name === "string"
|
|
? (data.case_name as string)
|
|
: null,
|
|
citation:
|
|
typeof data.citation === "string"
|
|
? (data.citation as string)
|
|
: null,
|
|
opinion_count:
|
|
typeof data.opinion_count === "number"
|
|
? (data.opinion_count as number)
|
|
: 0,
|
|
error:
|
|
typeof data.error === "string"
|
|
? (data.error as string)
|
|
: undefined,
|
|
isStreaming: false,
|
|
}),
|
|
);
|
|
pushThinkingPlaceholder();
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
data.type === "courtlistener_verify_citations_start"
|
|
) {
|
|
pushEvent({
|
|
type: "courtlistener_verify_citations",
|
|
citation_count:
|
|
typeof data.citation_count === "number"
|
|
? (data.citation_count as number)
|
|
: 0,
|
|
isStreaming: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "courtlistener_verify_citations") {
|
|
updateMatchingEvent(
|
|
(e) =>
|
|
e.type ===
|
|
"courtlistener_verify_citations" &&
|
|
!!e.isStreaming,
|
|
() => ({
|
|
type: "courtlistener_verify_citations",
|
|
citation_count:
|
|
typeof data.citation_count === "number"
|
|
? (data.citation_count as number)
|
|
: 0,
|
|
match_count:
|
|
typeof data.match_count === "number"
|
|
? (data.match_count as number)
|
|
: 0,
|
|
error:
|
|
typeof data.error === "string"
|
|
? (data.error as string)
|
|
: undefined,
|
|
isStreaming: false,
|
|
}),
|
|
);
|
|
pushThinkingPlaceholder();
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "case_citation") {
|
|
pushEvent({
|
|
type: "case_citation",
|
|
cluster_id:
|
|
typeof data.cluster_id === "number"
|
|
? (data.cluster_id as number)
|
|
: null,
|
|
case_name:
|
|
typeof data.case_name === "string"
|
|
? (data.case_name as string)
|
|
: null,
|
|
citation:
|
|
typeof data.citation === "string"
|
|
? (data.citation as string)
|
|
: null,
|
|
url: data.url as string,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "case_opinions") {
|
|
pushEvent({
|
|
type: "case_opinions",
|
|
cluster_id:
|
|
typeof data.cluster_id === "number"
|
|
? (data.cluster_id as number)
|
|
: 0,
|
|
case: data.case as Extract<
|
|
AssistantEvent,
|
|
{ type: "case_opinions" }
|
|
>["case"],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "doc_read_start") {
|
|
pushEvent({
|
|
type: "doc_read",
|
|
filename: data.filename as string,
|
|
isStreaming: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "doc_read") {
|
|
updateMatchingEvent(
|
|
(e) =>
|
|
e.type === "doc_read" &&
|
|
e.filename === data.filename &&
|
|
!!e.isStreaming,
|
|
(e) => ({ ...e, isStreaming: false }),
|
|
);
|
|
pushThinkingPlaceholder();
|
|
continue;
|
|
}
|
|
|
|
if (data.type === "citations") {
|
|
// End-of-stream signal — scrub any lingering
|
|
// placeholders so they don't persist into the
|
|
// finalised message.
|
|
clearStreamingPlaceholders();
|
|
const incoming = (data.citations ??
|
|
[]) as TRCitationAnnotation[];
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = {
|
|
...last,
|
|
annotations: incoming,
|
|
};
|
|
}
|
|
return updated;
|
|
});
|
|
continue;
|
|
}
|
|
} catch {
|
|
/* skip malformed */
|
|
}
|
|
}
|
|
}
|
|
|
|
flushDrip();
|
|
clearStreamingPlaceholders();
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = {
|
|
...last,
|
|
isStreaming: false,
|
|
};
|
|
}
|
|
return updated;
|
|
});
|
|
} catch (err: unknown) {
|
|
const isAbort = err instanceof Error && err.name === "AbortError";
|
|
stopDrip();
|
|
clearStreamingPlaceholders();
|
|
setMessages((prev) => {
|
|
const updated = [...prev];
|
|
const last = updated[updated.length - 1];
|
|
if (last?.role === "assistant") {
|
|
const hasContent = (last.events ?? []).some(
|
|
(e) =>
|
|
e.type === "content" &&
|
|
(e as { type: "content"; text: string }).text,
|
|
);
|
|
if (!hasContent) {
|
|
updated[updated.length - 1] = {
|
|
...last,
|
|
isStreaming: false,
|
|
events: [
|
|
...(last.events ?? []),
|
|
{
|
|
type: "content" as const,
|
|
text: isAbort
|
|
? ""
|
|
: "An error occurred. Please try again.",
|
|
},
|
|
],
|
|
};
|
|
} else {
|
|
updated[updated.length - 1] = {
|
|
...last,
|
|
isStreaming: false,
|
|
};
|
|
}
|
|
}
|
|
return updated;
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
abortRef.current = null;
|
|
}
|
|
}
|
|
|
|
// ---- render ----
|
|
|
|
const lastUserIdx = messages.map((m) => m.role).lastIndexOf("user");
|
|
const lastAssistantIdx = messages
|
|
.map((m) => m.role)
|
|
.lastIndexOf("assistant");
|
|
|
|
return (
|
|
<div
|
|
style={
|
|
{
|
|
"--tr-chat-panel-width": `${panelWidth}px`,
|
|
} as CSSProperties
|
|
}
|
|
className={cn(
|
|
"flex flex-col relative",
|
|
// Mobile: replaces the table, filling the row minus margins.
|
|
// md+: fixed width beside the table, top-aligned with it
|
|
// (below the toolbar).
|
|
"flex-1 min-w-0 mx-3 mb-3 md:flex-none md:w-[var(--tr-chat-panel-width)] md:mt-12 md:-ml-4 md:mr-6",
|
|
LIQUID_PANEL_SURFACE_CLASS,
|
|
"overflow-hidden",
|
|
)}
|
|
>
|
|
{/* Resize handle */}
|
|
<div
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
resizeStartRef.current = { x: e.clientX, width: panelWidth };
|
|
setIsResizing(true);
|
|
}}
|
|
className={`absolute top-0 left-0 h-full w-1 cursor-col-resize z-20 transition-colors hidden md:block ${
|
|
isResizing
|
|
? "bg-blue-400/70"
|
|
: "bg-transparent hover:bg-blue-400/70"
|
|
}`}
|
|
/>
|
|
{/* Header — fixed, overlaid on top of the messages */}
|
|
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between gap-2 px-2 py-2">
|
|
{/* Title pill — opens chat history */}
|
|
<div ref={historyRef} className="relative shrink min-w-0">
|
|
<div className={cn(HEADER_PILL_CLASS, "min-w-0")}>
|
|
<button
|
|
onClick={() => setHistoryOpen((v) => !v)}
|
|
title="Chat history"
|
|
className={`flex h-5 min-w-0 items-center gap-1 rounded-full px-1.5 text-gray-700 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
|
|
>
|
|
<span className="min-w-0 truncate text-xs font-medium">
|
|
{currentChatTitle ?? "New chat"}
|
|
</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-3 w-3 shrink-0 text-gray-400 transition-transform duration-200",
|
|
historyOpen && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{historyOpen && (
|
|
<LiquidDropdownSurface className="absolute top-full left-0 z-50 mt-2 w-64 overflow-hidden">
|
|
<HistoryDropdown
|
|
chats={chats}
|
|
currentChatId={currentChatId}
|
|
onLoad={handleLoadChat}
|
|
onRename={handleRenameChat}
|
|
onDelete={handleDeleteChat}
|
|
/>
|
|
</LiquidDropdownSurface>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{/* New chat circle — only once a chat has started */}
|
|
{messages.length > 0 && (
|
|
<div className={cn(HEADER_PILL_CLASS, "px-0.5")}>
|
|
<button
|
|
onClick={handleNewChat}
|
|
title="New chat"
|
|
className={HEADER_PILL_BUTTON_CLASS}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* Close circle */}
|
|
<div className={cn(HEADER_PILL_CLASS, "px-0.5")}>
|
|
<button
|
|
onClick={onClose}
|
|
title="Close"
|
|
className={HEADER_PILL_BUTTON_CLASS}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div
|
|
ref={messagesContainerRef}
|
|
className="flex-1 overflow-y-auto px-4 pt-12 flex flex-col"
|
|
style={{ paddingBottom: Math.ceil(inputHeight + 16) }}
|
|
>
|
|
{isLoadingMessages && (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex justify-end">
|
|
<div className="bg-gray-100 rounded-2xl p-3 w-3/5">
|
|
<div className="h-3 bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-[shimmer_2s_ease-in-out_infinite] rounded w-full" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div
|
|
key={i}
|
|
className={`h-3 bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-[shimmer_2s_ease-in-out_infinite] rounded ${i === 3 ? "w-5/6" : i === 4 ? "w-4/6" : "w-full"}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{messages.length > 0 && (
|
|
<div
|
|
className="flex flex-col gap-4 transition-opacity duration-150"
|
|
style={{ opacity: messagesVisible ? 1 : 0 }}
|
|
>
|
|
{messages.map((msg, i) => (
|
|
<div
|
|
key={i}
|
|
ref={
|
|
i === lastUserIdx
|
|
? latestUserMessageRef
|
|
: null
|
|
}
|
|
style={
|
|
i === lastAssistantIdx
|
|
? { minHeight }
|
|
: undefined
|
|
}
|
|
>
|
|
<MessageBubble
|
|
msg={msg}
|
|
onCitationClick={onCitationClick}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top blur overlay — messages fade out under the header */}
|
|
<div className="pointer-events-none absolute top-0 left-0 right-2 z-[5] h-10 backdrop-blur-2xl bg-gradient-to-b from-white/80 to-transparent [mask-image:linear-gradient(to_bottom,black_65%,transparent)]" />
|
|
|
|
{/* Bottom blur overlay — messages fade out under the input */}
|
|
<div className="pointer-events-none absolute bottom-0 left-0 right-2 z-[5] h-32 backdrop-blur-2xl bg-gradient-to-t from-white/80 to-transparent [mask-image:linear-gradient(to_top,black_65%,transparent)]" />
|
|
|
|
{/* Input */}
|
|
<TRChatInput
|
|
isLoading={isLoading}
|
|
onSubmit={handleSubmit}
|
|
onCancel={handleCancel}
|
|
model={currentModel}
|
|
onModelChange={(id) =>
|
|
updateModelPreference("tabularModel", id)
|
|
}
|
|
apiKeys={apiKeys}
|
|
onHeightChange={setInputHeight}
|
|
/>
|
|
|
|
<ApiKeyMissingPopup
|
|
open={apiKeyModalProvider !== null}
|
|
provider={apiKeyModalProvider}
|
|
onClose={() => setApiKeyModalProvider(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|