mirror of
https://github.com/willchen96/mike.git
synced 2026-06-24 21:38:06 +02:00
Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes
This commit is contained in:
parent
d39f5806e5
commit
44e868eb42
106 changed files with 16350 additions and 7753 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -85,8 +85,6 @@ export function TREditColumnMenu({
|
|||
setSaving(false);
|
||||
}
|
||||
}
|
||||
console.log(tags);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue