Add local repo contents

This commit is contained in:
willchen96 2026-04-29 19:49:06 +02:00
parent 65739ef1ce
commit d9690965b5
176 changed files with 68998 additions and 0 deletions

View file

@ -0,0 +1,522 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Plus, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
import { TAG_COLORS } from "./pillUtils";
import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface ColumnDraft {
name: string;
prompt: string;
format: ColumnFormat;
tags: string[];
tagInput: string;
}
const EMPTY_DRAFT: ColumnDraft = {
name: "",
prompt: "",
format: "text",
tags: [],
tagInput: "",
};
interface Props {
open: boolean;
existingCount: number;
onClose: () => void;
onAdd: (cols: ColumnConfig[]) => void;
editingColumn?: ColumnConfig;
onSave?: (col: ColumnConfig) => void;
onDelete?: () => void;
}
export function AddColumnModal({ open, existingCount, onClose, onAdd, editingColumn, onSave, onDelete }: Props) {
const isEditing = !!editingColumn;
const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_DRAFT }]);
const [generatingIndices, setGeneratingIndices] = useState<number[]>([]);
const [presetsOpenIndex, setPresetsOpenIndex] = useState<number | null>(
null,
);
const presetsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
if (editingColumn) {
setColumns([{
name: editingColumn.name,
prompt: editingColumn.prompt,
format: editingColumn.format ?? "text",
tags: editingColumn.tags ?? [],
tagInput: "",
}]);
} else {
setColumns([{ ...EMPTY_DRAFT }]);
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (presetsOpenIndex === null) return;
function handleClickOutside(e: MouseEvent) {
if (
presetsRef.current &&
!presetsRef.current.contains(e.target as Node)
) {
setPresetsOpenIndex(null);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [presetsOpenIndex]);
if (!open) return null;
function resetForm() {
setColumns([{ ...EMPTY_DRAFT }]);
setGeneratingIndices([]);
}
function handleClose() {
resetForm();
onClose();
}
function updateColumn(index: number, patch: Partial<ColumnDraft>) {
setColumns((prev) =>
prev.map((col, i) => (i === index ? { ...col, ...patch } : col)),
);
}
function addAnotherColumn() {
setColumns((prev) => [...prev, { ...EMPTY_DRAFT }]);
}
function removeColumn(index: number) {
setColumns((prev) =>
prev.length === 1
? [{ ...EMPTY_DRAFT }]
: prev.filter((_, i) => i !== index),
);
}
function commitTag(index: number) {
setColumns((prev) => {
const col = prev[index]!;
const tag = col.tagInput.trim();
if (!tag || col.tags.includes(tag)) {
return prev.map((c, i) =>
i === index ? { ...c, tagInput: "" } : c,
);
}
return prev.map((c, i) =>
i === index
? { ...c, tags: [...c.tags, tag], tagInput: "" }
: c,
);
});
}
function handleTagKeyDown(
e: React.KeyboardEvent<HTMLInputElement>,
index: number,
) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commitTag(index);
} else if (
e.key === "Backspace" &&
columns[index]!.tagInput === "" &&
columns[index]!.tags.length > 0
) {
updateColumn(index, {
tags: columns[index]!.tags.slice(0, -1),
});
}
}
async function autoGeneratePrompt(index: number) {
const title = columns[index]?.name?.trim() ?? "";
if (!title) return;
setGeneratingIndices((prev) => [...prev, index]);
try {
const col = columns[index]!;
const { prompt } = await generateTabularColumnPrompt(title, {
format: col.format,
tags: col.format === "tag" ? col.tags : undefined,
});
updateColumn(index, { prompt });
} finally {
setGeneratingIndices((prev) => prev.filter((v) => v !== index));
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (columns.some((col) => !col.name.trim() || !col.prompt.trim()))
return;
if (isEditing && onSave && editingColumn) {
const col = columns[0]!;
onSave({
index: editingColumn.index,
name: col.name.trim(),
prompt: col.prompt.trim(),
format: col.format,
tags: col.format === "tag" ? col.tags : undefined,
});
} else {
onAdd(
columns.map((col, i) => ({
index: existingCount + i,
name: col.name.trim(),
prompt: col.prompt.trim(),
format: col.format,
tags: col.format === "tag" ? col.tags : undefined,
})),
);
}
resetForm();
onClose();
}
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">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Tabular Review</span>
<span></span>
<span>{isEditing ? "Edit column" : "New column"}</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"
>
{/* Body */}
<div className="px-6 pt-3 pb-5 space-y-5 overflow-y-auto flex-1">
{columns.map((column, index) => (
<div
key={index}
className="rounded-xl border border-gray-200 p-4"
>
{/* Name row */}
<div className="flex items-start gap-2">
{/* Input + preset dropdown anchored to this wrapper */}
<div
className="relative flex flex-1 items-start"
ref={
presetsOpenIndex === index
? presetsRef
: null
}
>
<input
type="text"
value={column.name}
onChange={(e) => {
const name = e.target.value;
const preset =
getPresetConfig(name);
updateColumn(index, {
name,
...(preset
? {
prompt: preset.prompt,
format: preset.format,
tags:
preset.tags ??
[],
tagInput: "",
}
: {}),
});
}}
placeholder="Column name"
className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
autoFocus={index === 0}
/>
<button
type="button"
onClick={() =>
setPresetsOpenIndex(
presetsOpenIndex === index
? null
: index,
)
}
title="Column presets"
className="mt-1.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${presetsOpenIndex === index ? "rotate-180" : ""}`}
/>
</button>
{presetsOpenIndex === index && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-64">
<button
type="button"
onClick={() => {
updateColumn(index, { ...EMPTY_DRAFT });
setPresetsOpenIndex(null);
}}
className="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-50 transition-colors border-b border-gray-100"
>
No Preset
</button>
{PROMPT_PRESETS.map(
(preset) => (
<button
key={preset.name}
type="button"
onClick={() => {
updateColumn(
index,
{
name: preset.name,
prompt: preset.prompt,
format: preset.format,
tags:
preset.tags ??
[],
tagInput:
"",
},
);
setPresetsOpenIndex(
null,
);
}}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
{preset.name}
</button>
),
)}
</div>
)}
</div>
{columns.length > 1 && (
<button
type="button"
onClick={() => removeColumn(index)}
className="mt-1.5 rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Format */}
<div className="mt-4">
<label className="text-sm font-medium text-gray-500">
Format
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none">
<span className="flex items-center gap-2">
{(() => {
const Icon = formatIcon(
column.format,
);
return (
<Icon className="h-3.5 w-3.5 text-gray-400" />
);
})()}
{formatLabel(column.format)}
</span>
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[200]"
>
<DropdownMenuRadioGroup
value={column.format}
onValueChange={(v) =>
updateColumn(index, {
format: v as ColumnFormat,
tags: [],
tagInput: "",
})
}
>
{FORMAT_OPTIONS.map((o) => (
<DropdownMenuRadioItem
key={o.value}
value={o.value}
>
<o.icon className="h-3.5 w-3.5 text-gray-400" />
{o.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tag input */}
{column.format === "tag" && (
<div className="mt-3">
<label className="text-sm font-medium text-gray-500">
Tags
</label>
<div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400">
{column.tags.map((tag, tagIdx) => (
<span
key={tag}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
>
{tag}
<button
type="button"
onClick={() =>
updateColumn(
index,
{
tags: column.tags.filter(
(t) =>
t !==
tag,
),
},
)
}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<input
type="text"
value={column.tagInput}
onChange={(e) =>
updateColumn(index, {
tagInput:
e.target.value,
})
}
onKeyDown={(e) =>
handleTagKeyDown(e, index)
}
onBlur={() => commitTag(index)}
placeholder="Add tag…"
className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none"
/>
</div>
<p className="mt-1 text-xs text-gray-400">
Press Enter or comma to add a tag.
</p>
</div>
)}
{/* Prompt */}
<div className="mt-4 flex items-center justify-between">
<label className="text-sm font-medium text-gray-500">
Prompt
</label>
<button
type="button"
onClick={() =>
autoGeneratePrompt(index)
}
disabled={
!column.name.trim() ||
generatingIndices.includes(index)
}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-900 disabled:text-gray-300"
>
{generatingIndices.includes(index) ? (
<span className="h-4 w-4 rounded-full border-2 border-gray-300 border-t-gray-600 animate-spin block" />
) : (
<Plus className="h-4 w-4" />
)}
Auto-Generate Prompt
</button>
</div>
<textarea
rows={6}
value={column.prompt}
onChange={(e) =>
updateColumn(index, {
prompt: e.target.value,
})
}
placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…"
className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed"
/>
</div>
))}
{!isEditing && (
<button
type="button"
onClick={addAnotherColumn}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-900"
>
<Plus className="h-4 w-4" />
Add another column
</button>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<div>
{isEditing && onDelete && (
<button
type="button"
onClick={onDelete}
className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors"
>
Delete
</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={columns.some(
(col) => !col.name.trim() || !col.prompt.trim(),
)}
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"
>
{isEditing ? "Save changes" : "Add columns"}
</button>
</div>
</div>
</form>
</div>
</div>,
document.body,
);
}

View file

@ -0,0 +1,532 @@
"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 {
getProject,
listProjects,
listStandaloneDocuments,
listWorkflows,
uploadProjectDocument,
uploadStandaloneDocument,
} from "@/app/lib/mikeApi";
import { FileDirectory } from "../shared/FileDirectory";
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
interface Props {
open: boolean;
onClose: () => void;
onAdd: (
title: string,
projectId?: string,
documentIds?: string[],
columnsConfig?: MikeWorkflow["columns_config"],
) => void;
projects?: MikeProject[];
/** When provided, skip the project/directory picker and show only these docs */
projectDocs?: MikeDocument[];
projectName?: string;
projectCmNumber?: string | null;
}
export function AddNewTRModal({
open,
onClose,
onAdd,
projects = [],
projectDocs: fixedProjectDocs,
projectName,
projectCmNumber,
}: Props) {
const isProjectMode = fixedProjectDocs !== undefined;
const [title, setTitle] = useState("");
const [underProject, setUnderProject] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState("");
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
// Project-scoped docs (when underProject is true and no fixedProjectDocs)
const [projectDocs, setProjectDocs] = useState<MikeDocument[]>([]);
const [loadingDocs, setLoadingDocs] = useState(false);
// Full directory (when underProject is false)
const [standaloneDocs, setStandaloneDocs] = useState<MikeDocument[]>([]);
const [directoryProjects, setDirectoryProjects] = useState<MikeProject[]>(
[],
);
const [loadingDirectory, setLoadingDirectory] = useState(false);
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
new Set(),
);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Workflow templates
const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const [workflowDropdownOpen, setWorkflowDropdownOpen] = useState(false);
useEffect(() => {
if (!open) return;
setLoadingWorkflows(true);
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
(w) => w.type === "tabular",
);
listWorkflows("tabular")
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
.catch(() => setWorkflows(builtinTabular))
.finally(() => setLoadingWorkflows(false));
if (isProjectMode) {
setSelectedDocIds(
new Set((fixedProjectDocs ?? []).map((d) => d.id)),
);
return;
}
setLoadingDirectory(true);
// /projects only returns counts, not the documents array — fetch
// each project in parallel so FileDirectory can render the docs
// when the user expands a folder.
Promise.all([listStandaloneDocuments(), listProjects()])
.then(async ([docs, projs]) => {
setStandaloneDocs(
[...docs].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
);
const fullProjects = await Promise.all(
projs.map((p) => getProject(p.id)),
);
setDirectoryProjects(fullProjects);
})
.catch(() => {
setStandaloneDocs([]);
setDirectoryProjects([]);
})
.finally(() => setLoadingDirectory(false));
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null;
function handleClose() {
setTitle("");
setUnderProject(false);
setSelectedProjectId("");
setProjectDropdownOpen(false);
setProjectDocs([]);
setStandaloneDocs([]);
setDirectoryProjects([]);
setSelectedDocIds(new Set());
setSelectedWorkflowId(null);
setWorkflowDropdownOpen(false);
onClose();
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
if (underProject && !selectedProjectId) return;
const selectedWorkflow = workflows.find(
(w) => w.id === selectedWorkflowId,
);
onAdd(
title.trim(),
underProject ? selectedProjectId : undefined,
selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
selectedWorkflow?.columns_config ?? undefined,
);
handleClose();
}
async function handleSelectProject(projectId: string) {
setSelectedProjectId(projectId);
setProjectDropdownOpen(false);
setProjectDocs([]);
setSelectedDocIds(new Set());
setLoadingDocs(true);
try {
const proj = await getProject(projectId);
const docs = (proj.documents ?? []).filter(
(d) => d.status === "ready",
);
setProjectDocs(docs);
setSelectedDocIds(new Set(docs.map((d) => d.id)));
} finally {
setLoadingDocs(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) =>
underProject && selectedProjectId
? uploadProjectDocument(selectedProjectId, f)
: uploadStandaloneDocument(f),
),
);
if (underProject && selectedProjectId) {
setProjectDocs((prev) => [...uploaded, ...prev]);
} else {
setStandaloneDocs((prev) => [...uploaded, ...prev]);
}
uploaded.forEach((d) =>
setSelectedDocIds((prev) => new Set([...prev, d.id])),
);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const selectedWorkflow = workflows.find((w) => w.id === selectedWorkflowId);
// What to show in the directory depends on mode and toggle state
const directoryStandalone = isProjectMode
? (fixedProjectDocs ?? [])
: underProject
? []
: standaloneDocs;
const directoryFolders = isProjectMode
? []
: underProject
? []
: directoryProjects;
const flatProjectDocs: MikeDocument[] =
!isProjectMode && underProject ? projectDocs : [];
const directoryLoading = isProjectMode
? false
: underProject
? loadingDocs
: loadingDirectory;
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
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
/>
{/* Workflow template */}
<div className="space-y-2">
<p className="text-xs font-medium text-gray-700">
Workflow Template
</p>
<div className="relative">
<button
type="button"
onClick={() =>
setWorkflowDropdownOpen((o) => !o)
}
disabled={loadingWorkflows}
className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
{loadingWorkflows && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
)}
<span
className={
selectedWorkflow
? "text-gray-800 truncate"
: "text-gray-400"
}
>
{loadingWorkflows
? "Loading templates…"
: selectedWorkflow
? selectedWorkflow.title
: "No template — start from scratch"}
</span>
</div>
<ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0 ml-2" />
</button>
{workflowDropdownOpen && !loadingWorkflows && (
<div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-52">
<button
type="button"
onClick={() => {
setSelectedWorkflowId(null);
setWorkflowDropdownOpen(false);
}}
className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${!selectedWorkflowId ? "bg-gray-50 text-gray-900" : "text-gray-500"}`}
>
<span className="flex-1">
No template start from scratch
</span>
{!selectedWorkflowId && (
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
)}
</button>
{workflows.length > 0 && (
<div className="border-t border-gray-100" />
)}
{workflows.map((wf) => (
<button
key={wf.id}
type="button"
onClick={() => {
setSelectedWorkflowId(
wf.id,
);
setWorkflowDropdownOpen(
false,
);
}}
className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedWorkflowId === wf.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`}
>
<span className="flex-1 truncate">
{wf.title}
</span>
{selectedWorkflowId ===
wf.id && (
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
)}
</button>
))}
</div>
)}
</div>
</div>
{/* Create under a project toggle */}
{!isProjectMode && <div className="space-y-3">
<button
type="button"
onClick={() => {
const next = !underProject;
setUnderProject(next);
if (!next) {
setSelectedProjectId("");
setProjectDropdownOpen(false);
setProjectDocs([]);
setSelectedDocIds(new Set());
}
}}
className="flex items-center gap-2.5 w-fit"
>
<span
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-200"}`}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${underProject ? "translate-x-4" : "translate-x-0"}`}
/>
</span>
<span className="text-sm text-gray-600">
Create under a project
</span>
</button>
{underProject && (
<div className="relative">
<button
type="button"
onClick={() =>
setProjectDropdownOpen((o) => !o)
}
className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors"
>
<span
className={
selectedProject
? "text-gray-800"
: "text-gray-400"
}
>
{selectedProject
? selectedProject.name +
(selectedProject.cm_number
? ` (#${selectedProject.cm_number})`
: "")
: "Select project…"}
</span>
<ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
</button>
{projectDropdownOpen && (
<div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-48">
{projects.length === 0 ? (
<p className="px-3 py-2 text-xs text-gray-400">
No projects found
</p>
) : (
projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() =>
handleSelectProject(
p.id,
)
}
className={`w-full text-left flex items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedProjectId === p.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`}
>
<span className="truncate">
{p.name}
{p.cm_number && (
<span className="ml-1 text-gray-400">
(#
{
p.cm_number
}
)
</span>
)}
</span>
{selectedProjectId ===
p.id && (
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
)}
</button>
))
)}
</div>
)}
</div>
)}
</div>}
{/* File directory */}
{showDirectory && (
<div className="space-y-2">
<p className="text-xs font-medium text-gray-700">
Select Documents
</p>
<div>
<FileDirectory
standaloneDocs={
isProjectMode
? directoryStandalone
: underProject
? flatProjectDocs
: directoryStandalone
}
directoryProjects={
isProjectMode
? []
: underProject
? []
: directoryFolders
}
loading={directoryLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
heading={isProjectMode ? "Project Documents" : "Documents"}
emptyMessage={
isProjectMode || underProject
? "No ready documents in this project"
: "No documents yet"
}
/>
</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,
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,307 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Loader2, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
import { TAG_COLORS } from "./pillUtils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface TREditColumnMenuProps {
column: ColumnConfig;
disabled?: boolean;
onSave: (column: ColumnConfig) => void | Promise<void>;
onDelete: (columnIndex: number) => void | Promise<void>;
}
export function TREditColumnMenu({
column,
disabled,
onSave,
onDelete,
}: TREditColumnMenuProps) {
const [open, setOpen] = useState(false);
const [name, setName] = useState(column.name);
const [prompt, setPrompt] = useState(column.prompt);
const [format, setFormat] = useState<ColumnFormat>(column.format ?? "text");
const [tags, setTags] = useState<string[]>(column.tags ?? []);
const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
if (!open) {
setName(column.name);
setPrompt(column.prompt);
setFormat(column.format ?? "text");
setTags(column.tags ?? []);
setTagInput("");
}
}, [column.name, column.prompt, column.format, column.tags, open]);
function commitTag() {
const tag = tagInput.trim();
if (!tag) {
setTagInput("");
return;
}
setTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
setTagInput("");
}
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commitTag();
} else if (
e.key === "Backspace" &&
tagInput === "" &&
tags.length > 0
) {
setTags((prev) => prev.slice(0, -1));
}
}
async function handleSave() {
setSaving(true);
try {
await onSave({
...column,
name: name.trim(),
prompt: prompt.trim(),
format,
tags: format === "tag" ? tags : undefined,
});
setOpen(false);
} finally {
setSaving(false);
}
}
console.log(tags);
async function handleDelete() {
setDeleting(true);
try {
await onDelete(column.index);
setOpen(false);
} finally {
setDeleting(false);
}
}
async function handleAutoGenerate() {
if (!name.trim()) return;
setGenerating(true);
try {
const { prompt } = await generateTabularColumnPrompt(name.trim(), {
format,
tags: format === "tag" ? tags : undefined,
});
setPrompt(prompt);
} finally {
setGenerating(false);
}
}
return (
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => {
e.stopPropagation();
if (disabled) return;
setOpen((v) => !v);
}}
disabled={disabled}
className={`flex h-4 w-4 items-center justify-center rounded transition-colors ${
disabled
? "text-gray-300 cursor-default"
: "text-gray-400 hover:bg-gray-100 hover:text-gray-600"
}`}
>
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div
className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-xl border border-gray-100 bg-white p-3 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-gray-800">
Edit Column
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<label className="text-xs font-medium text-gray-800">
Label
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-gray-800 text-xs font-normal focus:border-gray-400 focus:outline-none"
/>
{/* Format */}
<div className="mt-3">
<label className="text-xs font-medium text-gray-800">
Format
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex w-full items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 hover:border-gray-400 focus:outline-none">
<span className="flex items-center gap-1.5">
{(() => {
const Icon = formatIcon(format);
return (
<Icon className="h-3 w-3 text-gray-400" />
);
})()}
{formatLabel(format)}
</span>
<ChevronDown className="h-3 w-3 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
value={format}
onValueChange={(v) => {
setFormat(v as ColumnFormat);
setTags([]);
setTagInput("");
}}
>
{FORMAT_OPTIONS.map((o) => (
<DropdownMenuRadioItem
key={o.value}
value={o.value}
className="text-xs"
>
<o.icon className="h-3 w-3 text-gray-400" />
{o.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tag input */}
{format === "tag" && (
<div className="mt-2">
<div className="flex flex-wrap gap-1 rounded-md border border-gray-200 px-2 py-1 focus-within:border-gray-400 min-h-[28px]">
{tags.map((tag, tagIdx) => (
<span
key={tag}
className={`inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
>
{tag}
<button
type="button"
onClick={() =>
setTags((prev) =>
prev.filter(
(t) => t !== tag,
),
)
}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-2 w-2" />
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) =>
setTagInput(e.target.value)
}
onKeyDown={handleTagKeyDown}
onBlur={commitTag}
placeholder={
tags.length === 0 ? "Add tags…" : ""
}
className="min-w-[60px] flex-1 bg-transparent text-xs text-gray-700 placeholder-gray-300 focus:outline-none"
/>
</div>
</div>
)}
{/* Prompt */}
<div className="mt-3">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-gray-800">
Prompt
</label>
<button
type="button"
onClick={handleAutoGenerate}
disabled={!name.trim() || generating}
className="inline-flex items-center gap-1 text-xs text-gray-600 transition-colors hover:text-gray-700 disabled:text-gray-300"
>
{generating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plus className="h-3 w-3" />
)}
Auto-generate
</button>
</div>
<textarea
rows={6}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="mt-2 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-normal text-gray-800 placeholder-gray-300 focus:border-gray-400 focus:outline-none resize-none leading-relaxed"
/>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting || saving}
className="inline-flex items-center gap-1.5 text-xs text-red-500 transition-colors hover:text-red-600 disabled:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
type="button"
onClick={handleSave}
disabled={
saving ||
deleting ||
generating ||
!name.trim() ||
!prompt.trim()
}
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 disabled:opacity-40"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,457 @@
"use client";
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Loader2,
RefreshCw,
X,
} from "lucide-react";
import type { ColumnConfig, MikeDocument, 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";
function isDocxDocument(d: {
file_type?: string | null;
filename?: string;
}): boolean {
const ft = (d.file_type ?? "").toLowerCase();
if (ft === "docx" || ft === "doc") return true;
const ext = d.filename?.split(".").pop()?.toLowerCase();
return ext === "docx" || ext === "doc";
}
interface Props {
cell: TabularCell;
document: MikeDocument;
column: ColumnConfig;
columns: ColumnConfig[];
onClose: () => void;
onNavigate: (columnIndex: number) => void;
onRegenerate?: () => Promise<void>;
/** If true, open the document panel immediately */
displayDocument?: boolean;
/** Quote to highlight when opening document panel */
citationQuote?: string;
/** Page to scroll to when opening document panel */
citationPage?: number;
}
const FLAG_BADGE: Record<string, string> = {
green: "bg-emerald-600 backdrop-blur-md border border-emerald-300/20 text-white shadow-md",
grey: "bg-slate-500 backdrop-blur-md border border-slate-300/20 text-white shadow-md",
yellow: "bg-amber-500 backdrop-blur-md border border-amber-300/20 text-white shadow-md",
red: "bg-red-600 backdrop-blur-md border border-red-300/20 text-white shadow-md",
};
// ---------------------------------------------------------------------------
// TRSidePanel
// ---------------------------------------------------------------------------
export function TRSidePanel({
cell,
document: doc,
column,
columns,
onClose,
onNavigate,
onRegenerate,
displayDocument = false,
citationQuote,
citationPage,
}: Props) {
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const currentPos = sortedColumns.findIndex((c) => c.index === column.index);
const prevColumn = currentPos > 0 ? sortedColumns[currentPos - 1] : null;
const nextColumn =
currentPos < sortedColumns.length - 1
? sortedColumns[currentPos + 1]
: null;
const [regenerating, setRegenerating] = useState(false);
const [quoteExpanded, setQuoteExpanded] = useState(false);
const [isTruncated, setIsTruncated] = useState(false);
const quoteParagraphRef = useRef<HTMLParagraphElement>(null);
// Internal state — initialised from props, also toggled by badge clicks inside the panel
const [docCitation, setDocCitation] = useState<
{ quote: string; page: number } | undefined
>(
displayDocument && citationQuote
? { quote: citationQuote, page: citationPage ?? 1 }
: undefined,
);
// Re-sync when the panel opens for a different cell or citation
useEffect(() => {
setDocCitation(
displayDocument && citationQuote
? { quote: citationQuote, page: citationPage ?? 1 }
: undefined,
);
setQuoteExpanded(false);
}, [cell.id, displayDocument, citationQuote, citationPage]);
useEffect(() => {
const el = quoteParagraphRef.current;
if (!el || quoteExpanded) return;
setIsTruncated(el.scrollWidth > el.clientWidth);
}, [docCitation?.quote, quoteExpanded]);
const { processed: summaryText, citations: summaryCitations } =
preprocessCitations(cell.content?.summary ?? "");
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%)",
}}
>
{/* Document panel — left, 600px */}
{docCitation !== undefined && (
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3">
{/* Doc header */}
<div className="flex items-center gap-2 pt-3 shrink-0 border-b border-white/30">
<p
className="flex-1 truncate text-sm font-semibold font-sans text-slate-700 font-serif"
title={doc.filename}
>
{doc.filename}
</p>
<button
onClick={() => setDocCitation(undefined)}
className="shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-white/40 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Quote row */}
{docCitation.quote && (
<div className="py-2 shrink-0">
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
<button
onClick={() =>
isTruncated || quoteExpanded
? setQuoteExpanded((v) => !v)
: undefined
}
className={`flex w-full items-start gap-1 text-left ${!(isTruncated || quoteExpanded) ? "cursor-default" : ""}`}
>
<p
ref={quoteParagraphRef}
className={`flex-1 text-sm text-gray-600 ${quoteExpanded ? "" : "truncate"}`}
>
"{docCitation.quote}"
</p>
{(isTruncated || quoteExpanded) && (
<ChevronDown
className={`mt-0.5 h-3 w-3 shrink-0 text-gray-500 transition-transform ${quoteExpanded ? "rotate-180" : ""}`}
/>
)}
</button>
</div>
</div>
)}
{isDocxDocument(doc) && !doc.pdf_storage_path ? (
<DocxView
documentId={doc.id}
quotes={[
{
page: docCitation.page,
quote: docCitation.quote,
},
]}
/>
) : (
<DocView
doc={{ document_id: doc.id }}
quote={docCitation.quote}
fallbackPage={docCitation.page}
/>
)}
</div>
)}
{/* Info column — right, 300px fixed */}
<div className="flex w-[300px] shrink-0 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-end gap-3 px-5 pt-3 pb-1 shrink-0 border-b border-white/30">
<div className="flex items-center gap-1 mr-auto">
<button
onClick={() =>
prevColumn && onNavigate(prevColumn.index)
}
disabled={!prevColumn}
title={prevColumn ? prevColumn.name : undefined}
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs text-slate-600 font-sans tabular-nums">
{currentPos + 1} / {sortedColumns.length}
</span>
<button
onClick={() =>
nextColumn && onNavigate(nextColumn.index)
}
disabled={!nextColumn}
title={nextColumn ? nextColumn.name : undefined}
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{onRegenerate && (
<button
onClick={async () => {
setRegenerating(true);
try {
await onRegenerate();
} finally {
setRegenerating(false);
}
}}
disabled={regenerating}
title="Regenerate"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 disabled:opacity-40"
>
{regenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
)}
<button
onClick={onClose}
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Analysis panel */}
<div className="flex-1 overflow-y-auto">
<div className="pb-2 px-5">
{/* Column name */}
<div className="mb-1">
<span className="text-lg font-semibold text-slate-900">
{column.name}
</span>
</div>
{/* Document name */}
<p className="text-xs mb-4">{doc.filename}</p>
{/* Flag section */}
{cell.content?.flag && (
<div className="mb-5">
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
Flag
</h4>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold ${FLAG_BADGE[cell.content.flag] ?? FLAG_BADGE.grey}`}
>
{cell.content.flag.charAt(0).toUpperCase() +
cell.content.flag.slice(1)}
</span>
</div>
)}
{/* Results */}
<div className="mb-6">
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
Results
</h4>
<div className="text-xs leading-relaxed text-slate-600">
<MarkdownContent
citations={summaryCitations}
onCitationClick={setDocCitation}
column={column}
>
{summaryText || "—"}
</MarkdownContent>
</div>
</div>
{/* Reasoning */}
{cell.content?.reasoning && (
<div>
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
Reasoning
</h4>
<div className="text-xs leading-relaxed text-slate-600">
<MarkdownContent
citations={reasoningCitations}
onCitationClick={setDocCitation}
citationOffset={summaryCitations.length}
column={column}
inline
>
{reasoningText}
</MarkdownContent>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Markdown renderer
// ---------------------------------------------------------------------------
function CitationBadge({
index,
citation,
onClick,
}: {
index: number;
citation: ParsedCitation;
onClick: (c: { quote: string; page: number }) => void;
}) {
return (
<button
type="button"
data-page={citation.page}
data-quote={citation.quote}
title={`Page ${citation.page}: "${citation.quote}"`}
onClick={() =>
onClick({ quote: citation.quote, page: citation.page })
}
className="inline-flex items-center justify-center rounded-full bg-gray-200 w-3.5 h-3.5 text-[9px] font-medium text-gray-700 align-super cursor-pointer hover:bg-gray-300 transition-colors"
>
{index + 1}
</button>
);
}
function MarkdownContent({
children,
citations,
onCitationClick,
citationOffset = 0,
column,
inline,
}: {
children: string;
citations: ParsedCitation[];
onCitationClick: (c: { quote: string; page: number }) => void;
inline?: boolean;
citationOffset?: number;
column?: ColumnConfig;
}) {
if (!children) return null;
const pills: string[] = [];
let processed = children.replace(/\[\[([^\]]+)\]\]/g, (_, content) => {
const idx = pills.length;
pills.push(content);
return `\`§p${idx}§\``;
});
processed = processed.replace(/§(\d+)§/g, (_, idx) => `\`§c${idx}§\``);
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) =>
inline ? (
<span {...props} />
) : (
<p
className="mb-1.5 last:mb-0 leading-relaxed"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
className="list-disc pl-4 space-y-0.5 mb-1.5 last:mb-0"
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
className="list-decimal pl-4 space-y-0.5 mb-1.5 last:mb-0"
{...props}
/>
),
li: ({ node, ...props }) => <li {...props} />,
strong: ({ node, ...props }) => (
<strong className="font-semibold" {...props} />
),
em: ({ node, ...props }) => (
<em className="italic" {...props} />
),
a: ({ node, href, children, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 underline"
{...props}
>
{children}
</a>
),
code: ({ node, children: codeChildren, ...props }) => {
const t = String(codeChildren);
const citMatch = t.match(/^§c(\d+)§$/);
if (citMatch) {
const idx = parseInt(citMatch[1]);
const citation = citations[idx];
if (citation) {
return (
<CitationBadge
index={citationOffset + idx}
citation={citation}
onClick={onCitationClick}
/>
);
}
}
const pillMatch = t.match(/^§p(\d+)§$/);
if (pillMatch) {
const content = pills[parseInt(pillMatch[1])];
if (content !== undefined) {
return (
<span
className={`inline-block rounded-full px-1.5 py-0.5 text-[11px] font-medium leading-none ${getPillClass(content, column)}`}
>
{content}
</span>
);
}
}
return (
<code
className="bg-gray-100 px-1 py-0.5 rounded text-[11px] font-mono"
{...props}
>
{codeChildren}
</code>
);
},
}}
>
{processed}
</ReactMarkdown>
);
}

View file

@ -0,0 +1,326 @@
"use client";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { Plus, Table2 } from "lucide-react";
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
import { TabularCell as TabularCellComponent } from "./TabularCell";
import { TREditColumnMenu } from "./TREditColumnMenu";
const SKELETON_COLS = 4;
const SKELETON_ROWS = 5;
const COL_W = "w-[300px] shrink-0";
const CHECK_W = "w-8 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 DATA_COL_W_PX = 300;
const STICKY_LEFT_PX = CHECK_W_PX + DOC_COL_W_PX; // 332px
export interface TRTableHandle {
scrollToCell: (colIdx: number, rowIdx: number) => void;
}
interface Props {
loading: boolean;
columns: ColumnConfig[];
documents: MikeDocument[];
cells: TabularCell[];
savingColumn: boolean;
savingColumnsConfig: boolean;
selectedDocIds: string[];
highlightedCell?: { colIdx: number; rowIdx: number } | null;
onSelectionChange: (ids: string[]) => void;
onExpand: (cell: TabularCell) => void;
onCitationClick: (cell: TabularCell, page: number, quote: string) => void;
onUpdateColumn: (col: ColumnConfig) => void;
onDeleteColumn: (colIndex: number) => void;
onAddColumn: () => void;
onAddDocuments: () => void;
}
export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{
loading,
columns,
documents,
cells,
savingColumn,
savingColumnsConfig,
selectedDocIds,
highlightedCell,
onSelectionChange,
onExpand,
onCitationClick,
onUpdateColumn,
onDeleteColumn,
onAddColumn,
onAddDocuments,
},
ref,
) {
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;
useImperativeHandle(ref, () => ({
scrollToCell(colIdx: number, rowIdx: number) {
const container = scrollContainerRef.current;
if (!container) return;
// Vertical: find actual row via DOM (handles variable row heights)
const allRows = container.querySelectorAll<HTMLElement>(
":scope > div.flex.min-w-full",
);
const targetRow = allRows[rowIdx];
if (targetRow) {
container.scrollTo({
top: Math.max(0, targetRow.offsetTop - 40),
behavior: "smooth",
});
}
// Horizontal: fixed column widths — center the target column in view
const targetScrollLeft =
STICKY_LEFT_PX +
colIdx * DATA_COL_W_PX -
container.clientWidth / 2 +
DATA_COL_W_PX / 2;
container.scrollLeft = Math.max(0, targetScrollLeft);
},
}));
function getCell(docId: string, colIdx: number) {
return cells.find(
(c) => c.document_id === docId && c.column_index === colIdx,
);
}
const allSelected =
documents.length > 0 &&
documents.every((d) => selectedDocIds.includes(d.id));
const someSelected =
!allSelected && documents.some((d) => selectedDocIds.includes(d.id));
function toggleAll() {
if (allSelected) {
onSelectionChange([]);
} else {
onSelectionChange(documents.map((d) => d.id));
}
}
function toggleDoc(id: string) {
if (selectedDocIds.includes(id)) {
onSelectionChange(selectedDocIds.filter((x) => x !== id));
} else {
onSelectionChange([...selectedDocIds, id]);
}
}
if (loading) {
return (
<div className="flex-1 overflow-hidden">
{/* 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`}
>
Document
</div>
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
<div
key={i}
className={`${COL_W} border-r border-gray-200 p-2`}
>
<div className="h-4 w-28 rounded bg-gray-100 animate-pulse" />
</div>
))}
<div className="flex-1" />
</div>
{/* Rows */}
{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"}`}
>
<div className={`${CHECK_W} p-2`} />
<div className={`${COL_W} p-2`}>
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
</div>
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
<div key={col} className={`${COL_W} p-2`}>
<div className="h-4 rounded bg-gray-100 animate-pulse" />
</div>
))}
<div className="flex-1" />
</div>
))}
</div>
);
}
if (columns.length === 0 && documents.length === 0) {
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`}
>
Document
</div>
<div className="flex-1" />
</div>
<div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto">
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Tabular Review
</p>
<p className="mt-1 text-xs text-gray-400 text-left">
Add columns and documents to get started.
</p>
<div className="mt-4 flex items-center gap-2">
<button
onClick={onAddColumn}
className="inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 shadow-md"
>
+ Add Columns
</button>
<button
onClick={onAddDocuments}
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors shadow-sm"
>
<Plus className="h-3.5 w-3.5" />
Add Documents
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-auto" ref={scrollContainerRef}>
{/* Header */}
<div
className="sticky top-0 z-20 flex bg-white h-8"
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`}
>
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
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
</div>
{columns.map((col) => (
<div
key={col.index}
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
>
<div className="flex items-center justify-between gap-3">
<span className="truncate">{col.name}</span>
<TREditColumnMenu
column={col}
disabled={savingColumn || savingColumnsConfig}
onSave={onUpdateColumn}
onDelete={onDeleteColumn}
/>
</div>
</div>
))}
<div className="flex-1 border-b border-gray-200 flex items-center justify-start p-2 min-w-8">
<button
onClick={onAddColumn}
disabled={savingColumn || savingColumnsConfig}
className="flex items-center justify-center text-gray-400 hover:text-gray-700 transition-colors disabled:text-gray-200"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{/* Rows */}
{documents.map((doc, docIdx) => {
const rowBg = selectedDocIds.includes(doc.id)
? "bg-gray-100"
: docIdx % 2 === 0
? "bg-white"
: "bg-gray-50";
return (
<div
key={doc.id}
className={`flex ${rowBg}`}
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}`}
>
<input
type="checkbox"
checked={selectedDocIds.includes(doc.id)}
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 ${rowBg}`}
>
<span className="line-clamp-1" title={doc.filename}>
{doc.filename}
</span>
</div>
{columns.map((col) => {
const cell = getCell(doc.id, col.index);
const colPos = sortedColumns.findIndex(
(c) => c.index === col.index,
);
const isHighlighted =
highlightedCell?.colIdx === colPos &&
highlightedCell?.rowIdx === docIdx;
return (
<div
key={col.index}
className={`${COL_W} border-b border-r border-gray-200 transition-colors ${isHighlighted ? "bg-blue-200" : ""}`}
>
{cell && (
<TabularCellComponent
cell={cell}
column={col}
onExpand={() => onExpand(cell)}
onCitationClick={(page, quote) =>
onCitationClick(
cell,
page,
quote,
)
}
/>
)}
</div>
);
})}
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
</div>
);
})}
</div>
);
});

View file

@ -0,0 +1,268 @@
"use client";
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { AlertCircle, Expand } from "lucide-react";
import type { ColumnConfig, TabularCell as TCell } from "../shared/types";
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
import { getPillClass } from "./pillUtils";
interface Props {
cell: TCell;
column?: ColumnConfig;
onExpand: () => void;
onCitationClick?: (page: number, quote: string) => void;
}
const FLAG_STYLES = {
green: "bg-green-500",
grey: "bg-gray-400",
yellow: "bg-amber-400",
red: "bg-red-500",
} as const;
// Replace citations and pills with inline-code tokens so ReactMarkdown passes
// them through its `code` component, where we render the final UI.
function preprocessCellMarkdown(text: string): {
processed: string;
citations: ParsedCitation[];
pills: string[];
} {
const { processed: withCits, citations } = preprocessCitations(text);
const pills: string[] = [];
let out = withCits.replace(/\[\[([^\]]+)\]\]/g, (_, content) => {
const idx = pills.length;
pills.push(content);
return `\`§p${idx}§\`\u200B`;
});
out = out.replace(/§(\d+)§/g, (_, idx) => `\`§c${idx}§\`\u200B`);
return { processed: out, citations, pills };
}
function CellMarkdown({
text,
citations,
pills,
column,
onCitationClick,
onExpand,
inline,
}: {
text: string;
citations: ParsedCitation[];
pills: string[];
column?: ColumnConfig;
onCitationClick?: (page: number, quote: string) => void;
onExpand: () => void;
inline?: boolean;
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) =>
inline ? (
<span {...props} />
) : (
<p className="mb-1 last:mb-0 leading-relaxed" {...props} />
),
ul: ({ node, ...props }) => (
<ul className="list-disc pl-4 space-y-0.5" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal pl-4 space-y-0.5" {...props} />
),
li: ({ node, ...props }) => <li {...props} />,
strong: ({ node, ...props }) => (
<strong className="font-semibold" {...props} />
),
em: ({ node, ...props }) => <em className="italic" {...props} />,
a: ({ node, href, children, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 underline"
{...props}
>
{children}
</a>
),
code: ({ node, children, ...props }) => {
const t = String(children);
const citMatch = t.match(/^§c(\d+)§$/);
if (citMatch) {
const idx = parseInt(citMatch[1]);
const citation = citations[idx];
if (citation) {
return (
<span
title={`Page ${citation.page}: "${citation.quote}"`}
onClick={(e) => {
e.stopPropagation();
if (onCitationClick) {
onCitationClick(
citation.page,
citation.quote,
);
} else {
onExpand();
}
}}
className="mx-0.5 inline-flex items-center justify-center rounded-full bg-gray-200 w-3.5 h-3.5 text-[9px] font-medium text-gray-700 align-super cursor-pointer hover:bg-gray-300 transition-colors"
>
{idx + 1}
</span>
);
}
}
const pillMatch = t.match(/^§p(\d+)§$/);
if (pillMatch) {
const content = pills[parseInt(pillMatch[1])];
if (content !== undefined) {
return (
<span
className={`inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none ${getPillClass(content, column)}`}
>
{content}
</span>
);
}
}
return (
<code
className="bg-gray-100 px-1 py-0.5 rounded text-[11px] font-mono"
{...props}
>
{children}
</code>
);
},
}}
>
{text}
</ReactMarkdown>
);
}
export function TabularCell({
cell,
column,
onExpand,
onCitationClick,
}: Props) {
const [inlineExpanded, setInlineExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!inlineExpanded) return;
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setInlineExpanded(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [inlineExpanded]);
if (cell.status === "generating") {
return (
<div className="h-10 px-2 flex items-center">
<div className="h-4 w-full rounded bg-gray-100 animate-pulse" />
</div>
);
}
if (cell.status === "error") {
return (
<div className="h-10 flex items-center justify-center text-gray-300">
<AlertCircle className="h-4 w-4 text-red-300" />
</div>
);
}
if (!cell.content?.summary) {
return <div className="h-10" />;
}
const { processed, citations, pills } = preprocessCellMarkdown(
cell.content.summary,
);
const firstLine = processed.split("\n").find((l) => l.trim()) ?? processed;
const collapsedDisplay = firstLine.replace(/^[-*•]\s+/, "");
function handleCitationClickInOverlay(page: number, quote: string) {
setInlineExpanded(false);
onCitationClick?.(page, quote);
}
function handleSeeDetails() {
setInlineExpanded(false);
onExpand();
}
return (
<div ref={containerRef} className="relative">
{/* Normal cell row — always visible, preserves table layout */}
<div
className="group relative h-10 px-2 flex items-center text-xs text-gray-800 leading-relaxed cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setInlineExpanded((v) => !v)}
>
{cell.content.flag && (
<span
className={`absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full ${FLAG_STYLES[cell.content.flag]}`}
title={cell.content.flag}
/>
)}
<div className="line-clamp-1 w-full min-w-0">
<CellMarkdown
text={collapsedDisplay}
citations={citations}
pills={pills}
column={column}
onCitationClick={onCitationClick}
onExpand={onExpand}
inline
/>
</div>
</div>
{/* Inline expanded overlay — absolutely positioned so it overlays without disrupting table layout */}
{inlineExpanded && (
<div className="absolute left-0 top-0 z-50 w-full bg-white border border-gray-200 shadow-lg rounded-sm">
<div className="relative p-2 pr-4 text-xs text-gray-800 leading-relaxed">
{cell.content.flag && (
<span
className={`absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full ${FLAG_STYLES[cell.content.flag]}`}
title={cell.content.flag}
/>
)}
<CellMarkdown
text={processed}
citations={citations}
pills={pills}
column={column}
onCitationClick={handleCitationClickInOverlay}
onExpand={handleSeeDetails}
/>
</div>
<div className="px-2 py-1.5 flex items-center justify-end">
<button
onClick={handleSeeDetails}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-700 transition-colors"
>
<Expand className="h-3 w-3" />
See details
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,853 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users } from "lucide-react";
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
import {
clearTabularCells,
getTabularReview,
getProject,
getTabularReviewPeople,
regenerateTabularCell,
streamTabularGeneration,
updateTabularReview,
} from "@/app/lib/mikeApi";
import type {
ColumnConfig,
MikeDocument,
MikeProject,
TabularCell,
TabularReview,
} from "../shared/types";
import { AddColumnModal } from "./AddColumnModal";
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
import { PeopleModal } from "../shared/PeopleModal";
import { OwnerOnlyModal } from "../shared/OwnerOnlyModal";
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
import { RenameableTitle } from "../shared/RenameableTitle";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import {
getModelProvider,
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import { TRSidePanel } from "./TRSidePanel";
import { TRTable } from "./TRTable";
import type { TRTableHandle } from "./TRTable";
import { TRChatPanel } from "./TRChatPanel";
import { exportTabularReviewToExcel } from "./exportToExcel";
import { useSidebar } from "@/app/contexts/SidebarContext";
interface Props {
reviewId: string;
projectId?: string;
}
export function TRView({ reviewId, projectId }: Props) {
const { setSidebarOpen } = useSidebar();
const [review, setReview] = useState<TabularReview | null>(null);
const [project, setProject] = useState<MikeProject | null>(null);
const [cells, setCells] = useState<TabularCell[]>([]);
const [documents, setDocuments] = useState<MikeDocument[]>([]);
const [columns, setColumns] = useState<ColumnConfig[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [savingColumn, setSavingColumn] = useState(false);
const [savingColumnsConfig, setSavingColumnsConfig] = useState(false);
const [addColOpen, setAddColOpen] = useState(false);
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const { user } = useAuth();
const [expandedCell, setExpandedCell] = useState<TabularCell | null>(null);
const [expandedCellCitation, setExpandedCellCitation] = useState<
{ quote: string; page: number } | undefined
>(undefined);
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [search, setSearch] = useState("");
const searchParams = useSearchParams();
const initialChatParamRef = useRef<string | null>(
searchParams.get("chat"),
);
const [chatOpen, setChatOpen] = useState(!!initialChatParamRef.current);
const [selectedChatId, setSelectedChatId] = useState<string | null>(
initialChatParamRef.current && initialChatParamRef.current !== "new"
? initialChatParamRef.current
: null,
);
const [highlightedCell, setHighlightedCell] = useState<{ colIdx: number; rowIdx: number } | null>(null);
const [apiKeyModalProvider, setApiKeyModalProvider] =
useState<ModelProvider | null>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<TRTableHandle>(null);
const router = useRouter();
const { profile } = useUserProfile();
const apiKeys = {
claudeApiKey: profile?.claudeApiKey ?? null,
geminiApiKey: profile?.geminiApiKey ?? null,
};
const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview";
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (chatOpen) {
params.set("chat", selectedChatId ?? "new");
} else {
params.delete("chat");
}
const query = params.toString();
const newUrl = `${window.location.pathname}${query ? `?${query}` : ""}`;
window.history.replaceState(null, "", newUrl);
}, [chatOpen, selectedChatId]);
useEffect(() => {
if (!actionsOpen) return;
function handleClickOutside(e: MouseEvent) {
if (
actionsRef.current &&
!actionsRef.current.contains(e.target as Node)
)
setActionsOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [actionsOpen]);
useEffect(() => {
const fetches: Promise<unknown>[] = [
getTabularReview(reviewId).then(({ review, cells, documents }) => {
setReview(review);
setCells(cells);
setDocuments(documents);
setColumns(review.columns_config || []);
}),
];
if (projectId) {
fetches.push(
getProject(projectId)
.then(setProject)
.catch(() => {}),
);
}
Promise.all(fetches).finally(() => setLoading(false));
}, [reviewId, projectId]);
function getNextColumnIndex() {
return (
columns.reduce((max, column) => Math.max(max, column.index), -1) + 1
);
}
async function saveColumnsConfig(nextColumns: ColumnConfig[]) {
setSavingColumnsConfig(true);
try {
const updated = await updateTabularReview(reviewId, {
columns_config: nextColumns,
document_ids: documents.map((document) => document.id),
});
setReview(updated);
setColumns(updated.columns_config || nextColumns);
} finally {
setSavingColumnsConfig(false);
}
}
async function handleAddDocuments(newDocs: MikeDocument[]) {
const toAdd = newDocs.filter(
(d) => !documents.some((existing) => existing.id === d.id),
);
if (!toAdd.length) return;
const allIds = [
...documents.map((d) => d.id),
...toAdd.map((d) => d.id),
];
await updateTabularReview(reviewId, {
document_ids: allIds,
columns_config: columns,
});
setDocuments((prev) => [...prev, ...toAdd]);
if (columns.length > 0) {
setCells((prev) => [
...prev,
...toAdd.flatMap((doc) =>
columns.map((col) => ({
id: `new-${doc.id}-${col.index}`,
review_id: reviewId,
document_id: doc.id,
column_index: col.index,
content: null,
status: "pending" as const,
created_at: new Date().toISOString(),
})),
),
]);
}
}
async function handleRegenerateCell(docId: string, colIndex: number) {
setCells((prev) =>
prev.map((c) =>
c.document_id === docId && c.column_index === colIndex
? { ...c, status: "generating" as const, content: null }
: c,
),
);
setExpandedCell((prev) =>
prev
? { ...prev, status: "generating" as const, content: null }
: null,
);
try {
const result = await regenerateTabularCell(
reviewId,
docId,
colIndex,
);
setCells((prev) =>
prev.map((c) =>
c.document_id === docId && c.column_index === colIndex
? { ...c, status: "done" as const, content: result }
: c,
),
);
setExpandedCell((prev) =>
prev
? { ...prev, status: "done" as const, content: result }
: null,
);
} catch (err) {
console.error("Regeneration failed", err);
setCells((prev) =>
prev.map((c) =>
c.document_id === docId && c.column_index === colIndex
? { ...c, status: "error" as const }
: c,
),
);
setExpandedCell((prev) =>
prev ? { ...prev, status: "error" as const } : null,
);
}
}
async function handleGenerate() {
if (!review || generating) return;
// If columns changed since last save, update the review first
if (columns.length === 0) return;
if (!isModelAvailable(tabularModel, apiKeys)) {
setApiKeyModalProvider(getModelProvider(tabularModel));
return;
}
setGenerating(true);
// Optimistically set empty/pending/error cells to generating (skip done cells)
setCells((prev) =>
documents.flatMap((doc) =>
columns.map((col) => {
const existing = prev.find(
(c) =>
c.document_id === doc.id &&
c.column_index === col.index,
);
if (existing?.status === "done" && existing?.content) {
return existing;
}
return existing
? {
...existing,
status: "generating" as const,
content: null,
}
: {
id: `${doc.id}-${col.index}`,
review_id: reviewId,
document_id: doc.id,
column_index: col.index,
content: null,
status: "generating" as const,
created_at: new Date().toISOString(),
};
}),
),
);
try {
const response = await streamTabularGeneration(reviewId);
if (!response.body) throw new Error("No body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const dataStr = line.slice(5).trim();
if (dataStr === "[DONE]") break;
try {
const data = JSON.parse(dataStr);
if (data.type === "cell_update") {
setCells((prev) =>
prev.map((c) =>
c.document_id === data.document_id &&
c.column_index === data.column_index
? {
...c,
content: data.content,
status: data.status,
}
: c,
),
);
}
} catch {}
}
}
} catch (err) {
console.error("Generation failed", err);
} finally {
setGenerating(false);
}
}
async function handleAddColumn(newColumns: ColumnConfig[]) {
const startIndex = getNextColumnIndex();
const normalizedColumns = newColumns.map((column, index) => ({
...column,
index: startIndex + index,
}));
const newCols = [...columns, ...normalizedColumns];
setSavingColumn(true);
setColumns(newCols);
setCells((prev) => [
...prev,
...documents
.filter((doc) =>
normalizedColumns.some(
(column) =>
!prev.some(
(cell) =>
cell.document_id === doc.id &&
cell.column_index === column.index,
),
),
)
.flatMap((doc) =>
normalizedColumns
.filter(
(column) =>
!prev.some(
(cell) =>
cell.document_id === doc.id &&
cell.column_index === column.index,
),
)
.map((column) => ({
id: `new-${doc.id}-${column.index}`,
review_id: reviewId,
document_id: doc.id,
column_index: column.index,
content: null,
status: "pending" as const,
created_at: new Date().toISOString(),
})),
),
]);
try {
await saveColumnsConfig(newCols);
} catch (err) {
setColumns(columns);
setCells((prev) =>
prev.filter(
(cell) =>
!normalizedColumns.some(
(column) => column.index === cell.column_index,
),
),
);
console.error("Failed to save column", err);
} finally {
setSavingColumn(false);
}
}
async function handleUpdateColumn(nextColumn: ColumnConfig) {
const nextColumns = columns.map((column) =>
column.index === nextColumn.index ? nextColumn : column,
);
const previousColumns = columns;
setColumns(nextColumns);
try {
await saveColumnsConfig(nextColumns);
} catch (err) {
setColumns(previousColumns);
console.error("Failed to update column", err);
}
}
async function handleDeleteColumn(columnIndex: number) {
const previousColumns = columns;
const nextColumns = columns.filter(
(column) => column.index !== columnIndex,
);
setColumns(nextColumns);
try {
await saveColumnsConfig(nextColumns);
} catch (err) {
setColumns(previousColumns);
console.error("Failed to delete column", err);
}
}
function handleTabularCitationClick(colIdx: number, rowIdx: number) {
setSearch("");
setHighlightedCell({ colIdx, rowIdx });
setTimeout(() => {
tableRef.current?.scrollToCell(colIdx, rowIdx);
}, 50);
setTimeout(() => setHighlightedCell(null), 3000);
}
async function handleDeleteDocuments() {
const remaining = documents.filter(
(d) => !selectedDocIds.includes(d.id),
);
setDocuments(remaining);
setCells((prev) =>
prev.filter((c) => !selectedDocIds.includes(c.document_id)),
);
setSelectedDocIds([]);
setActionsOpen(false);
await updateTabularReview(reviewId, {
document_ids: remaining.map((d) => d.id),
columns_config: columns,
});
}
async function handleClearResults() {
const docIds = [...selectedDocIds];
if (docIds.length === 0) return;
setCells((prev) =>
prev.map((c) =>
docIds.includes(c.document_id)
? { ...c, content: null, status: "pending" }
: c,
),
);
setSelectedDocIds([]);
setActionsOpen(false);
await clearTabularCells(reviewId, docIds);
}
async function handleTitleCommit(newTitle: string) {
if (!newTitle || newTitle === review?.title) return;
setReview((prev) => (prev ? { ...prev, title: newTitle } : prev));
await updateTabularReview(reviewId, { title: newTitle });
}
const q = search.toLowerCase();
const filteredDocuments = q
? documents.filter((d) => d.filename.toLowerCase().includes(q))
: documents;
return (
<div className="flex h-full overflow-hidden bg-white">
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="bg-white px-8 py-4 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 && (
<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>
{/* Toolbar */}
<div className="flex items-center h-10 px-8 border-b border-gray-200 gap-4">
<button
onClick={() => {
if (!chatOpen) setSidebarOpen(false);
if (chatOpen) setSelectedChatId(null);
setChatOpen((v) => !v);
}}
disabled={loading || columns.length === 0 || documents.length === 0}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
loading || columns.length === 0 || documents.length === 0
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<MessageSquare className="h-3.5 w-3.5" />
Assistant in Tabular Review
</button>
<div className="ml-auto flex items-center gap-4">
{selectedDocIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<button
onClick={handleClearResults}
className="w-full px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-gray-50 transition-colors"
>
Clear results
</button>
<button
onClick={handleDeleteDocuments}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
</div>
)}
<button
onClick={() => setAddDocsOpen(true)}
disabled={loading || savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
loading || savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Plus className="h-3.5 w-3.5" />
Add Documents
</button>
<button
onClick={() => setAddColOpen(true)}
disabled={
loading || savingColumn || savingColumnsConfig
}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
loading || savingColumn || savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Plus className="h-3.5 w-3.5" />
Add Columns
</button>
</div>
</div>
{/* Table area */}
<div className="flex flex-1 overflow-hidden">
{chatOpen && (
<TRChatPanel
reviewId={reviewId}
reviewTitle={review?.title ?? null}
projectName={project?.name ?? null}
columns={columns}
documents={documents}
onCitationClick={handleTabularCitationClick}
onClose={() => {
setSelectedChatId(null);
setChatOpen(false);
}}
initialChatId={selectedChatId}
onChatIdChange={setSelectedChatId}
/>
)}
<TRTable
ref={tableRef}
loading={loading}
columns={columns}
documents={filteredDocuments}
cells={cells}
highlightedCell={highlightedCell}
savingColumn={savingColumn}
savingColumnsConfig={savingColumnsConfig}
selectedDocIds={selectedDocIds}
onSelectionChange={setSelectedDocIds}
onExpand={(cell) => {
setExpandedCell(cell);
setExpandedCellCitation(undefined);
}}
onCitationClick={(cell, page, quote) => {
setExpandedCell(cell);
setExpandedCellCitation({ quote, page });
}}
onUpdateColumn={handleUpdateColumn}
onDeleteColumn={handleDeleteColumn}
onAddColumn={() => setAddColOpen(true)}
onAddDocuments={() => setAddDocsOpen(true)}
/>
</div>
</div>
{/* Cell detail side panel */}
{expandedCell &&
(() => {
const expandedDoc = documents.find(
(d) => d.id === expandedCell.document_id,
);
const expandedCol = columns.find(
(c) => c.index === expandedCell.column_index,
);
if (!expandedDoc || !expandedCol) return null;
return (
<TRSidePanel
cell={expandedCell}
document={expandedDoc}
column={expandedCol}
columns={columns}
onClose={() => {
setExpandedCell(null);
setExpandedCellCitation(undefined);
}}
onNavigate={(columnIndex) => {
const nextCell = cells.find(
(c) =>
c.document_id ===
expandedCell.document_id &&
c.column_index === columnIndex,
);
if (nextCell) {
setExpandedCell(nextCell);
setExpandedCellCitation(undefined);
}
}}
onRegenerate={() =>
handleRegenerateCell(
expandedCell.document_id,
expandedCell.column_index,
)
}
displayDocument={expandedCellCitation !== undefined}
citationQuote={expandedCellCitation?.quote}
citationPage={expandedCellCitation?.page}
/>
);
})()}
<AddColumnModal
open={addColOpen}
existingCount={columns.length}
onClose={() => setAddColOpen(false)}
onAdd={handleAddColumn}
/>
{project ? (
<AddProjectDocsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={(docs: MikeDocument[]) =>
handleAddDocuments(docs)
}
breadcrumb={[
"Projects",
project.name +
(project.cm_number
? ` (#${project.cm_number})`
: ""),
"Tabular Reviews",
...(review ? [review.title || "Untitled Review"] : []),
"Add Documents",
]}
projectId={project.id}
excludeDocIds={new Set(documents.map((d) => d.id))}
/>
) : (
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={(docs: MikeDocument[]) =>
handleAddDocuments(docs)
}
breadcrumb={[
"Tabular Reviews",
...(review ? [review.title || "Untitled Review"] : []),
"Add Documents",
]}
/>
)}
<PeopleModal
open={peopleModalOpen}
onClose={() => setPeopleModalOpen(false)}
resource={review}
fetchPeople={getTabularReviewPeople}
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Tabular Reviews",
review?.title || "Untitled Review",
"People",
]}
// Only the review owner may modify the member list. PeopleModal
// hides the add/remove controls when this prop is undefined.
onSharedWithChange={
review?.is_owner === false
? undefined
: async (next) => {
const updated = await updateTabularReview(
reviewId,
{ shared_with: next },
);
setReview((prev) =>
prev
? {
...prev,
shared_with: updated.shared_with,
}
: prev,
);
}
}
/>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}
onClose={() => setOwnerOnlyAction(null)}
/>
<ApiKeyMissingModal
open={apiKeyModalProvider !== null}
provider={apiKeyModalProvider}
onClose={() => setApiKeyModalProvider(null)}
/>
</div>
);
}

View file

@ -0,0 +1,26 @@
"use client";
const PAGE_CITATION_RE = /\[\[page:(\d+)\|\|(?:quote:)?((?:[^\[\]]|\[[^\]]*\])+)\]\]/gi;
export interface ParsedCitation {
page: number;
quote: string;
}
/**
* Replaces [[page:n||quote:...]] markers with `§idx§` placeholders.
* Returns the processed string and an ordered array of extracted citation data.
*/
export function preprocessCitations(text: string): {
processed: string;
citations: ParsedCitation[];
} {
const citations: ParsedCitation[] = [];
PAGE_CITATION_RE.lastIndex = 0;
const processed = text.replace(PAGE_CITATION_RE, (_, page, quote) => {
const idx = citations.length;
citations.push({ page: parseInt(page, 10), quote: quote.trim() });
return `§${idx}§`;
});
return { processed, citations };
}

View file

@ -0,0 +1,23 @@
import type { LucideIcon } from "lucide-react";
import { AlignLeft, List, Hash, DollarSign, ToggleLeft, Calendar, Tag, Percent, Banknote } from "lucide-react";
import type { ColumnFormat } from "../shared/types";
export const FORMAT_OPTIONS: Array<{ value: ColumnFormat; label: string; icon: LucideIcon }> = [
{ value: "text", label: "Free Text", icon: AlignLeft },
{ value: "bulleted_list", label: "Bulleted list", icon: List },
{ value: "number", label: "Number", icon: Hash },
{ value: "percentage", label: "Percentage", icon: Percent },
{ value: "monetary_amount", label: "Monetary Amount", icon: Banknote },
{ value: "currency", label: "Currency", icon: DollarSign },
{ value: "yes_no", label: "Yes / No", icon: ToggleLeft },
{ value: "date", label: "Date", icon: Calendar },
{ value: "tag", label: "Tags", icon: Tag },
];
export function formatLabel(format: ColumnFormat): string {
return FORMAT_OPTIONS.find((o) => o.value === format)?.label ?? "Text";
}
export function formatIcon(format: ColumnFormat): LucideIcon {
return FORMAT_OPTIONS.find((o) => o.value === format)?.icon ?? AlignLeft;
}

View file

@ -0,0 +1,104 @@
import type { ColumnFormat } from "../shared/types";
export interface ColumnPreset {
name: string;
matches: RegExp;
prompt: string;
format: ColumnFormat;
tags?: string[];
}
export const PROMPT_PRESETS: ColumnPreset[] = [
{
name: "Parties",
matches: /\bpart(y|ies)\b/i,
format: "bulleted_list",
prompt: 'List all parties to this agreement. For each party, state their full legal name, entity type, and defined role, e.g.:\n• ABC Corp, a Delaware corporation ("Company")\n• John Smith ("Shareholder")\nOne party per bullet. No additional commentary.',
},
{
name: "Governing Law",
matches: /\bgoverning law\b|\bjurisdiction\b/i,
format: "text",
prompt: 'State only the governing law of this agreement using the short-form jurisdiction name, e.g. "New York Law", "English Law", "Indian Law", "PRC Law". No other text.',
},
{
name: "Effective Date",
matches: /\beffective date\b/i,
format: "date",
prompt: 'State only the effective date of this agreement in DD Mon YYYY format, e.g. "2 Jan 2026". If not explicitly stated, write "Not specified".',
},
{
name: "Term",
matches: /\bterm\b|\bduration\b/i,
format: "text",
prompt: 'State only the duration or term of this agreement in a concise form, e.g. "3 years", "24 months", "perpetual". No other text.',
},
{
name: "Termination",
matches: /\bterminat(e|ion|ing)\b/i,
format: "text",
prompt: "Extract the termination provisions. State who may terminate, the trigger events, required notice period, any cure period, and the key consequences of termination. Be concise.",
},
{
name: "Change of Control",
matches: /\bchange of control\b/i,
format: "text",
prompt: "Identify any change of control provisions. Summarize the trigger events, consequences, consent requirements, and any related termination or acceleration rights. Be concise.",
},
{
name: "Confidentiality",
matches: /\bconfidential(ity)?\b|\bnon-?disclosure\b/i,
format: "text",
prompt: "Summarize the confidentiality obligations: scope of confidential information, permitted disclosures, use restrictions, duration, and key carve-outs or exceptions.",
},
{
name: "Assignment",
matches: /\bassign(ment|ability)?\b/i,
format: "yes_no",
prompt: "Is assignment of this agreement permitted without the other party's consent?",
},
{
name: "Payment & Fees",
matches: /\bpayment\b|\bfees?\b/i,
format: "text",
prompt: 'State the key payment obligations concisely: amount, timing, and currency, e.g. "USD 10,000 payable within 30 days of invoice". Note any late payment consequences.',
},
{
name: "Amendment",
matches: /\bamendment\b|\bvariation\b/i,
format: "text",
prompt: "Summarize the amendment provisions: how amendments may be made, who must consent, and any formality requirements such as writing or signature.",
},
{
name: "Indemnity",
matches: /\bindemni(ty|ties|fication)\b/i,
format: "text",
prompt: "Summarize the indemnity provisions: who indemnifies whom, the scope of indemnified losses, any liability caps or exclusions, and key claims procedures.",
},
{
name: "Warranties",
matches: /\bwarrant(y|ies|ing)\b|\brepresentations?\b/i,
format: "text",
prompt: "Identify and describe key representations and warranties provided by any party, including the scope of such assurances and any specific time periods or conditions applicable to them. In particular highlight any non-standard warranties.",
},
{
name: "Force Majeure",
matches: /\bforce majeure\b/i,
format: "yes_no",
prompt: "Does this agreement contain a force majeure clause?",
},
];
export function getPresetConfig(
title: string,
): Pick<ColumnPreset, "prompt" | "format" | "tags"> | null {
const trimmed = title.trim();
if (!trimmed) return null;
const preset = PROMPT_PRESETS.find(({ matches }) => matches.test(trimmed));
if (!preset) return null;
return { prompt: preset.prompt, format: preset.format, tags: preset.tags };
}
export function getPresetPrompt(title: string): string | null {
return getPresetConfig(title)?.prompt ?? null;
}

View file

@ -0,0 +1,81 @@
"use client";
import ExcelJS from "exceljs";
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
import { preprocessCitations } from "./citation-utils";
function formatCellForExport(cell: TabularCell | undefined): string {
if (!cell) return "";
if (cell.status === "pending" || cell.status === "generating") return "";
if (cell.status === "error") return "Error";
const summary = cell.content?.summary;
if (!summary) return "";
const { processed } = preprocessCitations(summary);
return processed
.replace(/§\d+§/g, "")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/[ \t]+/g, " ")
.trim();
}
function sanitizeFilename(name: string): string {
return (
name
.replace(/[\\/:*?"<>|]/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80) || "Tabular Review"
);
}
export async function exportTabularReviewToExcel(params: {
reviewTitle: string;
columns: ColumnConfig[];
documents: MikeDocument[];
cells: TabularCell[];
}) {
const { reviewTitle, columns, documents, cells } = params;
const sortedCols = [...columns].sort((a, b) => a.index - b.index);
const cellMap = new Map<string, TabularCell>();
for (const c of cells) cellMap.set(`${c.document_id}:${c.column_index}`, c);
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Review");
ws.columns = [
{ header: "Document", width: 40 },
...sortedCols.map((c) => ({ header: c.name, width: 40 })),
];
const headerRow = ws.getRow(1);
headerRow.font = { bold: true };
headerRow.alignment = { vertical: "middle" };
headerRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFF3F4F6" },
};
for (const doc of documents) {
const row: string[] = [doc.filename];
for (const col of sortedCols) {
row.push(formatCellForExport(cellMap.get(`${doc.id}:${col.index}`)));
}
const excelRow = ws.addRow(row);
excelRow.alignment = { vertical: "top", wrapText: true };
}
const buf = await wb.xlsx.writeBuffer();
const blob = new Blob([buf], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(reviewTitle)}.xlsx`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,72 @@
import type { ColumnConfig } from "../shared/types";
export type PillSegment =
| { type: "text"; content: string }
| { type: "pill"; content: string };
/** Sequential colors assigned to tags by their position in the tags array. */
export const TAG_COLORS = [
"bg-blue-100 text-blue-700",
"bg-violet-100 text-violet-700",
"bg-pink-100 text-pink-700",
"bg-orange-100 text-orange-700",
"bg-teal-100 text-teal-700",
"bg-amber-100 text-amber-700",
"bg-indigo-100 text-indigo-700",
"bg-rose-100 text-rose-700",
];
const CURRENCY_COLORS: Record<string, string> = {
USD: "bg-green-100 text-green-700",
EUR: "bg-blue-100 text-blue-700",
GBP: "bg-purple-100 text-purple-700",
JPY: "bg-red-100 text-red-700",
CHF: "bg-orange-100 text-orange-700",
AUD: "bg-cyan-100 text-cyan-700",
CAD: "bg-teal-100 text-teal-700",
SGD: "bg-pink-100 text-pink-700",
HKD: "bg-rose-100 text-rose-700",
NZD: "bg-lime-100 text-lime-700",
CNY: "bg-amber-100 text-amber-700",
};
export function getPillClass(content: string, column?: ColumnConfig): string {
if (column?.format === "yes_no") {
const lower = content.toLowerCase();
if (lower === "yes") return "bg-green-100 text-green-700";
if (lower === "no") return "bg-red-100 text-red-700";
return "bg-gray-100 text-gray-700";
}
if (column?.format === "currency") {
return (
CURRENCY_COLORS[content.toUpperCase()] ??
"bg-slate-100 text-slate-700"
);
}
if (column?.format === "tag" && column.tags?.length) {
const idx = column.tags.findIndex(
(t) => t.toLowerCase() === content.toLowerCase(),
);
if (idx >= 0) return TAG_COLORS[idx % TAG_COLORS.length]!;
}
return "bg-gray-100 text-gray-700";
}
/** Split text on [[...]] pill markers, preserving surrounding text. */
export function parsePills(text: string): PillSegment[] {
const segments: PillSegment[] = [];
const regex = /\[\[([^\]]+)\]\]/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
segments.push({ type: "pill", content: match[1] });
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
segments.push({ type: "text", content: text.slice(lastIndex) });
}
return segments;
}

View file

@ -0,0 +1,60 @@
const PROMPT_PRESETS: Array<{
matches: RegExp;
prompt: (title: string) => string;
}> = [
{
matches: /\bpart(y|ies)\b/i,
prompt: () =>
'Identify all parties referenced in the document. List their full names and describe each party\'s role or capacity in the agreement. If a party is not clearly identified, state "Not addressed".',
},
{
matches: /\bchange of control\b/i,
prompt: () =>
'Identify any change of control provisions in the document. Summarize the trigger, the consequences, any consent requirements, and any related termination, acceleration, or notice obligations. If not addressed, state "Not addressed".',
},
{
matches: /\bterminat(e|ion|ing)\b/i,
prompt: () =>
'Extract the termination provisions in the document. Summarize who may terminate, the termination triggers, any notice requirements, cure periods, and the consequences of termination. If not addressed, state "Not addressed".',
},
{
matches: /\bgoverning law\b|\bjurisdiction\b/i,
prompt: () =>
'Identify the governing law and jurisdiction provisions in the document. State the governing law, the forum for disputes, and any submission to jurisdiction or venue requirements. If not addressed, state "Not addressed".',
},
{
matches: /\bconfidential(ity)?\b|\bnon-?disclosure\b/i,
prompt: () =>
'Extract the confidentiality provisions in the document. Summarize the scope of confidential information, permitted disclosures, use restrictions, duration, and any carve-outs or exceptions. If not addressed, state "Not addressed".',
},
{
matches: /\bassign(ment|ability)?\b/i,
prompt: () =>
'Identify any assignment provisions in the document. Summarize whether assignment is permitted, restricted, or requires consent, and note any exceptions or deemed assignments. If not addressed, state "Not addressed".',
},
{
matches: /\bpayment\b|\bfees?\b/i,
prompt: () =>
'Extract the payment and fee terms in the document. Summarize payment obligations, amounts, timing, currencies, fee types, and any consequences for late or missed payment. If not addressed, state "Not addressed".',
},
];
export function getPresetTabularPrompt(title: string): string | null {
const trimmedTitle = title.trim();
if (!trimmedTitle) return null;
const preset = PROMPT_PRESETS.find(({ matches }) => matches.test(trimmedTitle));
return preset ? preset.prompt(trimmedTitle) : null;
}
export function buildFallbackTabularPrompt(title: string): string {
const trimmedTitle = title.trim();
if (!trimmedTitle) return "";
return (
`Review each document and extract the information relevant to "${trimmedTitle}". ` +
`Provide a concise, document-specific summary for this column. ` +
`Include the key facts, dates, thresholds, parties, and conditions where applicable. ` +
`If the document does not contain relevant information, return "Not addressed".`
);
}