Refactor ProjectPageParts and ProjectPageHeader components for improved loading states and skeleton UI. Update Modal and PageHeader components to support loading states. Enhance RenameableTitle for better caret positioning. Adjust DisplayWorkflowModal to utilize the new Modal component structure. Update WorkflowList to include loading indicators and improve sticky header behavior.

This commit is contained in:
willchen96 2026-06-11 21:50:58 +08:00
parent 444d1d38e4
commit 1fa0554ea5
49 changed files with 3623 additions and 1587 deletions

View file

@ -1195,11 +1195,11 @@ function MarkdownContent({
onClick={() =>
onCitationClick?.(annotation)
}
data-citation-ref={idx + 1}
data-citation-ref={annotation.ref}
className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`}
title={tooltipText}
>
{idx + 1}
{annotation.ref}
</button>
);
}
@ -1380,7 +1380,7 @@ function ensureTerminalPeriod(value: string): string {
function buildCitationAppendix(citations: CitationAnnotation[]) {
if (citations.length === 0) return { html: "", text: "" };
let previousSourceKey: string | null = null;
const entries = citations.map((annotation, index) => {
const entries = citations.map((annotation) => {
const sourceKey = citationSourceKey(annotation);
const label =
sourceKey === previousSourceKey
@ -1388,7 +1388,7 @@ function buildCitationAppendix(citations: CitationAnnotation[]) {
: citationSourceLabel(annotation);
previousSourceKey = sourceKey;
return {
number: index + 1,
number: annotation.ref,
label,
quote: displayCitationQuote(annotation).trim(),
};
@ -1484,7 +1484,7 @@ function CitationsBlock({
}
title={`${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`}
>
{index + 1}
{annotation.ref}
</button>
),
)}

View file

@ -30,30 +30,25 @@ export function AssistantWorkflowModal({
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Workflow | null>(null);
const [search, setSearch] = useState("");
const [rightVisible, setRightVisible] = useState(false);
useEffect(() => {
if (!selected) {
setRightVisible(false);
return;
}
const frame = requestAnimationFrame(() => setRightVisible(true));
return () => cancelAnimationFrame(frame);
}, [selected]);
useEffect(() => {
if (!open) {
setSelected(null);
setSearch("");
return;
}
if (!open) return;
let cancelled = false;
const builtins = BUILT_IN_WORKFLOWS.filter(
(w) => w.type === "assistant",
);
setWorkflows(builtins);
setLoading(true);
const frame = requestAnimationFrame(() => {
if (cancelled) return;
setWorkflows(builtins);
setLoading(true);
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
});
listWorkflows("assistant")
.then((custom) => {
if (cancelled) return;
const all = [...builtins, ...custom];
setWorkflows(all);
if (initialWorkflowId) {
@ -62,17 +57,19 @@ export function AssistantWorkflowModal({
}
})
.catch(() => {
if (cancelled) return;
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
})
.finally(() => setLoading(false));
// Pre-select from builtins immediately if possible
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [open, initialWorkflowId]);
if (!open) return null;
@ -81,10 +78,16 @@ export function AssistantWorkflowModal({
? workflows.filter((w) => w.title.toLowerCase().includes(search.toLowerCase()))
: workflows;
function handleClose() {
setSelected(null);
setSearch("");
onClose();
}
function handleUse() {
if (!selected) return;
onSelect(selected);
onClose();
handleClose();
}
const breadcrumbs = projectName
@ -99,7 +102,7 @@ export function AssistantWorkflowModal({
return (
<Modal
open={open}
onClose={onClose}
onClose={handleClose}
size={selected ? "xl" : "lg"}
breadcrumbs={breadcrumbs}
primaryAction={{
@ -110,13 +113,13 @@ export function AssistantWorkflowModal({
}}
>
{/* Content */}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
{/* Left panel — workflow list */}
<div
className={`overflow-y-auto ${selected ? "w-80 shrink-0" : "flex-1"}`}
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
>
{/* Search */}
<div className="pt-3 pb-2 shrink-0">
<div className="px-2 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -135,11 +138,11 @@ export function AssistantWorkflowModal({
</div>
{loading ? (
<div className="space-y-px pt-1">
<div className="space-y-1 px-2 pb-2 pt-1">
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
<div
key={i}
className="flex items-center justify-between gap-3 py-3 border-b border-gray-50"
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
>
<div
className="h-3 rounded bg-gray-100 animate-pulse"
@ -154,35 +157,37 @@ export function AssistantWorkflowModal({
{search ? "No matches found" : "No assistant workflows found"}
</p>
) : (
filteredWorkflows.map((wf) => (
<button
key={wf.id}
type="button"
onClick={() =>
setSelected((prev) =>
prev?.id === wf.id ? null : wf,
)
}
className={`w-full flex items-center gap-3 px-4 py-3 text-xs text-left transition-colors border-b border-gray-50 ${
selected?.id === wf.id
? "bg-gray-50"
: "hover:bg-gray-50"
}`}
>
<span className="flex-1 truncate text-gray-800">
{wf.title}
</span>
<span className="shrink-0 text-xs text-gray-400">
{wf.is_system ? "Built-in" : "Custom"}
</span>
</button>
))
<div className="overflow-y-auto">
{filteredWorkflows.map((wf) => (
<button
key={wf.id}
type="button"
onClick={() =>
setSelected((prev) =>
prev?.id === wf.id ? null : wf,
)
}
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${
selected?.id === wf.id
? "bg-gray-100"
: "hover:bg-gray-50"
}`}
>
<span className="flex-1 truncate text-gray-800">
{wf.title}
</span>
<span className="shrink-0 text-xs text-gray-400">
{wf.is_system ? "Built-in" : "Custom"}
</span>
</button>
))}
</div>
)}
</div>
{/* Right panel — prompt preview */}
{selected && (
<div className={`flex-1 border-l border-gray-100 flex flex-col overflow-hidden px-3 pb-3 transition-opacity duration-200 ${rightVisible ? "opacity-100" : "opacity-0"}`}>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">
Workflow Prompt

View file

@ -20,8 +20,11 @@ export interface ModelOption {
}
export const MODELS: ModelOption[] = [
{ id: "claude-fable-5", label: "Claude Fable 5", group: "Anthropic" },
{ id: "claude-opus-4-8", label: "Claude Opus 4.8", group: "Anthropic" },
{ id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" },
{ id: "gemini-3.5-flash", label: "Gemini 3.5 Flash", group: "Google" },
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" },
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" },
{ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" },

View file

@ -29,11 +29,11 @@ export function UserMessage({ content, files, workflow }: Props) {
return (
<div
key={i}
className="inline-flex items-center gap-1 pl-2 pr-2.5 py-0.5 rounded-full text-xs text-white shadow border border-black bg-black"
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-2.5 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
>
{isPdf
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-400" />
: <File className="h-2.5 w-2.5 shrink-0 text-blue-400" />
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
: <File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
}
<span className="max-w-[140px] truncate">{f.filename}</span>
</div>

View file

@ -18,7 +18,7 @@ import { WarningPopup } from "@/app/components/shared/WarningPopup";
import type { Document } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { formatBytes, formatDate } from "./ProjectPageParts";
import { formatBytes } from "./ProjectPageParts";
const MIN_DOC_COLUMN_WIDTH = 420;
const DEFAULT_DOC_COLUMN_WIDTH = 620;
@ -27,7 +27,7 @@ const DEFAULT_DATA_COLUMN_WIDTH = 340;
const RESIZER_WIDTH = 6;
const MAX_PANEL_WIDTH = 1180;
const primaryGlassButtonClass =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-gray-700/40 bg-gray-950/88 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-all hover:bg-gray-900/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-blue-800/35 bg-blue-700/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(30,64,175,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(30,64,175,0.18)] backdrop-blur-xl transition-all hover:bg-blue-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
const dangerGlassButtonClass =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-red-700/35 bg-red-600/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl transition-all hover:bg-red-600 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
@ -57,6 +57,14 @@ interface DocumentSidePanelProps {
file: File,
filename: string,
) => Promise<void>;
onReplaceVersion: (
docId: string,
versionId: string,
file: File,
filename: string,
) => Promise<void> | void;
canDelete?: boolean;
onOwnerOnlyAction?: (action: string) => void;
onDelete: (doc: Document) => Promise<void> | void;
}
@ -73,6 +81,9 @@ export function DocumentSidePanel({
onRenameVersion,
onDeleteVersion,
onUploadNewVersion,
onReplaceVersion,
canDelete = true,
onOwnerOnlyAction,
onDelete,
}: DocumentSidePanelProps) {
const [mounted, setMounted] = useState(false);
@ -86,6 +97,13 @@ export function DocumentSidePanel({
const [deletingVersionId, setDeletingVersionId] = useState<string | null>(
null,
);
const [replaceTargetVersion, setReplaceTargetVersion] =
useState<DocumentVersion | null>(null);
const [replaceFile, setReplaceFile] = useState<File | null>(null);
const [replaceConfirmOpen, setReplaceConfirmOpen] = useState(false);
const [replacingVersionId, setReplacingVersionId] = useState<string | null>(
null,
);
const [deletingDocument, setDeletingDocument] = useState(false);
const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] =
useState(false);
@ -98,8 +116,13 @@ export function DocumentSidePanel({
const [panelWidth, setPanelWidth] = useState(
DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH,
);
const [isMobile, setIsMobile] = useState(false);
const [mobilePane, setMobilePane] = useState<"document" | "details">(
"document",
);
const panelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const replaceFileInputRef = useRef<HTMLInputElement>(null);
const dragStartX = useRef(0);
const dragStartDataWidth = useRef(DEFAULT_DATA_COLUMN_WIDTH);
const dragStartPanelWidth = useRef(
@ -111,6 +134,7 @@ export function DocumentSidePanel({
useEffect(() => {
if (!mounted) return;
function handleWindowResize() {
setIsMobile(window.innerWidth < 768);
setPanelWidth((width) => clampPanelWidth(width, dataColumnWidth));
}
handleWindowResize();
@ -129,14 +153,21 @@ export function DocumentSidePanel({
setNameDraft("");
setNameError(null);
setExtensionWarningOpen(false);
setReplaceTargetVersion(null);
setReplaceFile(null);
setReplaceConfirmOpen(false);
setMobilePane("document");
}, [doc?.id, versionId, currentVersionId]);
if (!mounted || !doc) return null;
const activeDoc = doc;
const documentId = activeDoc.id;
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const newVersionAccept = ".pdf,.docx,.doc";
const orderedVersions = [...versions].reverse();
const activeVersionCount = versions.filter(
(version) => version.deleted_at == null,
).length;
const selectedVersion =
versions.find((version) => version.id === versionId) ??
versions.find((version) => version.id === currentVersionId) ??
@ -158,8 +189,19 @@ export function DocumentSidePanel({
: selectedVersion.page_count;
const selectedVersionNumber =
selectedVersion?.version_number ?? doc.active_version_number ?? null;
const selectedVersionTag =
selectedVersionNumber != null ? `V${selectedVersionNumber}` : null;
const selectedUploadedAt = selectedVersion?.created_at ?? doc.created_at;
const selectedExtension = filenameExtension(selectedFilename);
const replaceFileType = replaceTargetVersion
? fileTypeForVersion(replaceTargetVersion, selectedFileType)
: selectedFileType;
const replaceVersionAccept =
replaceFileType === "pdf" ? ".pdf" : ".docx,.doc";
const ownerLabel =
doc.owner_display_name?.trim() ||
doc.owner_email?.trim() ||
"—";
async function handleSaveName() {
if (!selectedVersionId) return;
@ -208,6 +250,10 @@ export function DocumentSidePanel({
}
async function handleDeleteVersion(versionIdToDelete: string) {
if (!canDelete) {
onOwnerOnlyAction?.("delete this document version");
return;
}
setDeletingVersionId(versionIdToDelete);
try {
await onDeleteVersion(documentId, versionIdToDelete);
@ -218,6 +264,46 @@ export function DocumentSidePanel({
}
}
function requestReplaceVersion(version: DocumentVersion) {
setUploadError(null);
setReplaceTargetVersion(version);
setReplaceFile(null);
window.setTimeout(() => replaceFileInputRef.current?.click(), 0);
}
function handleReplaceFileInputChange(
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0] ?? null;
e.target.value = "";
if (!file || !replaceTargetVersion) return;
setReplaceFile(file);
setReplaceConfirmOpen(true);
}
async function handleConfirmReplaceVersion() {
if (!replaceTargetVersion || !replaceFile) return;
const targetId = replaceTargetVersion.id;
setReplacingVersionId(targetId);
setUploadError(null);
try {
await onReplaceVersion(
documentId,
targetId,
replaceFile,
replaceFile.name,
);
setReplaceConfirmOpen(false);
setReplaceTargetVersion(null);
setReplaceFile(null);
} catch (err) {
console.error("replace version failed", err);
setUploadError("Could not replace this version.");
} finally {
setReplacingVersionId(null);
}
}
async function handleDeleteDocument() {
if (deleteDocumentStatus === "deleting") return;
setDeleteDocumentStatus("deleting");
@ -239,6 +325,10 @@ export function DocumentSidePanel({
}
function requestDeleteDocument() {
if (!canDelete) {
onOwnerOnlyAction?.("delete this document");
return;
}
if (versions.length > 1) {
setDeleteDocumentStatus("idle");
setConfirmDeleteDocumentOpen(true);
@ -313,27 +403,53 @@ export function DocumentSidePanel({
ref={panelRef}
className={cn(
"fixed z-[190] flex flex-col",
"inset-y-3 right-3 rounded-2xl border border-white/70 bg-white/72 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
"inset-3 md:left-auto rounded-2xl border border-white/70 bg-gray-50/80 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
)}
style={{ width: panelWidth }}
style={isMobile ? undefined : { width: panelWidth }}
>
<div
onMouseDown={handlePanelResizeMouseDown}
className="absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60"
className="absolute inset-y-0 left-0 z-20 hidden w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60 md:block"
title="Resize document view"
/>
<div
className={cn(
"flex h-11 shrink-0 items-center justify-between px-4",
"border-b border-white/60 bg-white/35",
)}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-700">
<div className="flex min-h-11 shrink-0 items-center justify-between gap-3 px-4 py-2 md:h-11 md:py-0">
<div className="flex min-w-0 items-center gap-2">
{selectedVersionTag && (
<span className="inline-flex h-5 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white/75 px-2 text-[10px] font-semibold text-gray-600">
{selectedVersionTag}
</span>
)}
<div className="min-w-0 truncate text-sm font-medium text-gray-700">
{selectedFilename}
</div>
</div>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex h-7 items-center rounded-full bg-gray-200/70 p-0.5 md:hidden">
<button
type="button"
onClick={() => setMobilePane("document")}
className={cn(
"h-6 rounded-full px-2 text-[11px] font-medium transition-colors",
mobilePane === "document"
? "bg-white text-gray-900 shadow-[0_1px_3px_rgba(15,23,42,0.08)]"
: "text-gray-500 hover:text-gray-800",
)}
>
Document
</button>
<button
type="button"
onClick={() => setMobilePane("details")}
className={cn(
"h-6 rounded-full px-2 text-[11px] font-medium transition-colors",
mobilePane === "details"
? "bg-white text-gray-900 shadow-[0_1px_3px_rgba(15,23,42,0.08)]"
: "text-gray-500 hover:text-gray-800",
)}
>
Details
</button>
</div>
<button
type="button"
onClick={onClose}
@ -348,27 +464,31 @@ export function DocumentSidePanel({
<div
className="grid min-h-0 flex-1"
style={{
gridTemplateColumns: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
gridTemplateColumns: isMobile
? "minmax(0, 1fr)"
: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
}}
>
<section
className={cn(
"flex min-h-0 min-w-0 pb-3 pl-3",
"bg-white/20",
"min-h-0 min-w-0 p-3 pt-0 md:flex md:pr-0",
mobilePane === "document" ? "flex" : "hidden",
)}
>
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
"rounded-xl border border-white/60 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
"rounded-xl border border-gray-200 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
)}
>
<DocView
key={selectedVersionId ?? "current"}
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
doc={{
document_id: doc.id,
version_id: selectedVersionId,
}}
rounded={false}
bordered={false}
/>
</div>
</section>
@ -376,7 +496,7 @@ export function DocumentSidePanel({
<div
onMouseDown={handleResizeMouseDown}
className={cn(
"relative cursor-col-resize transition-colors",
"relative hidden cursor-col-resize transition-colors md:block",
"bg-white/25 hover:bg-blue-400/60",
)}
title="Resize document panel"
@ -384,16 +504,12 @@ export function DocumentSidePanel({
<aside
className={cn(
"mb-3 ml-2 mr-3 flex min-h-0 flex-col overflow-hidden rounded-xl",
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
"mx-3 mb-3 min-h-0 flex-col overflow-hidden rounded-xl md:ml-2 md:mr-3",
mobilePane === "details" ? "flex" : "hidden md:flex",
"border border-white/70 bg-white shadow-[0_3px_9px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
)}
>
<div
className={cn(
"shrink-0 px-4 py-3",
"border-b border-white/60",
)}
>
<div className={cn("shrink-0 p-4")}>
<div className="mb-4">
<div className="mb-3 text-xs font-medium text-gray-900">
Name
@ -490,25 +606,30 @@ export function DocumentSidePanel({
: "—"
}
/>
<DataRow label="Owner" value={ownerLabel} />
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDate(selectedUploadedAt)
? formatDateTime(
selectedUploadedAt,
)
: "—"
}
/>
<DataRow
label="Pages"
value={
selectedPageCount != null
? String(selectedPageCount)
: "—"
}
/>
{selectedPageCount != null && (
<DataRow
label="Pages"
value={String(selectedPageCount)}
/>
)}
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
<div className="flex min-h-0 flex-1 flex-col px-4 pt-0">
<div className="mb-2 text-xs font-medium text-gray-900">
Versions
</div>
@ -520,9 +641,16 @@ export function DocumentSidePanel({
>
<div className="min-h-0 flex-1 overflow-y-auto py-2">
{versionsLoading && versions.length === 0 ? (
<div className="flex items-center gap-2 py-2 text-xs text-gray-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading versions
<div className="space-y-1.5">
{Array.from({
length: versionSkeletonCount(
doc.active_version_number,
),
}).map((_, index) => (
<VersionUploadSkeleton
key={`version-skeleton-${index}`}
/>
))}
</div>
) : orderedVersions.length === 0 ? (
<div className="py-2 text-xs text-gray-400">
@ -530,6 +658,9 @@ export function DocumentSidePanel({
</div>
) : (
<div className="space-y-1.5">
{uploading && (
<VersionUploadSkeleton />
)}
{orderedVersions.map((version) => {
const title =
versionTitleFor(version);
@ -538,9 +669,14 @@ export function DocumentSidePanel({
const selected =
selectedVersionId ===
version.id;
const deleted =
version.deleted_at != null;
const versionDeleting =
deletingVersionId ===
version.id;
const versionReplacing =
replacingVersionId ===
version.id;
const fileType = fileTypeForVersion(
version,
doc.file_type,
@ -554,25 +690,34 @@ export function DocumentSidePanel({
key={version.id}
role="button"
tabIndex={0}
onClick={() =>
onClick={() => {
if (deleted) return;
onSelectVersion(
version.id,
filename,
)
}
);
}}
onKeyDown={(event) => {
if (deleted) return;
if (
event.key !==
"Enter" &&
event.key !== " "
) return;
)
return;
event.preventDefault();
onSelectVersion(
version.id,
filename,
);
}}
className="group relative flex w-full cursor-pointer flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white"
aria-disabled={deleted}
className={cn(
"group relative flex w-full flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white",
deleted
? "cursor-not-allowed opacity-55"
: "cursor-pointer",
)}
>
{selected && (
<span className="absolute inset-y-0 left-0 w-[3px] bg-blue-500" />
@ -588,8 +733,10 @@ export function DocumentSidePanel({
<span
className={cn(
"shrink-0 text-[10px] font-semibold tracking-normal",
typeLabel ===
"PDF"
deleted
? "text-gray-300"
: typeLabel ===
"PDF"
? "text-red-600"
: "text-blue-600",
)}
@ -611,51 +758,99 @@ export function DocumentSidePanel({
<div
className={cn(
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
selected
deleted ||
selected
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void onDownloadVersion(
doc.id,
version.id,
filename,
);
}}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
aria-label={`Download ${title}`}
title="Download version"
>
<Download className="h-3 w-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDeleteVersion(
version.id,
);
}}
disabled={
versions.length <=
1 ||
deletingVersionId !=
null
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Delete ${title}`}
title="Delete version"
>
{versionDeleting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</button>
{deleted ? (
<span className="text-[11px] font-medium text-gray-800">
Deleted
</span>
) : (
<>
<button
type="button"
onClick={(
event,
) => {
event.stopPropagation();
requestReplaceVersion(
version,
);
}}
disabled={
replacingVersionId !=
null ||
deletingVersionId !=
null
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-blue-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Replace ${title}`}
title="Replace version file"
>
{versionReplacing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Upload className="h-3 w-3" />
)}
</button>
<button
type="button"
onClick={(
event,
) => {
event.stopPropagation();
void onDownloadVersion(
doc.id,
version.id,
filename,
);
}}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
aria-label={`Download ${title}`}
title="Download version"
>
<Download className="h-3 w-3" />
</button>
<button
type="button"
onClick={(
event,
) => {
event.stopPropagation();
void handleDeleteVersion(
version.id,
);
}}
disabled={
(canDelete &&
activeVersionCount <=
1) ||
deletingVersionId !=
null
}
className={cn(
"inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40",
!canDelete &&
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-red-500",
)}
aria-label={`Delete ${title}`}
title={
canDelete
? "Delete version"
: "Only the document owner can delete versions"
}
>
{versionDeleting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</button>
</>
)}
</div>
</div>
</div>
@ -677,21 +872,37 @@ export function DocumentSidePanel({
<div
className={cn(
"flex shrink-0 items-center justify-between px-4 py-3",
"border-t border-white/60 bg-white/25",
"bg-white/25",
)}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
accept={newVersionAccept}
className="hidden"
onChange={handleUpload}
/>
<input
ref={replaceFileInputRef}
type="file"
accept={replaceVersionAccept}
className="hidden"
onChange={handleReplaceFileInputChange}
/>
<button
type="button"
onClick={requestDeleteDocument}
disabled={deletingDocument}
className={dangerGlassButtonClass}
className={cn(
dangerGlassButtonClass,
!canDelete &&
"cursor-not-allowed opacity-45 hover:bg-red-600/90 active:scale-100",
)}
title={
canDelete
? "Delete document"
: "Only the document owner can delete this document"
}
>
{deletingDocument ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
@ -725,6 +936,23 @@ export function DocumentSidePanel({
: "File extensions cannot be changed here."
}
/>
<ConfirmPopup
open={replaceConfirmOpen}
title="Replace version?"
message={`This will wipe ${versionTitleFor(replaceTargetVersion)} and replace it with ${replaceFile?.name ?? "the selected file"}. Save as a new version instead if you want to keep both copies.`}
confirmLabel="Replace"
confirmStatus={
replacingVersionId != null ? "loading" : "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (replacingVersionId != null) return;
setReplaceConfirmOpen(false);
setReplaceTargetVersion(null);
setReplaceFile(null);
}}
onConfirm={() => void handleConfirmReplaceVersion()}
/>
<ConfirmPopup
open={confirmDeleteDocumentOpen}
title="Delete document?"
@ -759,6 +987,32 @@ function DataRow({ label, value }: { label: string; value: string }) {
);
}
function VersionUploadSkeleton() {
return (
<div className="rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)]">
<div className="animate-pulse space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="h-3 w-20 rounded-full bg-gray-200" />
<div className="h-3 w-9 rounded-full bg-blue-100" />
</div>
<div className="h-2.5 w-4/5 rounded-full bg-gray-200" />
<div className="h-2.5 w-2/5 rounded-full bg-gray-200" />
</div>
</div>
);
}
function versionSkeletonCount(activeVersionNumber: number | null | undefined) {
if (
typeof activeVersionNumber === "number" &&
Number.isFinite(activeVersionNumber) &&
activeVersionNumber > 0
) {
return Math.min(activeVersionNumber, 8);
}
return 2;
}
function clampPanelWidth(width: number, dataColumnWidth: number) {
const minWidth = MIN_DOC_COLUMN_WIDTH + RESIZER_WIDTH + dataColumnWidth;
const maxWidth =
@ -768,7 +1022,8 @@ function clampPanelWidth(width: number, dataColumnWidth: number) {
return Math.min(maxWidth, Math.max(minWidth, width));
}
function versionTitleFor(version: DocumentVersion) {
function versionTitleFor(version: DocumentVersion | null) {
if (!version) return "this version";
if (
typeof version.version_number === "number" &&
version.version_number >= 1
@ -805,3 +1060,13 @@ function hasExtensionChange(previous: string, next: string) {
previousExtension.toLowerCase()
);
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}

View file

@ -14,6 +14,7 @@ import {
} from "lucide-react";
import {
getProject,
deleteProject,
deleteDocument,
createTabularReview,
updateProject,
@ -33,6 +34,7 @@ import {
renameProjectDocument,
listDocumentVersions,
uploadDocumentVersion,
replaceDocumentVersionFile,
copyDocumentVersionFromDocument,
deleteDocumentVersion,
uploadProjectDocument,
@ -76,7 +78,6 @@ import {
formatBytes,
formatDate,
ProjectPageHeader,
ProjectPageSkeleton,
treeNameCellStyle,
type ProjectContextMenu,
type ProjectTab,
@ -108,6 +109,157 @@ function apiErrorDetail(error: unknown): string | null {
return error.message || null;
}
function ProjectTableLoading({
tab,
stickyCellBg,
}: {
tab: ProjectTab;
stickyCellBg: string;
}) {
if (tab === "assistant") {
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Chats</span>
</div>
<div className="ml-auto w-32 shrink-0 text-left">
Created
</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${44 + i * 7}px` }}
/>
</div>
</div>
<div className="ml-auto w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</>
);
}
if (tab === "reviews") {
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Name</span>
</div>
<div className="ml-auto w-24 shrink-0 text-left">
Columns
</div>
<div className="w-24 shrink-0 text-left">Documents</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${180 + i * 18}px` }}
/>
</div>
</div>
<div className="ml-auto w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Name</span>
</div>
<div className="ml-auto w-20 shrink-0 text-left">Type</div>
<div className="w-24 shrink-0 text-left">Size</div>
<div className="w-20 shrink-0 text-left">Version</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-32 shrink-0 text-left">Updated</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${210 + i * 16}px` }}
/>
</div>
</div>
<div className="ml-auto w-20 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
<div className="h-3 w-5 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</div>
);
}
export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [project, setProject] = useState<Project | null>(null);
const [folders, setFolders] = useState<ProjectFolder[]>([]);
@ -248,16 +400,31 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}
}
async function replaceVersionFile(
docId: string,
versionId: string,
file: File,
filename: string,
) {
await replaceDocumentVersionFile(docId, versionId, file, filename);
const res = await refreshDocumentVersionState(docId);
const replaced = res.versions.find(
(version) => version.id === versionId,
);
if (replaced) {
setViewingDocVersion({
id: replaced.id,
label: replaced.filename?.trim() || "Version",
});
}
}
async function refreshDocumentVersionState(docId: string) {
// Refresh project so doc.active_version_number and filename advance.
const updated = await getProject(projectId);
setProject(updated);
// Re-fetch versions for this doc (invalidate cache first).
setVersionsByDocId((prev) => {
const next = new Map(prev);
next.delete(docId);
return next;
});
// Re-fetch versions while keeping the previous rows visible until the
// updated list arrives.
const res = await listDocumentVersions(docId);
setVersionsByDocId((prev) => {
const next = new Map(prev);
@ -318,11 +485,14 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
try {
await deleteDocumentVersion(docId, versionId);
const res = await refreshDocumentVersionState(docId);
const activeVersions = res.versions.filter(
(version) => version.deleted_at == null,
);
const nextVersion =
res.versions.find(
activeVersions.find(
(version) => version.id === res.current_version_id,
) ??
res.versions[res.versions.length - 1] ??
activeVersions[activeVersions.length - 1] ??
null;
setViewingDocVersion(
nextVersion
@ -385,6 +555,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
string[]
>([]);
const [deletingDocIds, setDeletingDocIds] = useState<Set<string>>(
() => new Set(),
);
const [documentUploadWarning, setDocumentUploadWarning] = useState<
string | null
>(null);
@ -413,6 +586,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [pendingDeleteFolderStatus, setPendingDeleteFolderStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
useState(false);
const [deleteProjectStatus, setDeleteProjectStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
// Actions dropdown
const [actionsOpen, setActionsOpen] = useState(false);
@ -860,16 +1038,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setOwnerOnlyAction("delete this document");
return;
}
await deleteDocument(docId);
setProject((prev) =>
prev
? {
...prev,
documents:
prev.documents?.filter((d) => d.id !== docId) || [],
}
: prev,
);
setDeletingDocIds((prev) => new Set([...prev, docId]));
try {
await deleteDocument(docId);
setProject((prev) =>
prev
? {
...prev,
documents:
prev.documents?.filter((d) => d.id !== docId) ||
[],
}
: prev,
);
} finally {
setDeletingDocIds((prev) => {
const next = new Set(prev);
next.delete(docId);
return next;
});
}
}
function requestRemoveDoc(doc: Document) {
@ -877,6 +1065,14 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setOwnerOnlyAction("delete this document");
return;
}
const versionCount =
versionsByDocId.get(doc.id)?.versions.length ??
currentVersionNumber(doc) ??
1;
if (versionCount <= 1) {
void handleRemoveDoc(doc.id);
return;
}
setPendingDeleteStatus("idle");
setPendingDeleteDoc(doc);
}
@ -949,6 +1145,48 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
await updateProject(projectId, { name: newName });
}
async function handleCmNumberCommit(newCmNumber: string) {
if (project && project.is_owner === false) {
setOwnerOnlyAction("rename this project's CM number");
return;
}
const trimmed = newCmNumber.trim();
if (trimmed === (project?.cm_number ?? "")) return;
setProject((prev) =>
prev ? { ...prev, cm_number: trimmed || null } : prev,
);
const updated = await updateProject(projectId, {
cm_number: trimmed,
});
setProject((prev) =>
prev ? { ...prev, cm_number: updated.cm_number } : prev,
);
}
function requestProjectDelete() {
if (project?.is_owner === false) {
setOwnerOnlyAction("delete this project");
return;
}
setDeleteProjectStatus("idle");
setDeleteProjectConfirmOpen(true);
}
async function confirmProjectDelete() {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectStatus("deleting");
try {
await deleteProject(projectId);
setDeleteProjectStatus("deleted");
setTimeout(() => {
router.push("/projects");
}, 250);
} catch (err) {
setDeleteProjectStatus("idle");
console.error("Failed to delete project", err);
}
}
async function submitChatRename(chatId: string) {
const trimmed = renameChatValue.trim();
setRenamingChatId(null);
@ -1168,6 +1406,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
return doc.active_version_number ?? doc.latest_version_number ?? null;
}
function isSharedDocument(doc: Document | null | undefined): boolean {
return !!(doc?.user_id && user?.id && doc.user_id !== user.id);
}
async function handleDropProjectFiles(files: File[]) {
if (files.length === 0) return;
const { supported, unsupported } =
@ -1414,10 +1656,22 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
);
}
function renderUploadingDocumentRows(depth: number) {
return uploadingDroppedFilenames.map((filename) => (
function renderDocumentActivityRow({
key,
filename,
fileType,
depth,
statusLabel,
}: {
key: string;
filename: string;
fileType: string | null;
depth: number;
statusLabel: string;
}) {
return (
<div
key={`uploading-doc-${filename}`}
key={key}
className="group flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
@ -1425,31 +1679,40 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-4">
<input
type="checkbox"
disabled
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
/>
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
<DocIcon fileType={fileType} />
<span className="text-sm text-gray-400 truncate">
{filename}
</span>
</div>
</div>
<div className="ml-auto w-20 shrink-0 text-xs text-gray-300 uppercase truncate">
{filename.includes(".")
? filename.split(".").pop()
: "file"}
{fileType ??
(filename.includes(".")
? filename.split(".").pop()
: "file")}
</div>
<div className="w-24 shrink-0 text-sm text-gray-300">
Uploading
{statusLabel}
</div>
<div className="w-20 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-8 shrink-0" />
</div>
));
);
}
function renderUploadingDocumentRows(depth: number) {
return uploadingDroppedFilenames.map((filename) =>
renderDocumentActivityRow({
key: `uploading-doc-${filename}`,
filename,
fileType: null,
depth,
statusLabel: "Uploading",
}),
);
}
function renderLevel(parentId: string | null, depth: number) {
@ -1477,6 +1740,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const isUploadingVersion = uploadingVersionDocIds.has(
doc.id,
);
const isDeletingDoc = deletingDocIds.has(doc.id);
if (isDeletingDoc) {
return renderDocumentActivityRow({
key: `deleting-doc-${doc.id}`,
filename: doc.filename,
fileType: doc.file_type,
depth,
statusLabel: "Deleting...",
});
}
return (
<div key={`doc-${doc.id}`}>
<div
@ -1535,39 +1808,41 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-4">
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(prev) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(e) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{isProcessing ||
isUploadingVersion ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
) : isError ? (
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
) : (
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(prev) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(e) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
)}
{isError ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<DocIcon
@ -1738,6 +2013,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
doc,
)
}
deleteDisabled={isSharedDocument(
doc,
)}
/>
)}
</div>
@ -1749,7 +2027,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
<DocVersionHistory
docId={doc.id}
filename={docName}
fileType={doc.file_type}
activeVersionNumber={versionNumber}
loading={loadingVersionDocIds.has(doc.id)}
versions={
@ -1944,9 +2221,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
// ── Loading skeleton ──────────────────────────────────────────────────────
if (loading) return <ProjectPageSkeleton />;
if (!project) {
if (!loading && !project) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-gray-400">Project not found</p>
@ -1954,12 +2229,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
);
}
const docs = project.documents || [];
const docs = project?.documents || [];
const sidePanelDoc = viewingDoc
? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc)
: null;
const versionUploadAccept =
versionUploadTargetDoc?.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const versionUploadAccept = ".pdf,.docx,.doc";
const q = search.toLowerCase();
const filteredDocs = q
? docs.filter((d) => d.filename.toLowerCase().includes(q))
@ -2057,17 +2331,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
<>
<button
onClick={() => {
if (loading) return;
setCreatingFolderIn(null);
setNewFolderName("");
}}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<FolderPlus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Subfolder</span>
</button>
<button
onClick={() => setAddDocsOpen(true)}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Documents</span>
@ -2102,7 +2379,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
</div>
) : undefined;
const pendingDeleteDocVersionCount = pendingDeleteDoc
? currentVersionNumber(pendingDeleteDoc)
? (versionsByDocId.get(pendingDeleteDoc.id)?.versions.length ??
currentVersionNumber(pendingDeleteDoc) ??
1)
: 0;
const pendingDeleteDocMessage = pendingDeleteDoc ? (
<div className="space-y-2">
@ -2234,13 +2513,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
/>
<ProjectPageHeader
project={project}
tab={tab}
search={search}
creatingChat={creatingChat}
creatingReview={creatingReview}
docsCount={docs.length}
isOwner={project?.is_owner !== false}
onBackToProjects={() => router.push("/projects")}
onTitleCommit={handleTitleCommit}
onRenameProject={handleTitleCommit}
onRenameCmNumber={handleCmNumberCommit}
onOwnerOnly={setOwnerOnlyAction}
onDeleteProject={requestProjectDelete}
onSearchChange={setSearch}
onOpenPeople={() => setPeopleModalOpen(true)}
onNewChat={handleNewChat}
@ -2261,6 +2543,13 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
{/* Table content */}
<div className="w-full flex-1 min-h-0 overflow-x-auto">
<div className="min-w-max flex min-h-full flex-col">
{loading ? (
<ProjectTableLoading
tab={tab}
stickyCellBg={stickyCellBg}
/>
) : (
<>
{/* Tab: Documents */}
{tab === "documents" && (
<div className="flex-1 flex flex-col min-h-0">
@ -2437,6 +2726,24 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
uploadingVersionDocIds.has(
doc.id,
);
const isDeletingDoc =
deletingDocIds.has(
doc.id,
);
if (isDeletingDoc) {
return renderDocumentActivityRow(
{
key: `deleting-doc-${doc.id}`,
filename:
doc.filename,
fileType:
doc.file_type,
depth: 0,
statusLabel:
"Deleting...",
},
);
}
return (
<div key={doc.id}>
<div
@ -2520,43 +2827,45 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${isVersionDragOver ? "bg-blue-50" : selectedDocIds.includes(doc.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : "group-hover:bg-gray-100"}`}
>
<div className="flex items-center gap-4">
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(
prev,
) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(
e,
) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{isProcessing ||
isUploadingVersion ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
) : isError ? (
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
) : (
<input
type="checkbox"
checked={selectedDocIds.includes(
doc.id,
)}
onChange={() =>
setSelectedDocIds(
(
prev,
) =>
prev.includes(
doc.id,
)
? prev.filter(
(
x,
) =>
x !==
doc.id,
)
: [
...prev,
doc.id,
],
)
}
onClick={(
e,
) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
)}
{isError ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<DocIcon
@ -2741,6 +3050,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
doc,
)
}
deleteDisabled={isSharedDocument(
doc,
)}
/>
)}
</div>
@ -2753,9 +3065,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
filename={
docName
}
fileType={
doc.file_type
}
activeVersionNumber={
versionNumber
}
@ -2907,6 +3216,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
menuDoc,
)
}
deleteDisabled={isSharedDocument(
menuDoc,
)}
/>
) : (
<RowActionMenuItems
@ -3035,21 +3347,27 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setRenameReviewValue={setRenameReviewValue}
/>
)}
</>
)}
</div>
</div>
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={handleDocsSelected}
breadcrumb={[
"Projects",
project.name +
(project.cm_number ? ` (${project.cm_number})` : ""),
"Add Documents",
]}
projectId={projectId}
/>
{project && (
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={handleDocsSelected}
breadcrumb={[
"Projects",
project.name +
(project.cm_number
? ` (${project.cm_number})`
: ""),
"Add Documents",
]}
projectId={projectId}
/>
)}
<DocumentSidePanel
doc={sidePanelDoc}
@ -3083,6 +3401,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
onRenameVersion={handleRenameVersion}
onDeleteVersion={handleDeleteVersion}
onUploadNewVersion={submitNewVersion}
onReplaceVersion={replaceVersionFile}
canDelete={!isSharedDocument(sidePanelDoc)}
onOwnerOnlyAction={setOwnerOnlyAction}
onDelete={async (doc) => {
await handleRemoveDoc(doc.id);
}}
@ -3105,41 +3426,64 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
onClose={() => setOwnerOnlyAction(null)}
/>
<PeopleModal
open={peopleModalOpen}
onClose={() => setPeopleModalOpen(false)}
resource={project}
fetchPeople={getProjectPeople}
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Projects",
project
? project.name +
(project.cm_number ? ` (${project.cm_number})` : "")
: "",
"People",
]}
// Only owners may modify the member list. Without this prop
// PeopleModal renders read-only — non-owners can still see
// who has access but the add/remove controls are hidden.
onSharedWithChange={
project.is_owner === false
? undefined
: async (next) => {
const updated = await updateProject(projectId, {
shared_with: next,
});
setProject((prev) =>
prev
? {
...prev,
shared_with: updated.shared_with,
}
: prev,
);
}
<ConfirmPopup
open={deleteProjectConfirmOpen}
title="Delete project?"
message="This will permanently delete the project and its related documents, chats, and tabular reviews."
confirmLabel="Delete"
confirmStatus={
deleteProjectStatus === "deleting"
? "loading"
: deleteProjectStatus === "deleted"
? "complete"
: "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectConfirmOpen(false);
setDeleteProjectStatus("idle");
}}
onConfirm={() => void confirmProjectDelete()}
/>
{project && (
<PeopleModal
open={peopleModalOpen}
onClose={() => setPeopleModalOpen(false)}
resource={project}
fetchPeople={getProjectPeople}
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Projects",
project.name +
(project.cm_number
? ` (${project.cm_number})`
: ""),
"People",
]}
// Only owners may modify the member list. Without this prop
// PeopleModal renders read-only — non-owners can still see
// who has access but the add/remove controls are hidden.
onSharedWithChange={
project.is_owner === false
? undefined
: async (next) => {
const updated = await updateProject(projectId, {
shared_with: next,
});
setProject((prev) =>
prev
? {
...prev,
shared_with: updated.shared_with,
}
: prev,
);
}
}
/>
)}
</div>
);
}

View file

@ -1,21 +1,23 @@
"use client";
import { type CSSProperties, useState } from "react";
import { type CSSProperties, type KeyboardEvent, useState } from "react";
import {
CornerDownRight,
File,
FileText,
Hash,
Loader2,
MessageSquare,
Search,
Pencil,
Table2,
Trash2,
Users,
} from "lucide-react";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
import type { Project } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { RowActions } from "@/app/components/shared/RowActions";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
export type ProjectTab = "documents" | "assistant" | "reviews";
@ -55,7 +57,14 @@ export function formatDate(iso: string) {
});
}
export function DocIcon({ fileType }: { fileType: string | null }) {
export function DocIcon({
fileType,
muted = false,
}: {
fileType: string | null;
muted?: boolean;
}) {
if (muted) return <File className="h-4 w-4 text-gray-300 shrink-0" />;
if (fileType === "pdf")
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
if (fileType === "docx" || fileType === "doc")
@ -66,7 +75,6 @@ export function DocIcon({ fileType }: { fileType: string | null }) {
export function DocVersionHistory({
docId,
filename,
fileType,
activeVersionNumber,
currentVersionId,
loading,
@ -79,7 +87,6 @@ export function DocVersionHistory({
}: {
docId: string;
filename: string;
fileType: string | null;
activeVersionNumber: number | null;
currentVersionId: string | null;
loading: boolean;
@ -182,6 +189,8 @@ export function DocVersionHistory({
return (
<>
{ordered.map((v) => {
const versionFileType = v.file_type ?? null;
const isDeleted = v.deleted_at != null;
const numberLabel =
typeof v.version_number === "number" &&
v.version_number >= 1
@ -190,6 +199,7 @@ export function DocVersionHistory({
? "Original"
: "—";
const displayLabel = v.filename?.trim() || numberLabel;
const downloadFilename = v.filename?.trim() || filename;
const dt = new Date(v.created_at);
const dateLabel = Number.isNaN(dt.valueOf())
? ""
@ -201,28 +211,42 @@ export function DocVersionHistory({
minute: "2-digit",
});
const isEditing = editingVersionId === v.id;
const rowBg = "bg-gray-100";
const rowBg = isDeleted ? "bg-gray-50" : "bg-gray-100";
const hoverBg = isDeleted ? "hover:bg-gray-50" : "hover:bg-gray-200";
return (
<div
key={`ver-${docId}-${v.id}`}
onClick={() => {
if (isEditing) return;
if (isEditing || isDeleted) return;
onOpenVersion?.(v.id, displayLabel);
}}
className={`group flex h-10 cursor-pointer items-center pr-8 text-sm text-gray-500 transition-colors hover:bg-gray-200 ${rowBg}`}
className={`group flex h-10 items-center pr-8 text-sm transition-colors ${rowBg} ${hoverBg} ${
isDeleted
? "cursor-default text-gray-300"
: "cursor-pointer text-gray-500"
}`}
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-200`}
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${
isDeleted ? "group-hover:bg-gray-50" : "group-hover:bg-gray-200"
}`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-4">
<span className="flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<CornerDownRight
className="h-3.5 w-3.5 text-gray-400"
className={`h-3.5 w-3.5 ${
isDeleted
? "text-gray-300"
: "text-gray-400"
}`}
aria-hidden="true"
/>
</span>
<DocIcon fileType={fileType} />
<DocIcon
fileType={versionFileType}
muted={isDeleted}
/>
{isEditing ? (
<input
autoFocus
@ -243,22 +267,47 @@ export function DocVersionHistory({
className="min-w-0 flex-1 border-b border-gray-300 bg-transparent text-sm text-gray-800 outline-none focus:border-gray-500"
/>
) : (
<span className="truncate text-sm text-gray-700">
<span
className={`truncate text-sm ${
isDeleted
? "text-gray-300"
: "text-gray-700"
}`}
>
{isDeleted && (
<span className="font-medium text-gray-500">
[Deleted]{" "}
</span>
)}
{displayLabel}
</span>
)}
</div>
</div>
<div className="ml-auto w-20 shrink-0 truncate text-xs uppercase text-gray-500">
{fileType ?? <span className="text-gray-300"></span>}
<div
className={`ml-auto w-20 shrink-0 truncate text-xs uppercase ${
isDeleted ? "text-gray-300" : "text-gray-500"
}`}
>
{versionFileType ?? (
<span className="text-gray-300"></span>
)}
</div>
<div className="w-24 shrink-0 truncate text-sm text-gray-400">
</div>
<div className="w-20 shrink-0 truncate pl-1 text-sm text-gray-500">
<div
className={`w-20 shrink-0 truncate pl-1 text-sm ${
isDeleted ? "text-gray-300" : "text-gray-500"
}`}
>
{numberLabel}
</div>
<div className="w-32 shrink-0 truncate text-sm text-gray-500">
<div
className={`w-32 shrink-0 truncate text-sm ${
isDeleted ? "text-gray-300" : "text-gray-500"
}`}
>
{dateLabel ? formatDate(v.created_at) : <span className="text-gray-300"></span>}
</div>
<div className="w-32 shrink-0 truncate text-sm text-gray-400">
@ -268,20 +317,28 @@ export function DocVersionHistory({
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={
onRenameVersion
? () => {
setEditingVersionId(v.id);
setEditingValue(v.filename ?? "");
}
: undefined
}
renameLabel="Rename version"
onDownload={() =>
onDownloadVersion(docId, v.id, filename)
}
/>
{!isDeleted && (
<RowActions
onRename={
onRenameVersion
? () => {
setEditingVersionId(v.id);
setEditingValue(
v.filename ?? "",
);
}
: undefined
}
renameLabel="Rename version"
onDownload={() =>
onDownloadVersion(
docId,
v.id,
downloadFilename,
)
}
/>
)}
</div>
</div>
);
@ -290,116 +347,125 @@ export function DocVersionHistory({
);
}
export function ProjectPageSkeleton() {
return (
<div className="flex-1 overflow-y-auto">
<PageHeader
align="start"
actionGap="lg"
breadcrumbs={[
{ label: "Projects" },
{ loading: true, skeletonClassName: "w-40" },
]}
actionGroups={[
[
{
disabled: true,
iconOnly: true,
title: "Search",
icon: <Search className="h-4 w-4" />,
},
{
disabled: true,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
},
],
[
{
disabled: true,
icon: <MessageSquare className="h-4 w-4" />,
label: <span className="hidden sm:inline">New Chat</span>,
},
{
disabled: true,
icon: <Table2 className="h-4 w-4" />,
label: <span className="hidden sm:inline">New Review</span>,
},
],
]}
/>
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-5">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="ml-auto flex items-center gap-5">
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200">
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
>
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-56 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</div>
);
}
export function ProjectPageHeader({
project,
tab,
search,
creatingChat,
creatingReview,
docsCount,
isOwner,
onBackToProjects,
onTitleCommit,
onRenameProject,
onRenameCmNumber,
onOwnerOnly,
onDeleteProject,
onSearchChange,
onOpenPeople,
onNewChat,
onNewReview,
}: {
project: Project;
tab: ProjectTab;
project: Project | null;
search: string;
creatingChat: boolean;
creatingReview: boolean;
docsCount: number;
isOwner: boolean;
onBackToProjects: () => void;
onTitleCommit: (newName: string) => void | Promise<void>;
onRenameProject: (name: string) => void;
onRenameCmNumber: (cmNumber: string) => void;
onOwnerOnly: (action: string) => void;
onDeleteProject: () => void;
onSearchChange: (search: string) => void;
onOpenPeople: () => void;
onNewChat: () => void;
onNewReview: () => void;
}) {
const [editingField, setEditingField] = useState<"name" | "cm" | null>(
null,
);
const [draft, setDraft] = useState("");
const startEdit = (field: "name" | "cm") => {
if (!project) return;
if (!isOwner) {
onOwnerOnly(
field === "name"
? "rename this project"
: "rename this project's CM number",
);
return;
}
setDraft(field === "name" ? project.name : project.cm_number ?? "");
setEditingField(field);
};
const commitEdit = () => {
if (!editingField) return;
const value = draft.trim();
if (editingField === "name") onRenameProject(value);
else onRenameCmNumber(value);
setEditingField(null);
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
setEditingField(null);
}
};
const editInputClassName =
"min-w-0 cursor-text border-0 border-b border-gray-200 bg-transparent font-serif text-2xl font-medium outline-none transition-colors focus:border-gray-300";
const titleLabel = !project ? undefined : editingField === "name" ? (
<input
autoFocus
value={draft}
size={Math.max(draft.length + 1, 3)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
className={`${editInputClassName} text-gray-900`}
aria-label="Rename project"
/>
) : (
<span
onClick={() => startEdit("name")}
className="inline-block cursor-text"
title="Rename"
>
{project.name}
</span>
);
const cmSuffix = !project ? null : editingField === "cm" ? (
<span className="ml-1 inline-flex items-center text-gray-400">
(#
<input
autoFocus
value={draft}
size={Math.max(draft.length + 1, 3)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
className={`${editInputClassName} text-gray-400`}
aria-label="Rename CM number"
/>
)
</span>
) : project.cm_number ? (
<span
onClick={() => startEdit("cm")}
className="ml-1 inline-block cursor-text text-gray-400"
title="Rename CM"
>
(#{project.cm_number})
</span>
) : null;
return (
<PageHeader
breadcrumbs={[
@ -409,17 +475,16 @@ export function ProjectPageHeader({
title: "Back to Projects",
},
{
label: (
<RenameableTitle
value={project.name}
onCommit={onTitleCommit}
/>
),
suffix: project.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null,
...(project
? {
label: titleLabel,
suffix: cmSuffix,
cursor: "text",
}
: {
loading: true,
skeletonClassName: "w-40",
}),
},
]}
align="start"
@ -438,34 +503,69 @@ export function ProjectPageHeader({
title: "People with access",
icon: <Users className="h-4 w-4" />,
},
],
[
{
onClick: onNewChat,
disabled: creatingChat,
icon: creatingChat ? (
type: "custom",
render: (
<HeaderActionsMenu
items={[
{
label: "Rename",
icon: Pencil,
onSelect: () => startEdit("name"),
},
{
label: "Rename CM",
icon: Hash,
onSelect: () => startEdit("cm"),
},
{
label: "Delete",
icon: Trash2,
onSelect: onDeleteProject,
variant: "danger",
},
]}
/>
),
},
],
{
gap: "xs",
actions: [
{
onClick: onNewChat,
disabled: creatingChat,
icon: creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
),
label: <span className="hidden sm:inline">New Chat</span>,
},
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
icon: creatingReview ? (
label: (
<span className="hidden sm:inline">
New Chat
</span>
),
},
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
icon: creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Table2 className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
New Review
</span>
),
tooltip: docsCount === 0 ? "Upload a document first" : null,
},
],
label: (
<span className="hidden sm:inline">
New Review
</span>
),
tooltip:
docsCount === 0
? "Upload a document first"
: null,
},
],
},
]}
/>
);

View file

@ -206,6 +206,7 @@ export function ProjectsOverview() {
<div className="flex-1 overflow-y-auto">
{/* Page header */}
<PageHeader
loading={loading}
actions={[
{
type: "search",

View file

@ -157,7 +157,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
onClick={onToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-xl hover:bg-gray-100",
"rounded-md hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>

View file

@ -536,8 +536,11 @@ export function DocView({
return (
<DocxView
documentId={doc.document_id}
versionId={doc.version_id ?? null}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
rounded={rounded}
bordered={bordered}
/>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import { MoreHorizontal, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type HeaderActionsMenuItem = {
label: string;
icon?: LucideIcon;
onSelect: () => void;
disabled?: boolean;
variant?: "default" | "danger";
};
export function HeaderActionsMenu({
items,
title = "Actions",
}: {
items: HeaderActionsMenuItem[];
title?: string;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-600 transition-all",
"hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
)}
aria-label={title}
title={title}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[160] w-48 bg-white">
{items.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.label}
disabled={item.disabled}
variant={
item.variant === "danger"
? "destructive"
: "default"
}
onSelect={item.onSelect}
className={cn(
"cursor-pointer text-xs",
item.variant === "danger" &&
"text-red-600 focus:bg-red-50 focus:text-red-700",
)}
>
{Icon && <Icon className="h-3.5 w-3.5" />}
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -117,13 +117,13 @@ export function Modal({
</button>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-1 pb-2">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
{children}
</div>
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 p-4",
"flex items-center gap-3 p-3",
secondaryAction || footerInfo
? "justify-between"
: "justify-end",
@ -186,7 +186,7 @@ function ModalActionButton({
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
variant === "secondary" && "text-gray-600 hover:text-gray-950",
fallbackVariant === "secondary" &&
"rounded-full border border-gray-200/80 bg-gray-100/70 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.78),inset_0_-3px_8px_rgba(148,163,184,0.14)] backdrop-blur-xl hover:bg-gray-100",
"rounded-full border border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.16),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 hover:text-white active:scale-[0.98] disabled:active:scale-100",
variant === "danger" &&
"rounded-full border border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 active:scale-[0.98] disabled:active:scale-100",
)}

View file

@ -16,6 +16,7 @@ export interface PageHeaderBreadcrumb {
label?: ReactNode;
suffix?: ReactNode;
onClick?: () => void;
cursor?: "text";
loading?: boolean;
skeletonClassName?: string;
title?: string;
@ -30,7 +31,6 @@ type PageHeaderButtonAction = {
title?: string;
variant?: "default" | "danger";
iconOnly?: boolean;
className?: string;
tooltip?: ReactNode;
};
@ -70,18 +70,28 @@ export type PageHeaderAction =
| PageHeaderCustomAction
| ReactNode;
type PageHeaderActionGap = "xs" | "sm" | "md" | "lg";
type PageHeaderActionGroup =
| PageHeaderAction[]
| {
actions: PageHeaderAction[];
gap?: PageHeaderActionGap;
};
interface PageHeaderProps {
children?: ReactNode;
actions?: PageHeaderAction[];
actionGroups?: PageHeaderAction[][];
actionGroups?: PageHeaderActionGroup[];
align?: "center" | "start";
shrink?: boolean;
className?: string;
actionGap?: "sm" | "md" | "lg";
actionGap?: PageHeaderActionGap;
breadcrumbs?: PageHeaderBreadcrumb[];
loading?: boolean;
}
const actionGapClassName = {
xs: "gap-1",
sm: "gap-2.5",
md: "gap-2.5",
lg: "gap-2.5",
@ -96,18 +106,24 @@ export function PageHeader({
className,
actionGap = "sm",
breadcrumbs,
loading = false,
}: PageHeaderProps) {
const headerContent = breadcrumbs?.length ? (
<PageHeaderBreadcrumbs items={breadcrumbs} />
) : (
children
);
const actionsDisabled =
loading || !!breadcrumbs?.some((item) => item.loading);
const actionItems = actions?.filter(Boolean) ?? [];
const groupedActionItems =
const groupedActionItems = (
actionGroups
?.map((group) => group.filter(Boolean))
.filter((group) => group.length > 0) ??
(actionItems.length > 0 ? [actionItems] : []);
?.map((group) => normalizeActionGroup(group, actionGap))
.filter((group) => group.actions.length > 0) ??
(actionItems.length > 0
? [{ actions: actionItems, gap: actionGap }]
: [])
);
return (
<div
@ -128,13 +144,16 @@ export function PageHeader({
key={groupIndex}
className={cn(
"flex shrink-0 items-center",
actionGapClassName[actionGap],
actionGapClassName[group.gap],
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
)}
>
{group.map((action, index) => (
{group.actions.map((action, index) => (
<Fragment key={index}>
<PageHeaderActionRenderer action={action} />
<PageHeaderActionRenderer
action={action}
disabled={actionsDisabled}
/>
</Fragment>
))}
</div>
@ -145,21 +164,80 @@ export function PageHeader({
);
}
function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) {
if (!isPageHeaderActionObject(action)) return <>{action}</>;
function normalizeActionGroup(
group: PageHeaderActionGroup,
fallbackGap: PageHeaderActionGap,
) {
if (Array.isArray(group)) {
return {
actions: group.filter(Boolean),
gap: fallbackGap,
};
}
return {
actions: group.actions.filter(Boolean),
gap: group.gap ?? fallbackGap,
};
}
function PageHeaderActionRenderer({
action,
disabled,
}: {
action: PageHeaderAction;
disabled: boolean;
}) {
if (!isPageHeaderActionObject(action)) {
return disabled ? (
<span className="inline-flex h-7 items-center opacity-40 pointer-events-none">
{action}
</span>
) : (
<>{action}</>
);
}
switch (action.type) {
case "search":
return <PageHeaderSearchActionControl action={action} />;
return (
<PageHeaderSearchActionControl
action={action}
disabled={disabled}
/>
);
case "delete":
return <PageHeaderDeleteActionControl action={action} />;
return (
<PageHeaderDeleteActionControl
action={action}
disabled={disabled}
/>
);
case "new":
return <PageHeaderNewActionControl action={action} />;
return (
<PageHeaderNewActionControl
action={action}
disabled={disabled}
/>
);
case "custom":
return <>{action.render}</>;
return (
<span
className={cn(
"inline-flex h-7 items-center",
disabled && "pointer-events-none opacity-40",
)}
>
{action.render}
</span>
);
case "button":
default:
return <PageHeaderButtonActionControl action={action} />;
return (
<PageHeaderButtonActionControl
action={action}
disabled={disabled}
/>
);
}
}
@ -171,20 +249,21 @@ function isPageHeaderActionObject(
function PageHeaderButtonActionControl({
action,
disabled,
}: {
action: PageHeaderButtonAction;
disabled: boolean;
}) {
const iconOnly = action.iconOnly ?? !action.label;
return (
<div className={action.tooltip ? "relative group" : undefined}>
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled}
disabled={disabled || action.disabled}
title={action.title}
aria-label={action.title}
variant={action.variant}
iconOnly={iconOnly}
className={action.className}
>
{action.icon}
{action.label}
@ -200,14 +279,16 @@ function PageHeaderButtonActionControl({
function PageHeaderNewActionControl({
action,
disabled,
}: {
action: PageHeaderNewAction;
disabled: boolean;
}) {
const title = action.title ?? "New";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
disabled={disabled || action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
@ -223,14 +304,16 @@ function PageHeaderNewActionControl({
function PageHeaderDeleteActionControl({
action,
disabled,
}: {
action: PageHeaderDeleteAction;
disabled: boolean;
}) {
const title = action.title ?? "Delete";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
disabled={disabled || action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
@ -247,8 +330,10 @@ function PageHeaderDeleteActionControl({
function PageHeaderSearchActionControl({
action,
disabled,
}: {
action: PageHeaderSearchAction;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -280,6 +365,7 @@ function PageHeaderSearchActionControl({
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
autoFocus
disabled={disabled}
type="text"
placeholder={placeholder}
value={action.value}
@ -290,6 +376,7 @@ function PageHeaderSearchActionControl({
) : (
<PageHeaderActionButton
onClick={() => setOpen(true)}
disabled={disabled}
iconOnly
title={placeholder}
aria-label={placeholder}
@ -301,7 +388,10 @@ function PageHeaderSearchActionControl({
);
}
type PageHeaderActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
type PageHeaderActionButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
variant?: "default" | "danger";
iconOnly?: boolean;
};
@ -333,7 +423,6 @@ function pageHeaderActionControlClassName({
function PageHeaderActionButton({
children,
className,
variant = "default",
iconOnly = false,
disabled,
@ -346,7 +435,6 @@ function PageHeaderActionButton({
variant,
iconOnly,
disabled,
className,
})}
{...props}
>
@ -411,13 +499,21 @@ function BreadcrumbItem({
/>
) : (
<>
<span className="truncate">{item.label}</span>
<span
className={cn(
"truncate",
item.cursor === "text" && "cursor-text",
)}
>
{item.label}
</span>
{showSuffix && item.suffix}
</>
);
const className = cn(
"min-w-0 truncate transition-colors",
item.cursor === "text" && "cursor-text",
current
? "text-gray-900"
: item.onClick

View file

@ -8,6 +8,14 @@ interface Props {
suffix?: React.ReactNode;
}
type CaretDocument = Document & {
caretPositionFromPoint?: (
x: number,
y: number,
) => { offset: number } | null;
caretRangeFromPoint?: (x: number, y: number) => Range | null;
};
export function RenameableTitle({ value, onCommit, suffix }: Props) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@ -15,10 +23,14 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
const escaped = useRef(false);
function startEditing(e: React.MouseEvent) {
const doc = document as any;
const doc = document as CaretDocument;
const caret = doc.caretPositionFromPoint?.(e.clientX, e.clientY);
const range = !caret && doc.caretRangeFromPoint?.(e.clientX, e.clientY);
caretPos.current = caret ? caret.offset : range ? range.startOffset : null;
caretPos.current = caret
? caret.offset
: range
? range.startOffset
: null;
escaped.current = false;
setDraft(value);
setEditing(true);
@ -61,7 +73,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
return (
<span
className="text-gray-900 cursor-text hover:text-gray-600 transition-colors"
className="inline-block cursor-text text-gray-900 transition-colors hover:text-gray-600"
onClick={startEditing}
>
{value}

View file

@ -30,6 +30,7 @@ interface Props {
onUploadNewVersion?: () => void;
onNewSubfolder?: () => void;
deleting?: boolean;
deleteDisabled?: boolean;
onRename?: () => void;
onUpdateCmNumber?: () => void;
newSubfolderLabel?: string;
@ -47,6 +48,7 @@ export function RowActionMenuItems({
onUploadNewVersion,
onNewSubfolder,
deleting,
deleteDisabled = false,
onRename,
onUpdateCmNumber,
newSubfolderLabel = "New subfolder",
@ -141,7 +143,12 @@ export function RowActionMenuItems({
<button
onClick={() => { onClose(); onDelete(); }}
disabled={deleting}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
aria-disabled={deleteDisabled}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
deleteDisabled
? "cursor-not-allowed opacity-40 hover:bg-transparent"
: "hover:bg-red-50"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
{deleteLabel}

View file

@ -52,7 +52,7 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
if (!open || !doc) return null;
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const accept = ".pdf,.docx,.doc";
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;

View file

@ -32,6 +32,8 @@ export interface Document {
project_id: string | null;
folder_id?: string | null;
filename: string;
owner_email?: string | null;
owner_display_name?: string | null;
file_type: string | null; // pdf | docx | doc
storage_path: string | null;
pdf_storage_path: string | null;

View file

@ -0,0 +1,155 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, Loader2, Search } from "lucide-react";
import { listWorkflows } from "@/app/lib/mikeApi";
import { Modal } from "@/app/components/shared/Modal";
import type { Workflow } from "@/app/components/shared/types";
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
import { cn } from "@/lib/utils";
interface Props {
open: boolean;
applying?: boolean;
onClose: () => void;
onApply: (workflow: Workflow) => Promise<void> | void;
}
export function ApplyWorkflowPresetModal({
open,
applying = false,
onClose,
onApply,
}: Props) {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
(workflow) => workflow.type === "tabular",
);
setLoading(true);
setSearch("");
setSelectedWorkflowId(null);
listWorkflows("tabular")
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
.catch(() => setWorkflows(builtinTabular))
.finally(() => setLoading(false));
}, [open]);
const filteredWorkflows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return workflows;
return workflows.filter((workflow) =>
[workflow.title, workflow.practice ?? ""]
.join(" ")
.toLowerCase()
.includes(q),
);
}, [search, workflows]);
const selectedWorkflow =
workflows.find((workflow) => workflow.id === selectedWorkflowId) ?? null;
const canApply =
!!selectedWorkflow &&
!applying &&
!loading &&
!!selectedWorkflow.columns_config?.length;
return (
<Modal
open={open}
onClose={onClose}
title="Apply preset workflow"
size="md"
primaryAction={{
label: applying ? "Applying..." : "Apply",
onClick: () => {
if (selectedWorkflow) void onApply(selectedWorkflow);
},
disabled: !canApply,
icon: applying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : undefined,
}}
cancelAction={{ label: "Cancel", onClick: onClose }}
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<p className="text-sm text-gray-500">
Choose a tabular review workflow. Applying it will replace
the current review columns with the workflow preset.
</p>
<div className="flex h-9 items-center gap-2 rounded-xl bg-gray-100 px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search workflows..."
className="min-w-0 flex-1 bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-400"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-1.5">
{loading ? (
<div className="space-y-2 p-1">
{[1, 2, 3, 4].map((index) => (
<div
key={index}
className="h-14 animate-pulse rounded-xl bg-white"
/>
))}
</div>
) : filteredWorkflows.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-gray-400">
No workflows found
</div>
) : (
filteredWorkflows.map((workflow) => {
const selected = workflow.id === selectedWorkflowId;
const columnCount =
workflow.columns_config?.length ?? 0;
return (
<button
key={workflow.id}
type="button"
onClick={() =>
setSelectedWorkflowId(workflow.id)
}
disabled={columnCount === 0}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
selected
? "bg-white text-gray-950 shadow-[0_1px_4px_rgba(15,23,42,0.06)]"
: "text-gray-700 hover:bg-white/75",
columnCount === 0 &&
"cursor-not-allowed opacity-45",
)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{workflow.title}
</span>
<span className="mt-0.5 block truncate text-xs text-gray-400">
{workflow.practice ?? "Tabular"} ·{" "}
{columnCount}{" "}
{columnCount === 1
? "column"
: "columns"}
</span>
</span>
{selected && (
<Check className="h-4 w-4 shrink-0 text-green-600" />
)}
</button>
);
})
)}
</div>
</div>
</Modal>
);
}

View file

@ -472,7 +472,7 @@ function TRAssistantMessage({
title={`${cit.col_name} · ${cit.doc_name.replace(/\.[^.]+$/, "")}`}
className="mx-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-[10px] font-medium bg-gray-100 text-gray-900 hover:bg-gray-200 transition-colors align-super font-serif"
>
{idx + 1}
{cit.ref}
</button>
);
}

View file

@ -2,10 +2,24 @@
import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload, X } from "lucide-react";
import {
Plus,
Loader2,
Play,
ChevronDown,
MessageSquare,
Download,
Users,
Upload,
X,
Pencil,
Trash2,
WandSparkles,
} from "lucide-react";
import {
clearTabularCells,
deleteTabularReview,
getTabularReview,
getProject,
getTabularReviewPeople,
@ -20,14 +34,17 @@ import type {
Project,
TabularCell,
TabularReview,
Workflow,
} from "../shared/types";
import { AddColumnModal } from "./AddColumnModal";
import { ApplyWorkflowPresetModal } from "./ApplyWorkflowPresetModal";
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 { ConfirmPopup } from "../shared/ConfirmPopup";
import { HeaderActionsMenu } from "../shared/HeaderActionsMenu";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import {
@ -62,6 +79,14 @@ export function TRView({ reviewId, projectId }: Props) {
const [addColOpen, setAddColOpen] = useState(false);
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [workflowPresetModalOpen, setWorkflowPresetModalOpen] =
useState(false);
const [applyingWorkflowPreset, setApplyingWorkflowPreset] = useState(false);
const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] =
useState(false);
const [deleteReviewStatus, setDeleteReviewStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const { user } = useAuth();
const [expandedCell, setExpandedCell] = useState<TabularCell | null>(null);
@ -516,10 +541,97 @@ export function TRView({ reviewId, projectId }: Props) {
async function handleTitleCommit(newTitle: string) {
if (!newTitle || newTitle === review?.title) return;
if (review?.is_owner === false) {
setOwnerOnlyAction("rename this tabular review");
return;
}
setReview((prev) => (prev ? { ...prev, title: newTitle } : prev));
await updateTabularReview(reviewId, { title: newTitle });
}
function requestReviewRename() {
if (review?.is_owner === false) {
setOwnerOnlyAction("rename this tabular review");
return;
}
const nextTitle = window.prompt(
"Rename tabular review",
review?.title ?? "Untitled Review",
);
const trimmed = nextTitle?.trim();
if (!trimmed) return;
void handleTitleCommit(trimmed);
}
function requestReviewDelete() {
if (review?.is_owner === false) {
setOwnerOnlyAction("delete this tabular review");
return;
}
setDeleteReviewStatus("idle");
setDeleteReviewConfirmOpen(true);
}
async function confirmReviewDelete() {
if (deleteReviewStatus === "deleting") return;
setDeleteReviewStatus("deleting");
try {
await deleteTabularReview(reviewId);
setDeleteReviewStatus("deleted");
setTimeout(() => {
router.push(
projectId
? `/projects/${projectId}?tab=reviews`
: "/tabular-reviews",
);
}, 250);
} catch (err) {
setDeleteReviewStatus("idle");
console.error("Failed to delete tabular review", err);
}
}
function requestWorkflowPreset() {
if (review?.is_owner === false) {
setOwnerOnlyAction("apply a preset workflow");
return;
}
setWorkflowPresetModalOpen(true);
}
async function handleApplyWorkflowPreset(workflow: Workflow) {
if (!workflow.columns_config?.length) return;
const nextColumns = workflow.columns_config.map((column, index) => ({
...column,
index,
}));
const previousColumns = columns;
const previousCells = cells;
setApplyingWorkflowPreset(true);
setColumns(nextColumns);
setCells([]);
try {
await saveColumnsConfig(nextColumns);
if (documents.length > 0) {
try {
await clearTabularCells(
reviewId,
documents.map((document) => document.id),
);
} catch (err) {
console.error("Failed to clear old tabular cells", err);
}
}
setWorkflowPresetModalOpen(false);
} catch (err) {
setColumns(previousColumns);
setCells(previousCells);
console.error("Failed to apply workflow preset", err);
} finally {
setApplyingWorkflowPreset(false);
}
}
const q = search.toLowerCase();
const filteredDocuments = q
? documents.filter((d) => d.filename.toLowerCase().includes(q))
@ -573,75 +685,94 @@ export function TRView({ reviewId, projectId }: Props) {
skeletonClassName: "w-40",
}
: {
label: (
<RenameableTitle
value={review?.title || "Untitled Review"}
onCommit={handleTitleCommit}
/>
),
label: review?.title || "Untitled Review",
},
]}
actions={
!loading
? [
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search documents…",
},
!projectId
? {
onClick: () =>
setPeopleModalOpen(true),
disabled: loading,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
}
: null,
{
onClick: () =>
exportTabularReviewToExcel({
reviewTitle:
review?.title ||
"Tabular Review",
columns,
documents,
cells,
}),
disabled:
columns.length === 0 ||
documents.length === 0,
title: "Export to Excel",
icon: <Download className="h-4 w-4" />,
label: (
<span className="hidden sm:inline">
Export
</span>
),
},
{
onClick: handleGenerate,
disabled:
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig,
icon: generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
{generating ? "Running…" : "Run"}
</span>
),
},
]
: undefined
}
actionGroups={[
[
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search documents…",
},
!projectId
? {
onClick: () => setPeopleModalOpen(true),
disabled: loading,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
}
: null,
{
type: "custom",
render: (
<HeaderActionsMenu
items={[
{
label: "Rename",
icon: Pencil,
onSelect: requestReviewRename,
},
{
label: "Apply preset workflow",
icon: WandSparkles,
onSelect:
requestWorkflowPreset,
},
{
label: "Delete",
icon: Trash2,
onSelect: requestReviewDelete,
variant: "danger",
},
]}
/>
),
},
],
[
{
onClick: () =>
exportTabularReviewToExcel({
reviewTitle:
review?.title || "Tabular Review",
columns,
documents,
cells,
}),
disabled:
columns.length === 0 ||
documents.length === 0,
title: "Export to Excel",
icon: <Download className="h-4 w-4" />,
label: (
<span className="hidden sm:inline">
Export
</span>
),
},
{
onClick: handleGenerate,
disabled:
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig,
icon: generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
{generating ? "Running…" : "Run"}
</span>
),
},
],
]}
/>
{/* Toolbar */}
@ -926,6 +1057,37 @@ export function TRView({ reviewId, projectId }: Props) {
}
/>
<ApplyWorkflowPresetModal
open={workflowPresetModalOpen}
applying={applyingWorkflowPreset}
onClose={() => {
if (applyingWorkflowPreset) return;
setWorkflowPresetModalOpen(false);
}}
onApply={handleApplyWorkflowPreset}
/>
<ConfirmPopup
open={deleteReviewConfirmOpen}
title="Delete tabular review?"
message="This will permanently delete the tabular review and its generated cells."
confirmLabel="Delete"
confirmStatus={
deleteReviewStatus === "deleting"
? "loading"
: deleteReviewStatus === "deleted"
? "complete"
: "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (deleteReviewStatus === "deleting") return;
setDeleteReviewConfirmOpen(false);
setDeleteReviewStatus("idle");
}}
onConfirm={() => void confirmReviewDelete()}
/>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}

View file

@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
ChevronDown,
Folder,
@ -23,6 +22,7 @@ import { useDirectoryData } from "../shared/useDirectoryData";
import { FileDirectory } from "../shared/FileDirectory";
import type { Project } from "../shared/types";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { Modal } from "../shared/Modal";
interface Props {
workflows: Workflow[];
@ -177,7 +177,7 @@ function MarkdownBody({ content }: { content: string }) {
// ---------------------------------------------------------------------------
function AssistantPanel({ workflow }: { workflow: Workflow }) {
return (
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
<div className="flex-1 flex flex-col overflow-hidden">
<div className="py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">
Workflow Prompt
@ -202,7 +202,7 @@ function TabularPanel({ workflow }: { workflow: Workflow }) {
);
return (
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
<div className="flex-1 flex flex-col overflow-hidden">
<div className="py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">Columns</p>
</div>
@ -450,60 +450,86 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
p.documents.length > 0,
);
const breadcrumbs =
screen === "select"
? ["Workflows", "Select workflow"]
: [
<button
key="workflows"
type="button"
onClick={() => setScreen("select")}
className="transition-colors hover:text-gray-700"
>
Workflows
</button>,
wf.title,
wf.type === "assistant" ? "New Chat" : "New Review",
];
const selectPageAction = () => {
router.push(`/workflows/${wf.id}`);
handleClose();
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div
className={`w-full rounded-2xl bg-white shadow-2xl flex flex-col h-[600px] transition-all duration-200 ${screen === "select" ? "max-w-4xl" : "max-w-2xl"}`}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 shrink-0">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{screen === "select" ? (
<>
<span>Workflows</span>
<span></span>
<span>Select workflow</span>
</>
) : (
<>
<button
onClick={() => setScreen("select")}
className="hover:text-gray-700 transition-colors"
>
Workflows
</button>
<span></span>
<span className="truncate max-w-[160px]">
{wf.title}
</span>
<span></span>
<span>
{wf.type === "assistant"
? "New Chat"
: "New Review"}
</span>
</>
)}
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
</div>
return (
<Modal
open={!!workflow}
onClose={handleClose}
size={screen === "select" ? "xl" : "lg"}
breadcrumbs={breadcrumbs}
secondaryAction={
screen === "select"
? {
label: wf.is_system ? "View Page" : "Edit",
onClick: selectPageAction,
}
: undefined
}
footerStatus={
screen === "configure" &&
(wf.type === "assistant"
? !inProject && selectedDocIds.size > 0
: selectedDocIds.size > 0) ? (
<span className="text-xs text-gray-400">
{selectedDocIds.size} selected
</span>
) : null
}
primaryAction={
screen === "select"
? {
label: "Use",
onClick: () => setScreen("configure"),
}
: wf.type === "assistant"
? {
label: saving ? "Starting…" : "Start Chat",
onClick: handleStartChat,
disabled:
saving || (inProject && !selectedProjectId),
}
: {
label: saving ? "Creating…" : "Create Review",
onClick: handleCreateReview,
disabled:
saving ||
selectedDocIds.size === 0 ||
(inProject && !selectedProjectId),
}
}
cancelAction={false}
>
{/* ── SELECT SCREEN ── */}
{screen === "select" && (
<>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
{/* Left: workflow list */}
<div className="w-80 shrink-0 flex flex-col border-t border-gray-200">
<div className="w-80 shrink-0 flex flex-col overflow-hidden">
{/* Search */}
<div className="px-3 py-2 shrink-0 border-b border-gray-100">
<div className="px-2 py-3 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -533,7 +559,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
ref={isSelected ? selectedRowRef : null}
type="button"
onClick={() => setSelected(wfItem)}
className={`w-full flex items-center gap-3 px-4 py-3 text-xs text-left border-b border-gray-200 transition-colors ${isSelected ? "bg-gray-100" : "hover:bg-gray-50"}`}
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${isSelected ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50"}`}
>
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
{wfItem.title}
@ -551,46 +577,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
) : (
<TabularPanel key={wf.id} workflow={wf} />
)}
</div>
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
{wf.is_system ? (
<button
onClick={() => {
router.push(`/workflows/${wf.id}`);
handleClose();
}}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
>
View Page
</button>
) : (
<button
onClick={() => {
router.push(`/workflows/${wf.id}`);
handleClose();
}}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
>
Edit
</button>
)}
<button
onClick={() => setScreen("configure")}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700"
>
Use
</button>
</div>
</>
</div>
)}
{/* ── ASSISTANT CONFIGURE SCREEN ── */}
{screen === "configure" && wf.type === "assistant" && (
<>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* Add-on prompt */}
<div className="px-5 pb-3 shrink-0">
<div className="pb-3 shrink-0">
<p className="text-xs font-medium text-gray-700 mb-2">
Message (optional)
</p>
@ -606,7 +600,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
{/* Toggle row */}
<div className="px-5 py-3 flex flex-col gap-2 shrink-0">
<div className="py-3 flex flex-col gap-2 shrink-0">
<span className="text-xs font-medium text-gray-700">
Create in a project
</span>
@ -623,12 +617,12 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
{inProject ? (
<>
<div className="px-5 pt-1 pb-1 shrink-0">
<div className="pt-1 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select project
</p>
</div>
<div className="px-5 pb-2 shrink-0">
<div className="pb-2 shrink-0">
<SimpleProjectPicker
projects={projects}
selectedId={selectedProjectId}
@ -638,14 +632,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</>
) : (
<>
<div className="px-5 pt-1 pb-1 shrink-0">
<div className="pt-1 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select documents
</p>
</div>
{/* Search */}
<div className="px-4 pt-1.5 pb-1 shrink-0">
<div className="pt-1.5 pb-1 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -671,7 +665,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
{/* File browser */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
<div className="flex-1 overflow-y-auto pb-2">
<FileDirectory
standaloneDocs={filteredStandalone}
directoryProjects={
@ -691,33 +685,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
</>
)}
</div>
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
<span className="text-xs text-gray-400">
{!inProject && selectedDocIds.size > 0
? `${selectedDocIds.size} selected`
: ""}
</span>
<button
onClick={handleStartChat}
disabled={
saving || (inProject && !selectedProjectId)
}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
>
{saving ? "Starting…" : "Start Chat"}
</button>
</div>
</>
</div>
)}
{/* ── TABULAR CONFIGURE SCREEN ── */}
{screen === "configure" && wf.type === "tabular" && (
<>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* Toggle stacked */}
<div className="px-5 pb-3 flex flex-col gap-2 shrink-0">
<div className="pb-3 flex flex-col gap-2 shrink-0">
<span className="text-xs font-medium text-gray-700">
Create in a project
</span>
@ -735,12 +710,12 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
{/* Project section */}
{inProject && (
<>
<div className="px-5 pt-1 pb-1 shrink-0">
<div className="pt-1 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select Project
</p>
</div>
<div className="px-5 pb-2 shrink-0">
<div className="pb-2 shrink-0">
<SimpleProjectPicker
projects={projects}
selectedId={selectedProjectId}
@ -757,14 +732,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
)}
{/* Documents section */}
<div className="px-5 pt-3 pb-1 shrink-0">
<div className="pt-3 pb-1 shrink-0">
<p className="text-xs font-medium text-gray-700">
Select Documents
</p>
</div>
{/* Search */}
<div className="px-4 pt-1.5 pb-1 shrink-0">
<div className="pt-1.5 pb-1 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -788,7 +763,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
</div>
{/* File browser */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
<div className="flex-1 overflow-y-auto pb-2">
<FileDirectory
standaloneDocs={
inProject
@ -812,30 +787,8 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
}
/>
</div>
</div>
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
<span className="text-xs text-gray-400">
{selectedDocIds.size > 0
? `${selectedDocIds.size} selected`
: ""}
</span>
<button
onClick={handleCreateReview}
disabled={
saving ||
selectedDocIds.size === 0 ||
(inProject && !selectedProjectId)
}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
>
{saving ? "Creating…" : "Create Review"}
</button>
</div>
</>
</div>
)}
</div>
</div>,
document.body,
</Modal>
);
}

View file

@ -361,6 +361,7 @@ export function WorkflowList() {
{/* Page header */}
<PageHeader
shrink
loading={loading}
actions={[
{
type: "search",
@ -421,11 +422,13 @@ export function WorkflowList() {
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
>
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="w-28 shrink-0">
<div className="ml-auto w-28 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-40 shrink-0">