Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes

This commit is contained in:
willchen96 2026-06-06 15:48:47 +08:00
parent d39f5806e5
commit 44e868eb42
106 changed files with 16350 additions and 7753 deletions

View file

@ -1,9 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Loader2, Upload, X } from "lucide-react";
import type { MikeDocument, MikeProject, MikeWorkflow } from "../shared/types";
import { Check, ChevronDown, Loader2, Upload } from "lucide-react";
import type { Document, Project, Workflow } from "../shared/types";
import {
getProject,
listProjects,
@ -14,6 +13,7 @@ import {
} from "@/app/lib/mikeApi";
import { FileDirectory } from "../shared/FileDirectory";
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
import { Modal } from "../shared/Modal";
interface Props {
open: boolean;
@ -22,11 +22,11 @@ interface Props {
title: string,
projectId?: string,
documentIds?: string[],
columnsConfig?: MikeWorkflow["columns_config"],
columnsConfig?: Workflow["columns_config"],
) => void;
projects?: MikeProject[];
projects?: Project[];
/** When provided, skip the project/directory picker and show only these docs */
projectDocs?: MikeDocument[];
projectDocs?: Document[];
projectName?: string;
projectCmNumber?: string | null;
}
@ -47,12 +47,12 @@ export function AddNewTRModal({
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
// Project-scoped docs (when underProject is true and no fixedProjectDocs)
const [projectDocs, setProjectDocs] = useState<MikeDocument[]>([]);
const [projectDocs, setProjectDocs] = useState<Document[]>([]);
const [loadingDocs, setLoadingDocs] = useState(false);
// Full directory (when underProject is false)
const [standaloneDocs, setStandaloneDocs] = useState<MikeDocument[]>([]);
const [directoryProjects, setDirectoryProjects] = useState<MikeProject[]>(
const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]);
const [directoryProjects, setDirectoryProjects] = useState<Project[]>(
[],
);
const [loadingDirectory, setLoadingDirectory] = useState(false);
@ -64,12 +64,13 @@ export function AddNewTRModal({
const fileInputRef = useRef<HTMLInputElement>(null);
// Workflow templates
const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]);
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const [workflowDropdownOpen, setWorkflowDropdownOpen] = useState(false);
const formId = "new-tabular-review-modal-form";
useEffect(() => {
if (!open) return;
@ -205,7 +206,7 @@ export function AddNewTRModal({
: underProject
? []
: directoryProjects;
const flatProjectDocs: MikeDocument[] =
const flatProjectDocs: Document[] =
!isProjectMode && underProject ? projectDocs : [];
const directoryLoading = isProjectMode
? false
@ -213,56 +214,59 @@ export function AddNewTRModal({
? loadingDocs
: loadingDirectory;
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
const breadcrumbs =
isProjectMode && projectName
? [
"Projects",
`${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`,
"New Tabular Review",
]
: ["Tabular Reviews", "New Tabular Review"];
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{isProjectMode && projectName ? (
<>
<span>Projects</span>
<span></span>
<span>
{projectName}
{projectCmNumber ? ` (#${projectCmNumber})` : ""}
</span>
<span></span>
<span>Tabular Reviews</span>
<span></span>
<span>New review</span>
</>
) : (
<>
<span>Tabular Reviews</span>
<span></span>
<span>New review</span>
</>
)}
</div>
<button
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-col min-h-0 flex-1"
>
<div className="px-6 pt-3 pb-4 space-y-5 overflow-y-auto flex-1">
{/* Title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Review name"
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
autoFocus
/>
return (
<Modal
open={open}
onClose={handleClose}
breadcrumbs={breadcrumbs}
secondaryAction={{
label: uploading ? "Uploading…" : "Upload",
icon: uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
),
onClick: () => fileInputRef.current?.click(),
disabled: uploading,
}}
primaryAction={{
label: "Create",
type: "submit",
form: formId,
disabled: !title.trim() || (underProject && !selectedProjectId),
}}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc"
multiple
className="hidden"
onChange={handleUpload}
/>
<form
id={formId}
onSubmit={handleSubmit}
className="flex flex-col min-h-0 flex-1"
>
<div className="space-y-5">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Review name"
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
autoFocus
/>
{/* Workflow template */}
<div className="space-y-2">
@ -477,56 +481,8 @@ export function AddNewTRModal({
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-2 border-t border-gray-100 px-6 py-4 shrink-0">
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc"
multiple
className="hidden"
onChange={handleUpload}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={
!title.trim() ||
(underProject && !selectedProjectId)
}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
Create
</button>
</div>
</div>
</form>
</div>
</div>,
document.body,
</div>
</form>
</Modal>
);
}

View file

@ -4,13 +4,13 @@ import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
X,
Clock,
MessageSquarePlus,
Search,
Square,
ArrowRight,
ChevronDown,
ChevronLeft,
Trash2,
} from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
@ -23,11 +23,7 @@ import {
type TRChat,
type TRCitationAnnotation,
} from "@/app/lib/mikeApi";
import type {
AssistantEvent,
ColumnConfig,
MikeDocument,
} from "../shared/types";
import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
import { ModelToggle } from "../assistant/ModelToggle";
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
@ -38,6 +34,7 @@ import {
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
@ -51,12 +48,64 @@ interface TRMessage {
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: MikeDocument[];
documents: Document[];
onCitationClick: (colIdx: number, rowIdx: number) => void;
onClose: () => void;
initialChatId?: string | null;
@ -73,6 +122,8 @@ const THINKING_PHRASES = [
"Analyzing...",
"Reasoning...",
];
const REASONING_COLLAPSED_MAX_LINES = 6;
const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9;
function ReasoningBlock({
text,
@ -82,7 +133,11 @@ function ReasoningBlock({
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<HTMLDivElement | null>(null);
useEffect(() => {
if (!isStreaming) return;
@ -93,10 +148,28 @@ function ReasoningBlock({
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 (
<div className="ml-1">
<button
onClick={() => !isStreaming && setIsOpen((v) => !v)}
onClick={() => {
if (isStreaming) return;
setUserToggled(true);
setIsOpen((v) => !v);
}}
className="flex items-center text-sm text-gray-400 hover:text-gray-500 transition-colors"
>
{isStreaming ? (
@ -116,11 +189,56 @@ function ReasoningBlock({
/>
)}
</button>
{(isOpen || isStreaming) && (
<div className="mt-1.5 ml-[14px] text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text}
</ReactMarkdown>
{showContent && (
<div className="mt-1.5 ml-[14px]">
<div
className={`relative ${isCollapsed ? "overflow-hidden" : ""}`}
style={
isCollapsed
? {
maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`,
}
: undefined
}
>
<div
ref={contentRef}
className="text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text}
</ReactMarkdown>
</div>
{isCollapsed && (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" />
<button
type="button"
onClick={() => {
setUserToggled(true);
setIsOpen(true);
}}
className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label="Expand thought process"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
{isOverflowing && isOpen && (
<button
type="button"
onClick={() => {
setUserToggled(true);
setIsOpen(false);
}}
className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600"
aria-label="Minimise thought process"
>
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
)}
</div>
)}
</div>
@ -507,9 +625,17 @@ function TRChatInput({
return (
<div
ref={rootRef}
className="absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white"
className={cn(
"absolute bottom-0 left-0 right-0 px-4 pb-3",
"bg-transparent",
)}
>
<div className="border border-gray-300 rounded-xl bg-white pt-2 pb-1.5 flex flex-col gap-1">
<div
className={cn(
"pt-2 pb-1.5 flex flex-col gap-1",
"rounded-[18px] border border-white/65 bg-white/60 shadow-[0_6px_18px_rgba(15,23,42,0.16),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}
@ -537,7 +663,10 @@ function TRChatInput({
type="button"
onClick={handleAction}
disabled={!isLoading && !value.trim()}
className="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"
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
@ -930,7 +1059,7 @@ export function TRChatPanel({
.map((_, i) => i)
.reverse()
.find((i) => predicate(events[i]));
if (idx === undefined) return;
if (idx === undefined) return false;
const newEvents = [...events];
newEvents[idx] = updater(events[idx]);
eventsRef.current = newEvents;
@ -943,6 +1072,7 @@ export function TRChatPanel({
}
return updated;
});
return true;
}
// ---- chat actions ----
@ -1225,6 +1355,295 @@ export function TRChatPanel({
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",
@ -1337,7 +1756,10 @@ export function TRChatPanel({
return (
<div
style={{ width: panelWidth }}
className="shrink-0 flex flex-col border-r border-gray-200 bg-white h-full relative"
className={cn(
"shrink-0 flex flex-col border-r border-gray-200 h-full relative",
"bg-transparent",
)}
>
{/* Resize handle */}
<div
@ -1352,9 +1774,15 @@ export function TRChatPanel({
}`}
/>
{/* Header */}
<div className="flex items-center justify-between h-8 px-2 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-1.5 px-2 min-w-0">
<MikeIcon mike size={14} />
<div className="flex items-center justify-between h-8 pr-2 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-1 pl-2 pr-2 min-w-0">
<button
onClick={onClose}
title="Close"
className="flex items-center justify-center h-7 w-7 shrink-0 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<div
onMouseEnter={(e) => {
const el = e.currentTarget;
@ -1374,7 +1802,7 @@ export function TRChatPanel({
className="min-w-0 overflow-x-hidden whitespace-nowrap scrollbar-none"
>
<span className="text-xs font-medium text-gray-700">
{currentChatTitle ?? "Assistant"}
{currentChatTitle ?? "New chat"}
</span>
</div>
</div>
@ -1383,7 +1811,7 @@ export function TRChatPanel({
<button
onClick={() => setHistoryOpen((v) => !v)}
title="Chat history"
className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-400 hover:text-gray-700"}`}
className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-600 hover:text-gray-900"}`}
>
<Clock className="h-3.5 w-3.5" />
</button>
@ -1400,7 +1828,7 @@ export function TRChatPanel({
<button
onClick={handleNewChat}
title="New chat"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
@ -1408,18 +1836,11 @@ export function TRChatPanel({
<button
onClick={handleDeleteChat}
title="Delete chat"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-red-600 transition-colors"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-red-600 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={onClose}
title="Close"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
@ -1432,7 +1853,7 @@ export function TRChatPanel({
{messages.length === 0 && !isLoadingMessages && (
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<MikeIcon size={24} />
<p className="text-sm text-gray-400 text-center">
<p className="text-gray-400 font-serif text-center">
Ask a question about this tabular review.
</p>
</div>

View file

@ -85,8 +85,6 @@ export function TREditColumnMenu({
setSaving(false);
}
}
console.log(tags);
async function handleDelete() {
setDeleting(true);
try {

View file

@ -12,11 +12,16 @@ import {
RefreshCw,
X,
} from "lucide-react";
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
import type {
ColumnConfig,
Document,
TabularCell,
} from "../shared/types";
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
import { getPillClass } from "./pillUtils";
import { DocView } from "../shared/DocView";
import { DocxView } from "../shared/DocxView";
import { cn } from "@/lib/utils";
function isDocxDocument(d: {
file_type?: string | null;
@ -30,7 +35,7 @@ function isDocxDocument(d: {
interface Props {
cell: TabularCell;
document: MikeDocument;
document: Document;
column: ColumnConfig;
columns: ColumnConfig[];
onClose: () => void;
@ -109,22 +114,16 @@ export function TRSidePanel({
const { processed: reasoningText, citations: reasoningCitations } =
preprocessCitations(cell.content?.reasoning ?? "");
useEffect(() => {
console.log("[TRSidePanel] summary:", cell.content?.summary ?? "");
}, [cell.id, cell.content?.summary]);
return (
<div
className="fixed right-0 top-0 bottom-0 z-100 flex flex-row shadow-md border-l border-gray-200"
style={{
background: "rgba(255,255,255,0.08)",
backdropFilter: "blur(10px) saturate(50%)",
WebkitBackdropFilter: "blur(10px) saturate(50%)",
}}
className={cn(
"fixed z-100 flex flex-row",
"right-3 top-3 bottom-3 overflow-hidden rounded-2xl border border-white/70 bg-white/20 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl",
)}
>
{/* Document panel — left, 600px */}
{docCitation !== undefined && (
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3">
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3 pb-3">
{/* Doc header */}
<div className="flex items-center gap-2 pt-3 shrink-0 border-b border-white/30">
<p
@ -255,7 +254,9 @@ export function TRSidePanel({
</span>
</div>
{/* Document name */}
<p className="text-xs mb-4">{doc.filename}</p>
<p className="text-xs mb-4">
{doc.filename}
</p>
{/* Flag section */}
{cell.content?.flag && (

View file

@ -2,7 +2,11 @@
import { forwardRef, useImperativeHandle, useRef } from "react";
import { Loader2, Plus, Table2, Upload } from "lucide-react";
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
import type {
ColumnConfig,
Document,
TabularCell,
} from "../shared/types";
import { TabularCell as TabularCellComponent } from "./TabularCell";
import { TREditColumnMenu } from "./TREditColumnMenu";
@ -10,13 +14,12 @@ const SKELETON_COLS = 4;
const SKELETON_ROWS = 5;
const COL_W = "w-[300px] shrink-0";
const CHECK_W = "w-8 shrink-0";
const DOC_COL_W = "w-[332px] shrink-0";
// Pixel widths matching the CSS constants above
const CHECK_W_PX = 32; // w-8 = 2rem = 32px
const DOC_COL_W_PX = 300;
const DOC_COL_W_PX = 332;
const DATA_COL_W_PX = 300;
const STICKY_LEFT_PX = CHECK_W_PX + DOC_COL_W_PX; // 332px
const STICKY_LEFT_PX = DOC_COL_W_PX;
export interface TRTableHandle {
scrollToCell: (colIdx: number, rowIdx: number) => void;
@ -25,7 +28,7 @@ export interface TRTableHandle {
interface Props {
loading: boolean;
columns: ColumnConfig[];
documents: MikeDocument[];
documents: Document[];
cells: TabularCell[];
savingColumn: boolean;
savingColumnsConfig: boolean;
@ -64,10 +67,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
},
ref,
) {
const stickyCellBg = "bg-[#fcfcfd]";
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const totalContentWidth =
CHECK_W_PX + DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
useImperativeHandle(ref, () => ({
scrollToCell(colIdx: number, rowIdx: number) {
@ -130,12 +134,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{/* Header */}
<div className="flex border-b border-gray-200">
<div
className={`${CHECK_W} border-r border-gray-200 p-2`}
/>
<div
className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500`}
className={`${DOC_COL_W} flex items-center gap-4 border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`}
>
Document
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<span>Document</span>
</div>
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
<div
@ -151,10 +153,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
<div
key={row}
className={`flex border-b border-gray-50 ${row % 2 === 0 ? "bg-white" : "bg-gray-50/50"}`}
className={`flex border-b border-gray-50 ${row % 2 === 0 ? "" : "bg-gray-50/50"}`}
>
<div className={`${CHECK_W} p-2`} />
<div className={`${COL_W} p-2`}>
<div className={`${DOC_COL_W} flex items-center gap-4 py-2 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
</div>
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
@ -177,9 +179,8 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center border-b border-gray-200">
<div className={`${CHECK_W} border-r border-gray-200`} />
<div
className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500 select-none`}
className={`${DOC_COL_W} border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`}
>
Document
</div>
@ -225,11 +226,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
>
{/* Header */}
<div
className="sticky top-0 z-20 flex bg-white h-8"
className={`sticky top-0 z-20 flex h-8 ${stickyCellBg}`}
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-30 ${CHECK_W} bg-white border-b border-r border-gray-200 flex justify-center items-center select-none`}
className={`sticky left-0 z-30 ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`}
>
<input
type="checkbox"
@ -240,11 +241,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
onChange={toggleAll}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div
className={`sticky left-8 z-30 ${COL_W} bg-white border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
>
Document
<span>Document</span>
</div>
{columns.map((col) => (
<div
@ -281,21 +278,17 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{uploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="flex bg-white"
className="flex"
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center bg-white`}
className={`sticky left-0 z-[60] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-400 flex items-center gap-4`}
>
<input
type="checkbox"
disabled
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
/>
</div>
<div
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-400 flex items-center gap-2 bg-white`}
>
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
<span className="line-clamp-1" title={filename}>
{filename}
@ -314,7 +307,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
))}
{documents.map((doc, docIdx) => {
const baseRowBg =
docIdx % 2 === 0 ? "bg-white" : "bg-gray-50";
docIdx % 2 === 0 ? stickyCellBg : "bg-gray-50";
const rowBg = selectedDocIds.includes(doc.id)
? "bg-gray-100"
: baseRowBg;
@ -325,7 +318,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`}
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center gap-4 ${rowBg}`}
>
<input
type="checkbox"
@ -333,10 +326,6 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
onChange={() => toggleDoc(doc.id)}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${baseRowBg}`}
>
<span
className="line-clamp-1"
title={doc.filename}

View file

@ -2,8 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload } from "lucide-react";
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload, X } from "lucide-react";
import {
clearTabularCells,
@ -17,8 +16,8 @@ import {
} from "@/app/lib/mikeApi";
import type {
ColumnConfig,
MikeDocument,
MikeProject,
Document,
Project,
TabularCell,
TabularReview,
} from "../shared/types";
@ -42,6 +41,7 @@ import type { TRTableHandle } from "./TRTable";
import { TRChatPanel } from "./TRChatPanel";
import { exportTabularReviewToExcel } from "./exportToExcel";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { PageHeader } from "../shared/PageHeader";
interface Props {
reviewId: string;
@ -51,9 +51,9 @@ interface Props {
export function TRView({ reviewId, projectId }: Props) {
const { setSidebarOpen } = useSidebar();
const [review, setReview] = useState<TabularReview | null>(null);
const [project, setProject] = useState<MikeProject | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [cells, setCells] = useState<TabularCell[]>([]);
const [documents, setDocuments] = useState<MikeDocument[]>([]);
const [documents, setDocuments] = useState<Document[]>([]);
const [columns, setColumns] = useState<ColumnConfig[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
@ -160,7 +160,7 @@ export function TRView({ reviewId, projectId }: Props) {
}
}
async function handleAddDocuments(newDocs: MikeDocument[]) {
async function handleAddDocuments(newDocs: Document[]) {
const toAdd = newDocs.filter(
(d) => !documents.some((existing) => existing.id === d.id),
);
@ -201,7 +201,7 @@ export function TRView({ reviewId, projectId }: Props) {
if (files.length === 0) return;
setUploadingDroppedFilenames(files.map((file) => file.name));
try {
const uploaded: MikeDocument[] = [];
const uploaded: Document[] = [];
const documentIds = documents.map((document) => document.id);
for (const file of files) {
const document = await uploadReviewDocument(reviewId, file, {
@ -526,135 +526,123 @@ export function TRView({ reviewId, projectId }: Props) {
: documents;
return (
<div className="flex h-full overflow-hidden bg-white">
<div className="flex h-full overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="mb-1 bg-white px-4 py-3 md:px-10 flex items-start justify-between shrink-0 gap-4">
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
{projectId && (
<>
<button
onClick={() => router.push("/projects")}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
Projects
</button>
<span className="text-gray-300"></span>
<button
onClick={() =>
router.push(`/projects/${projectId}`)
}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
{loading ? (
<div className="h-6 w-32 rounded bg-gray-100 animate-pulse" />
) : (
<>
{project?.name ?? ""}
{project?.cm_number && (
<PageHeader
align="start"
shrink
className="gap-4"
breadcrumbs={[
...(projectId
? [
{
label: "Projects",
onClick: () => router.push("/projects"),
},
loading
? {
loading: true,
skeletonClassName: "w-32",
onClick: () =>
router.push(`/projects/${projectId}`),
title: "Back to project",
}
: {
label: project?.name ?? "",
suffix: project?.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
)}
</>
)}
</button>
<span className="text-gray-300"></span>
<button
onClick={() =>
router.push(
`/projects/${projectId}?tab=reviews`,
)
}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
Tabular Reviews
</button>
</>
)}
{!projectId && (
<button
onClick={() => router.push("/tabular-reviews")}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
Tabular Reviews
</button>
)}
<span className="text-gray-300"></span>
{loading ? (
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
) : (
<RenameableTitle
value={review?.title || "Untitled Review"}
onCommit={handleTitleCommit}
/>
)}
</div>
{!loading && (
<div className="flex items-center gap-2">
<HeaderSearchBtn value={search} onChange={setSearch} placeholder="Search documents…" />
{!projectId && (
<button
onClick={() => setPeopleModalOpen(true)}
disabled={loading}
className={`flex h-8 w-8 items-center justify-center text-sm transition-colors ${
loading
? "text-gray-300 cursor-default"
: "text-gray-500 hover:text-gray-900 cursor-pointer"
}`}
title="People with access"
aria-label="People with access"
>
<Users className="h-4 w-4" />
</button>
)}
<button
onClick={() =>
exportTabularReviewToExcel({
reviewTitle: review?.title || "Tabular Review",
columns,
documents,
cells,
})
}
disabled={columns.length === 0 || documents.length === 0}
title="Export to Excel"
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
columns.length === 0 || documents.length === 0
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900 cursor-pointer"
}`}
>
<Download className="h-4 w-4" />
Export
</button>
<button
onClick={handleGenerate}
disabled={
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig
}
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900 cursor-pointer"
}`}
>
{generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
{generating ? "Running…" : "Run"}
</button>
</div>
)}
</div>
) : null,
onClick: () =>
router.push(`/projects/${projectId}`),
title: "Back to project",
},
]
: [
{
label: "Tabular Reviews",
onClick: () => router.push("/tabular-reviews"),
title: "Back to Tabular Reviews",
},
]),
loading
? {
loading: true,
skeletonClassName: "w-40",
}
: {
label: (
<RenameableTitle
value={review?.title || "Untitled Review"}
onCommit={handleTitleCommit}
/>
),
},
]}
actions={
!loading
? [
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search documents…",
},
!projectId
? {
onClick: () =>
setPeopleModalOpen(true),
disabled: loading,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
}
: null,
{
onClick: () =>
exportTabularReviewToExcel({
reviewTitle:
review?.title ||
"Tabular Review",
columns,
documents,
cells,
}),
disabled:
columns.length === 0 ||
documents.length === 0,
title: "Export to Excel",
icon: <Download className="h-4 w-4" />,
label: (
<span className="hidden sm:inline">
Export
</span>
),
},
{
onClick: handleGenerate,
disabled:
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig,
icon: generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
{generating ? "Running…" : "Run"}
</span>
),
},
]
: undefined
}
/>
{/* Toolbar */}
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-4">
@ -671,8 +659,12 @@ export function TRView({ reviewId, projectId }: Props) {
: "text-gray-700 hover:text-gray-900"
}`}
>
<MessageSquare className="h-3.5 w-3.5" />
Assistant in Tabular Review
{chatOpen ? (
<X className="h-3.5 w-3.5" />
) : (
<MessageSquare className="h-3.5 w-3.5" />
)}
Assistant
</button>
<div className="ml-auto flex items-center gap-5">
{loading ? (
@ -870,7 +862,7 @@ export function TRView({ reviewId, projectId }: Props) {
<AddProjectDocsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={(docs: MikeDocument[]) =>
onSelect={(docs: Document[]) =>
handleAddDocuments(docs)
}
breadcrumb={[
@ -890,7 +882,7 @@ export function TRView({ reviewId, projectId }: Props) {
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={(docs: MikeDocument[]) =>
onSelect={(docs: Document[]) =>
handleAddDocuments(docs)
}
breadcrumb={[

View file

@ -1,7 +1,11 @@
"use client";
import ExcelJS from "exceljs";
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
import type {
ColumnConfig,
Document,
TabularCell,
} from "../shared/types";
import { preprocessCitations } from "./citation-utils";
function formatCellForExport(cell: TabularCell | undefined): string {
@ -31,7 +35,7 @@ function sanitizeFilename(name: string): string {
export async function exportTabularReviewToExcel(params: {
reviewTitle: string;
columns: ColumnConfig[];
documents: MikeDocument[];
documents: Document[];
cells: TabularCell[];
}) {
const { reviewTitle, columns, documents, cells } = params;