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

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

View file

@ -1,26 +1,31 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X, Upload, Search, Loader2 } from "lucide-react";
import { AlertCircle, Upload, Search, Loader2, X } from "lucide-react";
import {
uploadStandaloneDocument,
uploadProjectDocument,
addDocumentToProject,
deleteDocument,
} from "@/app/lib/mikeApi";
import type { MikeDocument } from "./types";
import type { Document } from "./types";
import { FileDirectory } from "./FileDirectory";
import { useDirectoryData, invalidateDirectoryCache } from "./useDirectoryData";
import { OwnerOnlyModal } from "./OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
import { Modal } from "./Modal";
import {
SUPPORTED_DOCUMENT_ACCEPT,
formatUnsupportedDocumentWarning,
partitionSupportedDocumentFiles,
} from "@/app/lib/documentUploadValidation";
export { invalidateDirectoryCache };
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: MikeDocument[], projectId?: string) => void;
onSelect: (documents: Document[], projectId?: string) => void;
breadcrumb: string[];
allowMultiple?: boolean;
projectId?: string;
@ -39,8 +44,9 @@ export function AddDocumentsModal({
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [extraUploadedDocs, setExtraUploadedDocs] = useState<MikeDocument[]>([]);
const [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]);
// IDs deleted in this session — hidden locally since `useDirectoryData`'s
// cached state won't re-fetch until the modal reopens.
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
@ -54,6 +60,7 @@ export function AddDocumentsModal({
setExtraUploadedDocs([]);
setDeletedIds(new Set());
setUploadingFilenames([]);
setUploadWarning(null);
}, [open]);
if (!open) return null;
@ -68,7 +75,9 @@ export function AddDocumentsModal({
].filter((d) => !deletedIds.has(d.id));
const filteredStandalone = q
? allStandalone.filter((d) => d.filename.toLowerCase().includes(q))
? allStandalone.filter((d) =>
d.filename.toLowerCase().includes(q),
)
: allStandalone;
const filteredProjects = projects
@ -78,7 +87,8 @@ export function AddDocumentsModal({
documents: (p.documents || []).filter(
(d) =>
!deletedIds.has(d.id) &&
(!q || d.filename.toLowerCase().includes(q)),
(!q ||
d.filename.toLowerCase().includes(q)),
),
}))
.filter(
@ -134,7 +144,7 @@ export function AddDocumentsModal({
async function handleDelete(ids: string[]) {
// Server only allows the doc creator to delete. Filter to owned
// and warn for the rest.
const docsById = new Map<string, MikeDocument>();
const docsById = new Map<string, Document>();
for (const d of [
...standaloneDocuments,
...extraUploadedDocs,
@ -177,11 +187,17 @@ export function AddDocumentsModal({
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setUploadingFilenames(files.map((file) => file.name));
const { supported, unsupported } = partitionSupportedDocumentFiles(files);
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
if (supported.length === 0) {
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
setUploadingFilenames(supported.map((file) => file.name));
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) =>
supported.map((f) =>
projectId
? uploadProjectDocument(projectId, f)
: uploadStandaloneDocument(f),
@ -201,29 +217,45 @@ export function AddDocumentsModal({
}
}
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{breadcrumb.map((segment, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <span></span>}
{segment}
</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={open}
onClose={onClose}
breadcrumbs={breadcrumb}
secondaryAction={{
label: uploading ? "Uploading…" : "Upload",
icon: uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
),
onClick: () => fileInputRef.current?.click(),
disabled: uploading,
}}
footerStatus={
selectedIds.size > 0 ? (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
) : null
}
primaryAction={{
label: uploading ? "Saving…" : "Confirm",
onClick: handleConfirm,
disabled: selectedIds.size === 0 || uploading,
}}
>
<input
ref={fileInputRef}
type="file"
accept={SUPPORTED_DOCUMENT_ACCEPT}
multiple
className="hidden"
onChange={handleUpload}
/>
{/* Search bar */}
<div className="px-4 pt-1 pb-2">
<div className="pt-1 pb-2">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
@ -245,76 +277,40 @@ export function AddDocumentsModal({
</div>
</div>
{/* File browser */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
<FileDirectory
standaloneDocs={filteredStandalone}
directoryProjects={filteredProjects}
loading={loading}
selectedIds={selectedIds}
onChange={setSelectedIds}
allowMultiple={allowMultiple}
forceExpanded={!!q}
emptyMessage={
q ? "No matches found" : "No documents yet"
}
onDelete={handleDelete}
uploadingFilenames={uploadingFilenames}
/>
</div>
{uploadWarning && (
<div className="mb-2 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-xs text-gray-900">
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" />
<span className="min-w-0 flex-1">{uploadWarning}</span>
<button
type="button"
onClick={() => setUploadWarning(null)}
className="shrink-0 rounded p-0.5 text-black hover:bg-gray-100"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3">
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc"
multiple
className="hidden"
onChange={handleUpload}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
)}
<button
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedIds.size === 0 || uploading}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
>
{uploading ? "Saving…" : "Confirm"}
</button>
</div>
</div>
</div>
{/* File browser */}
<FileDirectory
standaloneDocs={filteredStandalone}
directoryProjects={filteredProjects}
loading={loading}
selectedIds={selectedIds}
onChange={setSelectedIds}
allowMultiple={allowMultiple}
forceExpanded={!!q}
emptyMessage={q ? "No matches found" : "No documents yet"}
onDelete={handleDelete}
uploadingFilenames={uploadingFilenames}
/>
</Modal>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}
onClose={() => setOwnerOnlyAction(null)}
/>
</div>,
document.body,
</>
);
}

View file

@ -1,17 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, Loader2, Search, Upload, X } from "lucide-react";
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
import type { MikeDocument } from "./types";
import type { Document } from "./types";
import { DocFileIcon } from "./FileDirectory";
import { VersionChip } from "./VersionChip";
import { Modal } from "./Modal";
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: MikeDocument[]) => void;
onSelect: (documents: Document[]) => void;
breadcrumb: string[];
projectId: string;
/** Docs already in the target list — rendered checked + disabled. */
@ -37,7 +37,7 @@ export function AddProjectDocsModal({
excludeDocIds,
allowMultiple = true,
}: Props) {
const [docs, setDocs] = useState<MikeDocument[]>([]);
const [docs, setDocs] = useState<Document[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@ -115,185 +115,147 @@ export function AddProjectDocsModal({
}
}
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{breadcrumb.map((segment, i) => (
<span
key={i}
className="flex items-center gap-1.5"
>
{i > 0 && <span></span>}
{segment}
</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>
{/* Search */}
<div className="px-4 pt-1 pb-2">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
autoFocus
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* File list */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
{loading ? (
<div className="rounded-sm border border-gray-100 overflow-hidden">
{[60, 45, 75, 55, 40].map((w, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-2"
>
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-200 animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
</div>
) : filtered.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">
{q ? "No matches found" : "No documents in this project"}
</p>
) : (
<div className="rounded-sm border border-gray-100 overflow-hidden">
{filtered.map((doc) => {
const excluded = isExcluded(doc.id);
const checked =
excluded || selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
disabled={excluded}
onClick={() => toggle(doc.id)}
className={`w-full flex items-center gap-2 px-2 py-2 text-xs text-left transition-colors ${
excluded
? "opacity-50 cursor-not-allowed"
: checked
? "bg-gray-100"
: "hover:bg-gray-50"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
checked
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{checked && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon
fileType={doc.file_type}
/>
<span
className={`flex-1 truncate ${
checked
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
{excluded && (
<span className="text-[10px] text-gray-400 shrink-0">
Already added
</span>
)}
<VersionChip
n={doc.latest_version_number}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(doc.created_at)}
</span>
)}
</button>
);
})}
</div>
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={breadcrumb}
secondaryAction={{
label: uploading ? "Uploading…" : "Upload",
icon: uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
),
onClick: () => fileInputRef.current?.click(),
disabled: uploading,
}}
footerStatus={
selectedIds.size > 0 ? (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
) : null
}
primaryAction={{
label: "Confirm",
onClick: handleConfirm,
disabled: selectedIds.size === 0 || uploading,
}}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc"
multiple
className="hidden"
onChange={handleUpload}
/>
{/* Search */}
<div className="pt-1 pb-2">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
autoFocus
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3">
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc"
multiple
className="hidden"
onChange={handleUpload}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
)}
<button
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedIds.size === 0 || uploading}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
>
Confirm
</button>
</div>
</div>
</div>
</div>,
document.body,
{/* File list */}
{loading ? (
<div className="rounded-sm border border-gray-100 overflow-hidden">
{[60, 45, 75, 55, 40].map((w, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-2"
>
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-200 animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
</div>
) : filtered.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">
{q ? "No matches found" : "No documents in this project"}
</p>
) : (
<div className="rounded-sm border border-gray-100 overflow-hidden">
{filtered.map((doc) => {
const excluded = isExcluded(doc.id);
const checked = excluded || selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
disabled={excluded}
onClick={() => toggle(doc.id)}
className={`w-full flex items-center gap-2 px-2 py-2 text-xs text-left transition-colors ${
excluded
? "opacity-50 cursor-not-allowed"
: checked
? "bg-gray-100"
: "hover:bg-gray-50"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
checked
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{checked && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`flex-1 truncate ${
checked
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
{excluded && (
<span className="text-[10px] text-gray-400 shrink-0">
Already added
</span>
)}
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(doc.created_at)}
</span>
)}
</button>
);
})}
</div>
)}
</Modal>
);
}

View file

@ -1,9 +1,9 @@
"use client";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { AlertTriangle, X } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
import { WarningPopup } from "./WarningPopup";
interface Props {
open: boolean;
@ -27,52 +27,19 @@ export function ApiKeyMissingModal({ open, onClose, provider, message }: Props)
router.push("/account/models");
};
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<h2 className="text-base font-medium text-gray-900">
API key required
</h2>
</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>
<div className="px-5 pb-2 pt-1">
<p className="text-sm text-gray-600 leading-relaxed">
{body}
</p>
</div>
<div className="flex justify-end gap-2 px-5 pb-5 pt-3">
<button
onClick={onClose}
className="rounded-lg px-4 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleGoToAccount}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
>
Go to account settings
</button>
</div>
</div>
</div>,
document.body,
return (
<WarningPopup
open={open}
onClose={onClose}
title="API key required"
message={body}
icon={
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
}
primaryAction={{
label: "Go to account settings",
onClick: handleGoToAccount,
}}
/>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import {
PanelLeft,
MessageSquare,
@ -19,7 +19,8 @@ import Link from "next/link";
import { MikeIcon } from "@/components/chat/mike-icon";
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
import { listProjects } from "@/app/lib/mikeApi";
import type { MikeProject } from "@/app/components/shared/types";
import type { Project } from "@/app/components/shared/types";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
@ -36,15 +37,20 @@ interface AppSidebarProps {
export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
const { user } = useAuth();
const { profile } = useUserProfile();
const {
chats,
currentChatId,
hasMoreChats,
loadMoreChats,
setCurrentChatId,
} = useChatHistoryContext();
const { chats, hasMoreChats, loadMoreChats, setCurrentChatId } =
useChatHistoryContext();
const router = useRouter();
const pathname = usePathname();
const routeChatId = useMemo(() => {
if (pathname.startsWith("/assistant/chat/")) {
return pathname.split("/").pop() ?? null;
}
const projectChatMatch = pathname.match(
/^\/projects\/[^/]+\/assistant\/chat\/([^/]+)/,
);
return projectChatMatch?.[1] ?? null;
}, [pathname]);
const [shouldAnimate, setShouldAnimate] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [projectsCollapsed, setProjectsCollapsed] = useState(false);
@ -52,7 +58,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
const [projectNames, setProjectNames] = useState<Record<string, string>>(
{},
);
const [recentProjects, setRecentProjects] = useState<MikeProject[] | null>(
const [recentProjects, setRecentProjects] = useState<Project[] | null>(
null,
);
@ -93,24 +99,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
}, [isDropdownOpen]);
useEffect(() => {
if (pathname.startsWith("/assistant/chat/")) {
const chatId = pathname.split("/").pop() ?? null;
setCurrentChatId(chatId);
return;
}
const projectChatMatch = pathname.match(
/^\/projects\/[^/]+\/assistant\/chat\/([^/]+)/,
);
if (projectChatMatch) {
setCurrentChatId(projectChatMatch[1]);
return;
}
if (pathname === "/assistant") {
setCurrentChatId(null);
}
}, [pathname, setCurrentChatId]);
setCurrentChatId(routeChatId);
}, [routeChatId, setCurrentChatId]);
const getUserInitials = (email: string) => {
if (profile?.displayName)
@ -132,11 +122,13 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
return (
<div
className={`${
className={cn(
isOpen
? "w-64 h-dvh bg-gray-50 border-r"
: "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent pointer-events-none md:pointer-events-auto"
} border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-[99] overflow-visible`}
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65"
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto",
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-2px_7px_rgba(15,23,42,0.044),0_5px_12px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
)}
>
{/* Toggle + Logo */}
<div
@ -145,7 +137,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
}`}
>
{isOpen && (
<div className="px-2.5">
<div className="px-2">
<Link
href="/assistant"
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
@ -163,7 +155,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
)}
<button
onClick={onToggle}
className="h-9 w-9 p-2.5 items-center flex hover:bg-gray-100 rounded-md transition-colors"
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-xl hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>
<PanelLeft className="h-4 w-4" />
@ -173,17 +168,24 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
{/* Nav items */}
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const isActive =
pathname === href || pathname.startsWith(href + "/");
href === "/assistant"
? pathname === href
: href === "/projects"
? pathname === href
: pathname === href ||
pathname.startsWith(href + "/");
return (
<div key={href} className="py-0.5 px-2.5">
<button
onClick={() => router.push(href)}
title={!isOpen ? label : ""}
className={`w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left ${
className={cn(
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
isActive
? "bg-gray-100 text-gray-900"
: "hover:bg-gray-100 text-gray-700"
} ${!isOpen ? "hidden md:flex" : "flex"}`}
? "bg-gray-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
!isOpen ? "hidden md:flex" : "flex",
)}
>
<Icon
className={`h-4 w-4 flex-shrink-0 ${
@ -271,11 +273,12 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
)
}
title={project.name}
className={`flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors ${
className={cn(
"flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors",
isActive
? "bg-gray-100 text-gray-900"
: "text-gray-700 hover:bg-gray-100"
}`}
? "bg-gray-200/60 text-gray-900"
: "text-gray-700 hover:bg-gray-100",
)}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-500" />
<span className="min-w-0 flex-1 truncate">
@ -346,7 +349,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
key={chat.id}
chat={chat}
isActive={
currentChatId === chat.id
routeChatId === chat.id
}
projectName={
chat.project_id
@ -370,7 +373,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
<div className="px-2.5 pt-1">
<button
onClick={loadMoreChats}
className="flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
className={cn(
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
"hover:bg-gray-100",
)}
>
Load more
</button>
@ -384,21 +390,22 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
)}
{/* User Profile */}
<div className="mt-auto">
<div className="mt-auto p-1">
{user && (
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className={`flex items-center transition-colors w-full px-3.5 py-4 border-t border-gray-200 ${
!isOpen ? "hidden md:flex" : ""
} ${
className={cn(
"flex items-center transition-colors w-full px-2.5 py-3 border-t",
"rounded-xl border-white/60",
!isOpen ? "hidden md:flex" : "",
pathname === "/account" || isDropdownOpen
? "bg-gray-100"
: "hover:bg-gray-100"
}`}
? "bg-gray-200/60"
: "hover:bg-gray-100",
)}
title={!isOpen ? user.email : undefined}
>
<div className="h-7 w-7 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
<div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
{getUserInitials(user.email)}
</div>
{isOpen && (
@ -421,13 +428,21 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
</button>
{isDropdownOpen && (
<div className="absolute bottom-full left-0 m-1 bg-white rounded-lg shadow-lg border border-gray-200 p-1 z-50 w-62 whitespace-nowrap">
<div
className={cn(
"absolute bottom-full left-0 right-0 z-50 mb-1 p-1 whitespace-nowrap",
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
)}
>
<button
onClick={() => {
router.push("/account");
setIsDropdownOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 rounded-md"
className={cn(
"w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md",
"hover:bg-white/70",
)}
>
<User className="h-4 w-4" />
Account Settings

View file

@ -0,0 +1,104 @@
"use client";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
type ConfirmStatus = "idle" | "loading" | "complete";
interface ConfirmPopupProps {
open: boolean;
title?: ReactNode;
message?: ReactNode;
confirmLabel?: ReactNode;
confirmStatus?: ConfirmStatus;
cancelLabel?: ReactNode;
onConfirm: () => void;
onCancel: () => void;
confirmDisabled?: boolean;
className?: string;
}
export function ConfirmPopup({
open,
title,
message,
confirmLabel = "Confirm",
confirmStatus = "idle",
cancelLabel = "Cancel",
onConfirm,
onCancel,
confirmDisabled = false,
className,
}: ConfirmPopupProps) {
if (!open) return null;
const confirmBusy = confirmStatus === "loading";
const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle";
const normalizedConfirmLabel =
typeof confirmLabel === "string" ? confirmLabel : "Confirm";
const resolvedConfirmLabel =
confirmStatus === "loading" ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
{progressiveLabel(normalizedConfirmLabel)}
</span>
) : confirmStatus === "complete" ? (
completedLabel(normalizedConfirmLabel)
) : (
confirmLabel
);
return createPortal(
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4">
<div
className={cn(
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white/58 px-4 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-10px_24px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
className,
)}
>
{title && (
<div className="text-sm font-medium text-gray-950">
{title}
</div>
)}
{message && (
<div className={cn("text-xs text-gray-700", title && "mt-1")}>
{message}
</div>
)}
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={resolvedConfirmDisabled}
className="rounded-full bg-gray-950 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
aria-busy={confirmBusy}
>
{resolvedConfirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
function progressiveLabel(label: string) {
const lower = label.toLowerCase();
if (lower.endsWith("e")) return `${label.slice(0, -1)}ing...`;
return `${label}ing...`;
}
function completedLabel(label: string) {
const lower = label.toLowerCase();
if (lower.endsWith("e")) return `${label}d`;
return `${label}ed`;
}

View file

@ -7,14 +7,19 @@ import { applyOptimisticResolution } from "../assistant/EditCard";
import { DocView } from "./DocView";
import { DocxView } from "./DocxView";
import {
displayCitationQuote,
RelevantQuotes,
type RelevantQuoteItem,
} from "./RelevantQuotes";
import {
expandCitationToEntries,
formatCitationPage,
getDocumentCitationQuotes,
} from "./types";
import type {
CitationQuote,
MikeCitationAnnotation,
MikeEditAnnotation,
CitationAnnotation,
DocumentCitationAnnotation,
EditAnnotation,
} from "./types";
function isDocxFilename(name: string): boolean {
@ -24,16 +29,16 @@ function isDocxFilename(name: string): boolean {
/**
* Discriminated-union describing what the panel is showing above the viewer.
* - "document": no header card, no label just the viewer.
* - "citation": "Citation Quote" card with the quoted text and page ref.
* - "edit": "Tracked Change" card with the diff + Accept/Reject.
* - "document": title row + viewer.
* - "citation": title row + relevant quote + viewer.
* - "edit": title row + tracked change + viewer.
*/
export type DocPanelMode =
| { kind: "document" }
| { kind: "citation"; citation: MikeCitationAnnotation }
| { kind: "citation"; citation: CitationAnnotation }
| {
kind: "edit";
edit: MikeEditAnnotation;
edit: EditAnnotation;
/**
* True while an accept/reject request for this exact edit is in
* flight. Scoped per-edit (not per-document) so sibling edits on
@ -98,11 +103,42 @@ export function DocPanel({
// re-fetch every time they toggle. Tracked-change rendering still
// only lives in DocxView, which is fine because edits are DOCX-only.
const useDocxView = isDocxFilename(filename);
const citationQuoteId =
mode.kind === "citation" ? `document:${mode.citation.ref}:0` : null;
const [activeCitationQuoteId, setActiveCitationQuoteId] = useState<
string | null
>(citationQuoteId);
const [quoteFocusKey, setQuoteFocusKey] = useState(0);
const quotes: CitationQuote[] | undefined = useMemo(() => {
if (mode.kind !== "citation") return undefined;
return expandCitationToEntries(mode.citation);
}, [mode]);
if (!activeCitationQuoteId) return [];
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
if (!Number.isFinite(selectedIndex)) return [];
const selectedQuote =
getDocumentCitationQuotes(mode.citation)[selectedIndex];
if (!selectedQuote) return [];
const documentCitation = mode.citation as DocumentCitationAnnotation;
return expandCitationToEntries({
...documentCitation,
page: selectedQuote.page,
quote: selectedQuote.quote,
quotes: [selectedQuote],
});
}, [activeCitationQuoteId, citationQuoteId, mode]);
useEffect(() => {
setActiveCitationQuoteId(citationQuoteId);
}, [citationQuoteId]);
const handleCitationQuoteSelect = useCallback(
(quoteId: string) => {
const shouldSelect = activeCitationQuoteId !== quoteId;
setActiveCitationQuoteId(shouldSelect ? quoteId : null);
if (shouldSelect) setQuoteFocusKey((current) => current + 1);
},
[activeCitationQuoteId],
);
const highlightEdit = useMemo(() => {
if (mode.kind !== "edit") return null;
@ -116,64 +152,50 @@ export function DocPanel({
}, [mode]);
return (
<div className="flex h-full flex-col px-3 pb-3">
{mode.kind === "citation" ? (
<CitationHeader
<div className="flex h-full flex-col">
<DocumentTitleRow
documentId={documentId}
filename={filename}
versionId={versionId}
versionNumber={versionNumber}
isReloading={isReloading}
/>
{mode.kind === "citation" && (
<RelevantQuoteSection
citation={mode.citation}
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
activeQuoteId={activeCitationQuoteId}
onQuoteSelect={handleCitationQuoteSelect}
/>
) : mode.kind === "edit" ? (
<TrackedChangeHeader
mode={mode}
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
/>
) : (
<div className="flex items-center justify-end gap-2 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm text-gray-700">
{filename}
</span>
{versionNumber && versionNumber > 0 && (
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
V{versionNumber}
</span>
)}
</div>
<DownloadButton
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
/>
</div>
)}
{useDocxView ? (
<DocxView
documentId={documentId}
versionId={versionId ?? undefined}
quotes={quotes}
highlightEdit={highlightEdit}
warning={warning ?? null}
onWarningDismiss={onWarningDismiss}
initialScrollTop={initialScrollTop ?? null}
onScrollChange={onScrollChange}
/>
) : (
<DocView
doc={{
document_id: documentId,
version_id: versionId,
}}
quotes={quotes}
/>
)}
{mode.kind === "edit" && <TrackedChangeHeader mode={mode} />}
<div className="flex flex-1 min-h-0 flex-col px-3 py-3">
{useDocxView ? (
<DocxView
documentId={documentId}
versionId={versionId ?? undefined}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
highlightEdit={highlightEdit}
warning={warning ?? null}
onWarningDismiss={onWarningDismiss}
initialScrollTop={initialScrollTop ?? null}
onScrollChange={onScrollChange}
/>
) : (
<DocView
doc={{
document_id: documentId,
version_id: versionId,
}}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
/>
)}
</div>
</div>
);
}
@ -182,68 +204,106 @@ export function DocPanel({
// Header variants
// ---------------------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-xs font-medium text-gray-700">{children}</p>;
}
function CitationHeader({
citation,
function DocumentTitleRow({
documentId,
versionId,
filename,
versionId,
versionNumber,
isReloading,
}: {
citation: MikeCitationAnnotation;
documentId: string;
versionId: string | null;
filename: string;
versionId: string | null;
versionNumber: number | null;
isReloading: boolean;
}) {
const displayQuote = displayCitationQuote(citation);
const pagesLabel = formatCitationPage(citation);
return (
<div className="pt-2 pb-3">
<div className="flex items-center gap-2 mb-2">
<SectionLabel>Citation</SectionLabel>
<div className="ml-auto shrink-0">
<DownloadButton
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
/>
</div>
</div>
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
<p className="text-sm font-serif text-gray-600">
&ldquo;{displayQuote}&rdquo;
{pagesLabel && (
<span className="ml-1 text-gray-400">
({pagesLabel})
<div className="flex items-start gap-3 px-3 pt-4 pb-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2
className="min-w-0 break-words font-serif text-xl text-gray-900"
title={filename}
>
{filename}
</h2>
{versionNumber && versionNumber > 0 && (
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
V{versionNumber}
</span>
)}
</p>
</div>
</div>
<div className="shrink-0">
<DownloadButton
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
/>
</div>
</div>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-xs font-medium text-gray-700">{children}</p>;
}
function RelevantQuoteSection({
citation,
filename,
activeQuoteId,
onQuoteSelect,
}: {
citation: CitationAnnotation;
filename: string;
activeQuoteId: string | null;
onQuoteSelect: (quoteId: string) => void;
}) {
const citationQuotes = getDocumentCitationQuotes(citation);
const pagesLabel = formatCitationPage(citation);
const citationText = [filename, pagesLabel].filter(Boolean).join(", ");
const relevantQuotes: RelevantQuoteItem[] = citationQuotes.map(
(quote, index) => {
const pageLabel = `Page ${quote.page}`;
return {
id: `document:${citation.ref}:${index}`,
quote: quote.quote.replaceAll("[[PAGE_BREAK]]", "..."),
inlineDetail: pageLabel,
citationText: [filename, pageLabel].filter(Boolean).join(", "),
};
},
);
const currentIndex = Math.max(
0,
relevantQuotes.findIndex((quote) => quote.id === activeQuoteId),
);
return (
<RelevantQuotes
quotes={relevantQuotes}
activeQuoteId={activeQuoteId}
currentIndex={currentIndex}
citationRef={citation.ref}
citationText={citationText}
onSelect={(quote) => onQuoteSelect(quote.id)}
onIndexChange={(index) => {
const quote = relevantQuotes[index];
if (quote) onQuoteSelect(quote.id);
}}
/>
);
}
function TrackedChangeHeader({
mode,
documentId,
versionId,
filename,
isReloading,
}: {
mode: Extract<DocPanelMode, { kind: "edit" }>;
documentId: string;
versionId: string | null;
filename: string;
isReloading: boolean;
}) {
const { edit, isEditReloading, onResolveStart, onResolved, onError } = mode;
return (
<div className="pt-2 pb-3">
<div className="px-3 pb-3">
<div className="flex items-center gap-2 mb-2">
<SectionLabel>Tracked Change</SectionLabel>
<div className="ml-auto flex items-center gap-2 shrink-0">
@ -254,12 +314,6 @@ function TrackedChangeHeader({
onResolved={onResolved}
onError={onError}
/>
<DownloadButton
documentId={documentId}
versionId={versionId}
filename={filename}
isReloading={isReloading}
/>
</div>
</div>
{edit.reason && (
@ -294,7 +348,7 @@ function EditResolveButtons({
onResolved,
onError,
}: {
edit: MikeEditAnnotation;
edit: EditAnnotation;
/**
* True while an accept/reject for any edit on this document is in
* flight (triggered from here, the inline EditCard, the bulk bar, or

View file

@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ZoomIn, ZoomOut } from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
import { DocxView } from "./DocxView";
import type { CitationQuote } from "./types";
@ -17,6 +16,8 @@ interface Props {
doc: { document_id: string; version_id?: string | null } | null;
/** Preferred: one or more (page, quote) pairs to highlight. */
quotes?: CitationQuote[];
/** Changes when the parent wants the current quote re-focused. */
quoteFocusKey?: string | number;
/** Back-compat single-quote API. Ignored if `quotes` is provided. */
quote?: string;
fallbackPage?: number;
@ -42,6 +43,7 @@ type RenderedPage = {
export function DocView({
doc,
quotes,
quoteFocusKey,
quote,
fallbackPage,
rounded = true,
@ -495,9 +497,8 @@ export function DocView({
useEffect(() => {
if (!pdfDocRef.current) return;
quoteListRef.current = quoteList;
if (quoteList.length === 0) return;
rehighlightQuotes(quoteList);
}, [quoteKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
}, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
function handleZoomIn() {
const next = Math.min(
@ -536,13 +537,14 @@ export function DocView({
<DocxView
documentId={doc.document_id}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
/>
);
}
return (
<div
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`}
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`}
>
<div
ref={scrollContainerRef}
@ -550,7 +552,7 @@ export function DocView({
>
{loading && (
<div className="flex h-full items-center justify-center">
<MikeIcon spin mike size={28} />
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
)}
{error && (

View file

@ -5,16 +5,16 @@ import { createPortal } from "react-dom";
import { Download, Trash2, X } from "lucide-react";
import { DocView } from "./DocView";
import { getDocumentUrl } from "@/app/lib/mikeApi";
import type { MikeDocument } from "./types";
import type { Document } from "./types";
interface Props {
doc: MikeDocument | null;
doc: Document | null;
/** Optional specific version to display. Only honoured for DOCX. */
versionId?: string | null;
/** Optional label suffix for the header (e.g. "V3"). */
versionLabel?: string | null;
onClose: () => void;
onDelete?: (doc: MikeDocument) => void;
onDelete?: (doc: Document) => void;
}
export function DocViewModal({

View file

@ -1,12 +1,12 @@
"use client";
import { FileText, File, X, AlertCircle, Loader2 } from "lucide-react";
import type { MikeDocument } from "./types";
import type { Document } from "./types";
interface Props {
document: MikeDocument;
document: Document;
onRemove?: (id: string) => void;
onClick?: (doc: MikeDocument) => void;
onClick?: (doc: Document) => void;
selected?: boolean;
}
@ -29,6 +29,7 @@ function formatBytes(bytes: number): string {
export function DocumentCard({ document, onRemove, onClick, selected }: Props) {
const isError = document.status === "error";
const isProcessing = document.status === "pending" || document.status === "processing";
const filename = document.filename;
return (
<div
@ -52,8 +53,8 @@ export function DocumentCard({ document, onRemove, onClick, selected }: Props) {
)}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-800" title={document.filename}>
{document.filename}
<p className="truncate font-medium text-gray-800" title={filename}>
{filename}
</p>
<p className="text-xs text-gray-400">
{isProcessing

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { MikeIcon } from "@/components/chat/mike-icon";
import { Loader2 } from "lucide-react";
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
import { supabase } from "@/lib/supabase";
import {
@ -50,6 +50,8 @@ interface Props {
* pagination the renderer can match against.
*/
quotes?: CitationQuote[];
/** Changes when the parent wants the current quote re-focused. */
quoteFocusKey?: string | number;
/**
* Warning banner copy rendered in the top-left of the viewer. Used
* for non-blocking errors (e.g. "Accept failed — reverted").
@ -201,6 +203,7 @@ export function DocxView({
highlightEdit,
refetchKey,
quotes,
quoteFocusKey,
warning,
onWarningDismiss,
initialScrollTop,
@ -347,13 +350,6 @@ export function DocxView({
const scrollEl = scrollRef.current;
const containerEl = containerRef.current;
console.log("[DocxView] render effect fired", {
documentId,
versionId,
refetchKey,
bytesLen: bytes.byteLength,
});
// Remember scroll position across re-renders so Accept/Reject stays put.
lastScrollTopRef.current = scrollEl.scrollTop;
const thisRender = ++renderKeyRef.current;
@ -447,7 +443,7 @@ export function DocxView({
scrollRef.current,
quotesRef.current,
);
}, [quoteKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [quoteKey, quoteFocusKey]); // eslint-disable-line react-hooks/exhaustive-deps
// Fire onScrollChange (rAF-throttled) so parents can persist scroll
// per-tab. We still maintain lastScrollTopRef locally for same-mount
@ -471,7 +467,7 @@ export function DocxView({
return (
<div
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`}
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`}
>
{warning && (
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800 shadow-sm">
@ -494,7 +490,7 @@ export function DocxView({
>
{loading && !bytes && (
<div className="flex h-full items-center justify-center">
<MikeIcon spin mike size={28} />
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
</div>
)}
{error && (

View file

@ -11,7 +11,7 @@ import {
Trash2,
Loader2,
} from "lucide-react";
import type { MikeDocument, MikeProject } from "./types";
import type { Document, Project } from "./types";
import { VersionChip } from "./VersionChip";
function formatDate(iso: string | null) {
@ -30,8 +30,8 @@ export function DocFileIcon({ fileType }: { fileType: string | null }) {
}
interface FileDirectoryProps {
standaloneDocs: MikeDocument[];
directoryProjects: MikeProject[];
standaloneDocs: Document[];
directoryProjects: Project[];
loading: boolean;
selectedIds: Set<string>;
onChange: (ids: Set<string>) => void;
@ -238,7 +238,12 @@ export function FileDirectory({
>
{doc.filename}
</span>
<VersionChip n={doc.latest_version_number} />
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(doc.created_at)}
@ -333,7 +338,10 @@ export function FileDirectory({
{doc.filename}
</span>
<VersionChip
n={doc.latest_version_number}
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">

View file

@ -1,57 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Search, X } from "lucide-react";
interface Props {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}
export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
onChange("");
}
}
if (open) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open, onChange]);
return (
<div ref={ref} className="relative flex items-center">
{open ? (
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-1.5 shadow-sm z-10 w-72">
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
autoFocus
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent"
/>
<button
onClick={() => { setOpen(false); onChange(""); }}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
onClick={() => setOpen(true)}
className="flex h-8 w-8 items-center justify-center text-gray-500 hover:text-gray-900 transition-colors"
>
<Search className="h-4 w-4" />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,199 @@
"use client";
import { createPortal } from "react-dom";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
type ModalSize = "sm" | "md" | "lg" | "xl";
type ModalAction = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
label: ReactNode;
icon?: ReactNode;
variant?: "primary" | "secondary" | "danger";
};
interface ModalProps {
open: boolean;
onClose: () => void;
children: ReactNode;
breadcrumbs?: ReactNode[];
title?: ReactNode;
icon?: ReactNode;
size?: ModalSize;
className?: string;
footerInfo?: ReactNode;
footerStatus?: ReactNode;
primaryAction?: ModalAction;
secondaryAction?: ModalAction;
cancelAction?: ModalAction | false;
}
const sizeClassName: Record<ModalSize, string> = {
sm: "max-w-md",
md: "max-w-xl",
lg: "max-w-2xl",
xl: "max-w-4xl",
};
export function Modal({
open,
onClose,
children,
breadcrumbs,
title,
icon,
size = "lg",
className,
footerInfo,
footerStatus,
primaryAction,
secondaryAction,
cancelAction,
}: ModalProps) {
const hasHeader = breadcrumbs?.length || title || icon;
const hasFooter =
footerInfo ||
footerStatus ||
primaryAction ||
secondaryAction ||
cancelAction;
const resolvedCancelAction =
cancelAction === undefined && primaryAction
? { label: "Cancel", onClick: onClose }
: cancelAction;
if (!open) return null;
return createPortal(
<div
className={cn(
"fixed inset-0 z-[200] flex items-center justify-center px-4",
"bg-white/30 backdrop-blur-[2px]",
)}
onClick={onClose}
>
<div
className={cn(
"w-full rounded-2xl shadow-2xl flex h-[600px] flex-col",
sizeClassName[size],
"border border-white/70 bg-white/80 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-2xl",
className,
)}
onClick={(e) => e.stopPropagation()}
>
{hasHeader && (
<div className="flex items-start justify-between gap-3 px-4 py-4">
{breadcrumbs?.length ? (
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-gray-400">
{breadcrumbs.map((segment, index) => (
<span
key={index}
className="flex items-center gap-1.5"
>
{index > 0 && <span></span>}
<span className="truncate">
{segment}
</span>
</span>
))}
</div>
) : (
<div className="flex min-w-0 items-center gap-2">
{icon}
<h2 className="truncate text-base font-medium text-gray-900">
{title}
</h2>
</div>
)}
<button
onClick={onClose}
className="shrink-0 text-gray-400 transition-colors hover:text-gray-600"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-1 pb-2">
{children}
</div>
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 px-4 py-3",
secondaryAction || footerInfo
? "justify-between"
: "justify-end",
"border-t border-white/60",
)}
>
{(secondaryAction || footerInfo) && (
<div className="flex min-w-0 items-center gap-2">
{secondaryAction && (
<ModalActionButton
action={secondaryAction}
fallbackVariant="secondary"
/>
)}
{footerInfo}
</div>
)}
<div className="flex items-center gap-2">
{footerStatus}
{resolvedCancelAction && (
<ModalActionButton
action={resolvedCancelAction}
fallbackVariant="cancel"
/>
)}
{primaryAction && (
<ModalActionButton
action={primaryAction}
fallbackVariant="primary"
/>
)}
</div>
</div>
)}
</div>
</div>,
document.body,
);
}
function ModalActionButton({
action,
fallbackVariant,
}: {
action: ModalAction;
fallbackVariant: "primary" | "secondary" | "danger" | "cancel";
}) {
const {
label,
icon,
variant = fallbackVariant === "cancel" ? "secondary" : fallbackVariant,
...props
} = action;
return (
<button
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
variant === "primary" &&
"bg-gray-900 text-white hover:bg-gray-700",
variant === "secondary" && "text-gray-600 hover:bg-gray-100",
fallbackVariant === "secondary" &&
"border border-gray-200 hover:bg-gray-50",
variant === "danger" &&
"bg-red-600 text-white hover:bg-red-700",
)}
{...props}
>
{icon}
{label}
</button>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { createPortal } from "react-dom";
import { Lock, X } from "lucide-react";
import { Lock } from "lucide-react";
import { WarningPopup } from "./WarningPopup";
interface Props {
open: boolean;
@ -38,56 +38,21 @@ export function OwnerOnlyModal({
? `Only the project owner can ${action}.`
: "Only the project owner can perform this action.");
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"
onClick={onClose}
return (
<WarningPopup
open={open}
onClose={onClose}
title={title}
message={body}
icon={<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />}
primaryAction={{ label: "OK", onClick: onClose }}
>
<div
className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-amber-600" />
<h2 className="text-base font-medium text-gray-900">
{title}
</h2>
</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>
{/* Body */}
<div className="px-5 pb-2 pt-1">
<p className="text-sm text-gray-600 leading-relaxed">
{body}
</p>
{ownerEmail && (
<p className="mt-2 text-xs text-gray-400">
Ask{" "}
<span className="text-gray-600">{ownerEmail}</span>{" "}
if you need access.
</p>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-5 pb-5 pt-3">
<button
onClick={onClose}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
>
OK
</button>
</div>
</div>
</div>,
document.body,
{ownerEmail && (
<p className="mt-1 text-xs text-gray-600">
Ask <span className="text-gray-600">{ownerEmail}</span> if
you need access.
</p>
)}
</WarningPopup>
);
}

View file

@ -0,0 +1,442 @@
"use client";
import {
Fragment,
isValidElement,
useEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PageHeaderBreadcrumb {
label?: ReactNode;
suffix?: ReactNode;
onClick?: () => void;
loading?: boolean;
skeletonClassName?: string;
title?: string;
}
type PageHeaderButtonAction = {
type?: "button";
icon?: ReactNode;
label?: ReactNode;
onClick?: () => void;
disabled?: boolean;
title?: string;
variant?: "default" | "danger";
iconOnly?: boolean;
className?: string;
tooltip?: ReactNode;
};
type PageHeaderSearchAction = {
type: "search";
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
type PageHeaderDeleteAction = {
type: "delete";
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
title?: string;
};
type PageHeaderNewAction = {
type: "new";
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
title?: string;
};
type PageHeaderCustomAction = {
type: "custom";
render: ReactNode;
};
export type PageHeaderAction =
| PageHeaderButtonAction
| PageHeaderSearchAction
| PageHeaderDeleteAction
| PageHeaderNewAction
| PageHeaderCustomAction
| ReactNode;
interface PageHeaderProps {
children?: ReactNode;
actions?: PageHeaderAction[];
actionGroups?: PageHeaderAction[][];
align?: "center" | "start";
shrink?: boolean;
className?: string;
actionGap?: "sm" | "md" | "lg";
breadcrumbs?: PageHeaderBreadcrumb[];
}
const actionGapClassName = {
sm: "gap-2.5",
md: "gap-2.5",
lg: "gap-2.5",
};
export function PageHeader({
children,
actions,
actionGroups,
align = "center",
shrink = false,
className,
actionGap = "sm",
breadcrumbs,
}: PageHeaderProps) {
const headerContent = breadcrumbs?.length ? (
<PageHeaderBreadcrumbs items={breadcrumbs} />
) : (
children
);
const actionItems = actions?.filter(Boolean) ?? [];
const groupedActionItems =
actionGroups
?.map((group) => group.filter(Boolean))
.filter((group) => group.length > 0) ??
(actionItems.length > 0 ? [actionItems] : []);
return (
<div
className={cn(
"flex justify-between",
align === "start" ? "items-start" : "items-center",
"px-4 md:px-10",
"pb-4 pt-5.5",
shrink && "shrink-0",
className,
)}
>
{headerContent}
{groupedActionItems.length > 0 && (
<div className="ml-4 flex shrink-0 items-center gap-3">
{groupedActionItems.map((group, groupIndex) => (
<div
key={groupIndex}
className={cn(
"flex shrink-0 items-center",
actionGapClassName[actionGap],
"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) => (
<Fragment key={index}>
<PageHeaderActionRenderer action={action} />
</Fragment>
))}
</div>
))}
</div>
)}
</div>
);
}
function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) {
if (!isPageHeaderActionObject(action)) return <>{action}</>;
switch (action.type) {
case "search":
return <PageHeaderSearchActionControl action={action} />;
case "delete":
return <PageHeaderDeleteActionControl action={action} />;
case "new":
return <PageHeaderNewActionControl action={action} />;
case "custom":
return <>{action.render}</>;
case "button":
default:
return <PageHeaderButtonActionControl action={action} />;
}
}
function isPageHeaderActionObject(
action: PageHeaderAction,
): action is Exclude<PageHeaderAction, ReactNode> {
return !!action && typeof action === "object" && !isValidElement(action);
}
function PageHeaderButtonActionControl({
action,
}: {
action: PageHeaderButtonAction;
}) {
const iconOnly = action.iconOnly ?? !action.label;
return (
<div className={action.tooltip ? "relative group" : undefined}>
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled}
title={action.title}
aria-label={action.title}
variant={action.variant}
iconOnly={iconOnly}
className={action.className}
>
{action.icon}
{action.label}
</PageHeaderActionButton>
{action.tooltip && (
<div className="pointer-events-none absolute right-0 top-full mt-1.5 z-10 hidden items-center whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg group-hover:flex">
{action.tooltip}
</div>
)}
</div>
);
}
function PageHeaderNewActionControl({
action,
}: {
action: PageHeaderNewAction;
}) {
const title = action.title ?? "New";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</PageHeaderActionButton>
);
}
function PageHeaderDeleteActionControl({
action,
}: {
action: PageHeaderDeleteAction;
}) {
const title = action.title ?? "Delete";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
variant="danger"
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</PageHeaderActionButton>
);
}
function PageHeaderSearchActionControl({
action,
}: {
action: PageHeaderSearchAction;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const placeholder = action.placeholder ?? "Search…";
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
action.onChange("");
}
}
if (open) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open, action]);
return (
<div ref={ref} className="relative flex items-center">
{open ? (
<div
className={cn(
pageHeaderActionControlClassName({
className:
"cursor-text justify-start gap-2 px-3 text-gray-700 hover:text-gray-700",
}),
"w-56 bg-gray-100 sm:w-80",
)}
>
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
autoFocus
type="text"
placeholder={placeholder}
value={action.value}
onChange={(e) => action.onChange(e.target.value)}
className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent"
/>
</div>
) : (
<PageHeaderActionButton
onClick={() => setOpen(true)}
iconOnly
title={placeholder}
aria-label={placeholder}
>
<Search className="h-4 w-4" />
</PageHeaderActionButton>
)}
</div>
);
}
type PageHeaderActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "default" | "danger";
iconOnly?: boolean;
};
type PageHeaderActionControlClassNameOptions = {
variant?: "default" | "danger";
iconOnly?: boolean;
disabled?: boolean;
className?: string;
};
function pageHeaderActionControlClassName({
variant = "default",
iconOnly = false,
disabled = false,
className,
}: PageHeaderActionControlClassNameOptions = {}) {
return cn(
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
iconOnly ? "w-7" : "gap-1.5 px-3",
disabled ? "cursor-default" : "cursor-pointer",
"hover:bg-gray-100 active:bg-gray-100",
variant === "danger"
? "text-gray-500 hover:text-red-600"
: "text-gray-500 hover:text-gray-900",
className,
);
}
function PageHeaderActionButton({
children,
className,
variant = "default",
iconOnly = false,
disabled,
...props
}: PageHeaderActionButtonProps) {
return (
<button
disabled={disabled}
className={pageHeaderActionControlClassName({
variant,
iconOnly,
disabled,
className,
})}
{...props}
>
{children}
</button>
);
}
function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
const current = items[items.length - 1];
const parent = [...items]
.slice(0, -1)
.reverse()
.find((item) => item.onClick);
return (
<div className="flex min-w-0 items-center gap-1.5 text-2xl font-medium font-serif">
{parent?.onClick && (
<button
onClick={parent.onClick}
className="shrink-0 text-gray-400 transition-colors hover:text-gray-600 sm:hidden"
title={parent.title ?? "Back"}
aria-label={parent.title ?? "Back"}
>
<ChevronLeft className="h-5 w-5" />
</button>
)}
<div className="hidden min-w-0 items-center gap-1.5 sm:flex">
{items.map((item, index) => (
<BreadcrumbItem
key={index}
item={item}
current={index === items.length - 1}
showSuffix
/>
))}
</div>
<div className="min-w-0 sm:hidden">
{current ? (
<BreadcrumbItem item={current} current showSuffix={false} />
) : null}
</div>
</div>
);
}
function BreadcrumbItem({
item,
current,
showSuffix,
}: {
item: PageHeaderBreadcrumb;
current: boolean;
showSuffix: boolean;
}) {
const content = item.loading ? (
<div
className={cn(
"h-6 rounded bg-gray-100 animate-pulse",
item.skeletonClassName ?? "w-32",
)}
/>
) : (
<>
<span className="truncate">{item.label}</span>
{showSuffix && item.suffix}
</>
);
const className = cn(
"min-w-0 truncate transition-colors",
current
? "text-gray-900"
: item.onClick
? "text-gray-500 hover:text-gray-700"
: "text-gray-500",
);
return (
<>
{current ? (
<span className={className}>{content}</span>
) : item.onClick ? (
<button onClick={item.onClick} className={className}>
{content}
</button>
) : (
<span className={className}>{content}</span>
)}
{!current && <span className="shrink-0 text-gray-300"></span>}
</>
);
}

View file

@ -1,9 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X, User, UserPlus, Loader2, Plus } from "lucide-react";
import { User, UserPlus, Loader2, Plus } from "lucide-react";
import type { ProjectPeople } from "@/app/lib/mikeApi";
import { Modal } from "./Modal";
/**
* Any resource the modal can manage members for projects today, tabular
@ -194,30 +194,22 @@ export function PeopleModal({
}
}
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{breadcrumb.map((segment, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <span></span>}
{segment}
</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={open}
onClose={onClose}
breadcrumbs={breadcrumb}
footerInfo={
roster.length === 0
? "No one has access yet."
: `${roster.length} ${
roster.length === 1 ? "person" : "people"
} with access.`
}
>
{/* Add-member row */}
{onSharedWithChange && (
<div className="px-4 pt-1 pb-2">
<div className="pt-1 pb-2">
<div className="flex items-center gap-2">
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
@ -281,7 +273,7 @@ export function PeopleModal({
)}
{/* Section heading */}
<div className="px-4 pt-3 pb-1 flex items-center gap-2">
<div className="pt-3 pb-1 flex items-center gap-2">
<h3 className="text-xs font-medium text-gray-500">
People with Access
</h3>
@ -291,89 +283,77 @@ export function PeopleModal({
</div>
{/* Member list */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
{roster.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
No one has access yet.
</div>
) : (
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
{roster.map((entry) => {
const isYou =
!!currentUserEmail &&
entry.email.toLowerCase() ===
currentUserEmail.toLowerCase();
const isRemoving =
busy === "remove" &&
removingEmail === entry.email;
const primary =
entry.display_name?.trim() || entry.email;
const showSecondary =
!!entry.display_name?.trim() &&
primary !== entry.email;
return (
<li
key={`${entry.role}-${entry.email}`}
className="flex items-center gap-3 py-3"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
<User className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-800">
{primary}
{isYou && (
<span className="ml-1.5 text-xs text-gray-400">
(You)
</span>
)}
{entry.role === "owner" && (
<span className="ml-1.5 text-[10px] text-gray-400">
Owner
</span>
)}
{roster.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
No one has access yet.
</div>
) : (
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
{roster.map((entry) => {
const isYou =
!!currentUserEmail &&
entry.email.toLowerCase() ===
currentUserEmail.toLowerCase();
const isRemoving =
busy === "remove" &&
removingEmail === entry.email;
const primary =
entry.display_name?.trim() || entry.email;
const showSecondary =
!!entry.display_name?.trim() &&
primary !== entry.email;
return (
<li
key={`${entry.role}-${entry.email}`}
className="flex items-center gap-3 py-3"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
<User className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-800">
{primary}
{isYou && (
<span className="ml-1.5 text-xs text-gray-400">
(You)
</span>
)}
{entry.role === "owner" && (
<span className="ml-1.5 text-[10px] text-gray-400">
Owner
</span>
)}
</p>
{showSecondary && (
<p className="truncate text-xs text-gray-400">
{entry.email}
</p>
{showSecondary && (
<p className="truncate text-xs text-gray-400">
{entry.email}
</p>
)}
</div>
{entry.role === "member" &&
onSharedWithChange && (
<button
onClick={() =>
void handleRemove(
entry.email,
)
}
disabled={busy !== null}
title="Remove access"
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
>
{isRemoving && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Remove
</button>
)}
</li>
);
})}
</ul>
)}
</div>
)}
</div>
{entry.role === "member" &&
onSharedWithChange && (
<button
onClick={() =>
void handleRemove(
entry.email,
)
}
disabled={busy !== null}
title="Remove access"
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
>
{isRemoving && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Remove
</button>
)}
</li>
);
})}
</ul>
)}
{/* Footer */}
<div className="px-5 py-3 text-[11px] text-gray-400">
{roster.length === 0
? "No one has access yet."
: `${roster.length} ${
roster.length === 1 ? "person" : "people"
} with access.`}
</div>
</div>
</div>,
document.body,
</Modal>
);
}

View file

@ -40,7 +40,7 @@ export function PreResponseWrapper({
const childrenGapClass = compact ? "gap-2.5" : "gap-4";
return (
<div className="border border-gray-200 rounded-lg px-3 py-2">
<div className="rounded-xl border border-white/70 bg-white/55 px-3 py-2 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl">
<button
type="button"
onClick={() => {
@ -61,7 +61,7 @@ export function PreResponseWrapper({
</span>
<ChevronDown
size={12}
className={`shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
className={`relative top-px shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
</button>
{isOpen && (

View file

@ -2,10 +2,10 @@
import { useState } from "react";
import { Folder, Search, X } from "lucide-react";
import type { MikeProject } from "./types";
import type { Project } from "./types";
interface Props {
projects: MikeProject[];
projects: Project[];
loading: boolean;
selectedId: string | null;
onSelect: (id: string | null) => void;
@ -18,7 +18,7 @@ export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props
return (
<>
<div className="px-4 pt-1 pb-2">
<div className="pt-1 pb-2">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
@ -36,7 +36,7 @@ export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props
)}
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-2">
<div className="flex-1 overflow-y-auto pb-2">
{loading ? (
<div className="rounded-sm border border-gray-100 overflow-hidden">
<div className="flex items-center px-2 py-2">

View file

@ -0,0 +1,297 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { Minus, RectangleHorizontal, Rows3 } from "lucide-react";
import { CiteButton } from "@/components/ui/cite-button";
export type RelevantQuoteItem = {
id: string;
quote: string;
eyebrow?: string | null;
inlineDetail?: string | null;
detail?: string | null;
citationText?: string | null;
};
interface Props {
quotes: RelevantQuoteItem[];
error?: string | null;
isLoading?: boolean;
activeQuoteId?: string | null;
currentIndex?: number;
citationRef?: number;
citationText?: string;
onSelect?: (quote: RelevantQuoteItem, index: number) => void;
onIndexChange?: (index: number) => void;
}
export function RelevantQuotes({
quotes,
error = null,
isLoading = false,
activeQuoteId = null,
currentIndex = 0,
citationRef,
citationText,
onSelect,
onIndexChange,
}: Props) {
const [isExpanded, setIsExpanded] = useState(true);
const [viewMode, setViewMode] = useState<"single" | "list">("single");
const hasMultipleQuotes = quotes.length > 1;
const currentQuote = quotes[currentIndex];
useEffect(() => {
if (!hasMultipleQuotes && viewMode === "list") {
setViewMode("single");
}
}, [hasMultipleQuotes, viewMode]);
return (
<div className="px-3">
<div className="rounded-lg border border-gray-200">
<div className="flex h-10 items-center justify-between px-2">
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-gray-700">
{typeof citationRef === "number"
? `Citation ${citationRef}`
: "Citation"}
</p>
{hasMultipleQuotes && (
<div className="flex items-center gap-1">
{quotes.map((quote, index) => (
<button
key={quote.id}
type="button"
onClick={() =>
onIndexChange?.(index)
}
className={`flex h-4 w-4 items-center justify-center rounded-full text-[9px] transition-colors ${
currentIndex === index
? "bg-white font-medium text-gray-800 shadow-[0_1px_3px_rgba(0,0,0,0.22)]"
: "bg-gray-200 text-gray-500 hover:bg-gray-300 hover:text-gray-700"
}`}
>
{index + 1}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
{currentQuote && (
<CiteButton
quoteText={currentQuote.quote}
citationText={
currentQuote.citationText ??
citationText ??
""
}
className="rounded-sm bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
showText
/>
)}
<div
className={`relative flex h-6 items-center justify-start gap-1 rounded-sm bg-gray-200 p-1 ${
hasMultipleQuotes ? "w-16" : "w-11"
}`}
>
<div
className={`absolute top-1 h-4 w-4 rounded bg-white shadow-sm transition-all ${
!isExpanded
? "left-1"
: hasMultipleQuotes &&
viewMode === "list"
? "left-11"
: "left-6"
}`}
/>
<button
type="button"
onClick={() => setIsExpanded(false)}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
!isExpanded
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
}`}
title="Minimize"
>
<Minus className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => {
setIsExpanded(true);
setViewMode("single");
}}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
isExpanded && viewMode === "single"
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
}`}
title="Single quote"
>
<RectangleHorizontal className="h-3 w-3" />
</button>
{hasMultipleQuotes && (
<button
type="button"
onClick={() => {
setIsExpanded(true);
setViewMode("list");
}}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
isExpanded && viewMode === "list"
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
}`}
title="Quote list"
>
<Rows3 className="h-3 w-3" />
</button>
)}
</div>
</div>
</div>
{isExpanded && (
<div className="px-2 pb-2">
{isLoading ? (
<RelevantQuoteSkeleton />
) : error ? (
<RelevantQuoteMessage tone="error">
{error}
</RelevantQuoteMessage>
) : quotes.length > 0 ? (
viewMode === "list" ? (
<div className="space-y-2">
{quotes.map((quote, index) => (
<QuoteItem
key={quote.id}
quote={quote}
isActive={
activeQuoteId === quote.id
}
onClick={() =>
onSelect?.(quote, index)
}
/>
))}
</div>
) : currentQuote ? (
<div className="flex flex-col gap-2">
<QuoteItem
quote={currentQuote}
isActive={
activeQuoteId === currentQuote.id
}
onClick={() =>
onSelect?.(
currentQuote,
currentIndex,
)
}
/>
</div>
) : null
) : (
<RelevantQuoteMessage>
No relevant quotes.
</RelevantQuoteMessage>
)}
</div>
)}
</div>
</div>
);
}
function RelevantQuoteSkeleton() {
return (
<div className="animate-pulse rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
<div className="h-3 w-28 rounded bg-gray-200" />
<div className="mt-2.5 h-3 w-full rounded bg-gray-200" />
<div className="mt-2 h-3 w-11/12 rounded bg-gray-200" />
<div className="mt-2 h-3 w-2/3 rounded bg-gray-200" />
</div>
);
}
function RelevantQuoteMessage({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: "neutral" | "error";
}) {
return (
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
<p
className={`font-serif text-sm leading-6 ${
tone === "error" ? "text-red-700" : "text-gray-600"
}`}
>
{children}
</p>
</div>
);
}
function QuoteItem({
quote,
isActive,
onClick,
}: {
quote: RelevantQuoteItem;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? "border-blue-300 bg-blue-50"
: "border-gray-200 bg-gray-50 hover:border-blue-300 hover:bg-blue-50/50"
}`}
>
<div className="flex flex-col gap-1.5">
{quote.eyebrow && (
<p
className={`font-serif text-xs ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{quote.eyebrow}
</p>
)}
<p
className={`font-serif text-sm leading-6 ${
isActive ? "text-blue-950" : "text-gray-700"
}`}
>
&ldquo;{quote.quote.replace(/"/g, "'")}&rdquo;
{quote.inlineDetail && (
<span
className={`text-sm ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{" "}
({quote.inlineDetail})
</span>
)}
</p>
{quote.detail && (
<p
className={`font-serif text-xs ${
isActive ? "text-blue-900" : "text-gray-500"
}`}
>
{quote.detail}
</p>
)}
</div>
</button>
);
}

View file

@ -11,10 +11,11 @@ import {
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useAuth } from "@/contexts/AuthContext";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import type { MikeChat } from "@/app/components/shared/types";
import type { Chat } from "@/app/components/shared/types";
import { cn } from "@/lib/utils";
interface Props {
chat: MikeChat;
chat: Chat;
isActive: boolean;
onSelect: () => void;
projectName?: string;
@ -48,9 +49,10 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
return (
<div
className={`group relative flex items-center w-full h-9 rounded-md transition-colors ${
isActive ? "bg-gray-100" : "hover:bg-gray-100"
}`}
className={cn(
"group relative flex items-center w-full h-9 rounded-md transition-colors",
isActive ? "bg-gray-200/60" : "hover:bg-gray-100",
)}
>
{isRenaming ? (
<div className="flex items-center w-full px-2 py-1">
@ -104,7 +106,7 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`p-1 mr-1 text-gray-500 transition-opacity hover:text-gray-900 ${
className={`mr-1 rounded-md p-1 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 ${
isActive
? "opacity-100"
: "opacity-0 group-hover:opacity-100"

View file

@ -1,16 +1,16 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X, Upload } from "lucide-react";
import { Upload } from "lucide-react";
import { listDocumentVersions } from "@/app/lib/mikeApi";
import type { MikeDocument } from "./types";
import type { Document } from "./types";
import { Modal } from "./Modal";
interface Props {
open: boolean;
onClose: () => void;
doc: MikeDocument | null;
onSubmit: (file: File, displayName: string) => Promise<void>;
doc: Document | null;
onSubmit: (file: File, filename: string) => Promise<void>;
}
export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
@ -35,7 +35,7 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
(v) => v.id === current_version_id,
);
const initial =
(current?.display_name && current.display_name.trim()) ||
(current?.filename && current.filename.trim()) ||
doc.filename;
if (!cancelled) {
setName(initial);
@ -72,87 +72,52 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
}
}
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div className="text-xs text-gray-400">
Upload new version · {doc.filename}
</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>
{/* Name input */}
<div className="px-5 pb-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
New version name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Version name"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-gray-400"
/>
<div className="mt-2 text-xs text-gray-500">
Current Version:{" "}
<span className="text-gray-700 font-medium">
{currentVersion ?? "—"}
</span>
</div>
{stagedFile && (
<div className="mt-2 text-xs text-gray-500 truncate">
New Version File:{" "}
<span className="text-gray-700">
{stagedFile.name}
</span>
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3">
<div>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleFilePick}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={submitting}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
<Upload className="h-3.5 w-3.5" />
{stagedFile ? "Change file" : "Upload"}
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!stagedFile || submitting}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
>
{submitting ? "Saving…" : "Save"}
</button>
</div>
</div>
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={["Upload new version", doc.filename]}
secondaryAction={{
label: stagedFile ? "Change file" : "Upload",
icon: <Upload className="h-3.5 w-3.5" />,
onClick: () => fileInputRef.current?.click(),
disabled: submitting,
}}
primaryAction={{
label: submitting ? "Saving…" : "Save",
onClick: handleSubmit,
disabled: !stagedFile || submitting,
}}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleFilePick}
/>
<label className="block text-xs font-medium text-gray-500 mb-1">
New version name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Version name"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-gray-400"
/>
<div className="mt-2 text-xs text-gray-500">
Current Version:{" "}
<span className="text-gray-700 font-medium">
{currentVersion ?? "—"}
</span>
</div>
</div>,
document.body,
{stagedFile && (
<div className="mt-2 text-xs text-gray-500 truncate">
New Version File:{" "}
<span className="text-gray-700">{stagedFile.name}</span>
</div>
)}
</Modal>
);
}

View file

@ -0,0 +1,108 @@
"use client";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { AlertCircle, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface WarningPopupAction {
label: ReactNode;
onClick: () => void;
disabled?: boolean;
}
interface WarningPopupProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
message?: ReactNode;
children?: ReactNode;
icon?: ReactNode;
primaryAction?: WarningPopupAction;
secondaryAction?: WarningPopupAction;
className?: string;
}
export function WarningPopup({
open,
onClose,
title,
message,
children,
icon,
primaryAction,
secondaryAction,
className,
}: WarningPopupProps) {
if (!open) return null;
return createPortal(
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
<div
className={cn(
"pointer-events-auto flex items-start gap-2 rounded-2xl border border-white/70 bg-red-50/75 px-3 py-2 text-xs shadow-[0_4px_12px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-6px_12px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
className,
)}
>
{icon ?? (
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
)}
<div className="min-w-0 flex-1 self-center text-gray-900">
{title && (
<div className="font-medium text-gray-950">
{title}
</div>
)}
{message && <div>{message}</div>}
{children}
{(primaryAction || secondaryAction) && (
<div className="mt-2 flex items-center gap-2">
{secondaryAction && (
<WarningPopupButton action={secondaryAction} />
)}
{primaryAction && (
<WarningPopupButton
action={primaryAction}
primary
/>
)}
</div>
)}
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 text-gray-700 transition-colors hover:text-gray-950"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>,
document.body,
);
}
function WarningPopupButton({
action,
primary = false,
}: {
action: WarningPopupAction;
primary?: boolean;
}) {
return (
<button
type="button"
onClick={action.onClick}
disabled={action.disabled}
className={cn(
"rounded-lg px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
primary
? "bg-gray-900 text-white hover:bg-gray-700"
: "text-gray-700 hover:bg-white/70",
)}
>
{action.label}
</button>
);
}

View file

@ -1,4 +1,5 @@
const HIGHLIGHT_CLASS = "docx-text-highlight";
const IGNORED_TEXT_SELECTOR = ".star-pagination,.case-page-number";
function onlyLetters(s: string): string {
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
@ -23,6 +24,8 @@ function collectTextNodes(root: HTMLElement): Text[] {
const tag = p.tagName;
if (tag === "STYLE" || tag === "SCRIPT")
return NodeFilter.FILTER_REJECT;
if (p.closest(IGNORED_TEXT_SELECTOR))
return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});

View file

@ -1,6 +1,6 @@
// Shared TypeScript types for Mike AI legal assistant
export interface MikeFolder {
export interface Folder {
id: string;
project_id: string;
user_id: string;
@ -10,7 +10,7 @@ export interface MikeFolder {
updated_at: string;
}
export interface MikeProject {
export interface Project {
id: string;
user_id: string;
is_owner?: boolean;
@ -19,14 +19,14 @@ export interface MikeProject {
shared_with: string[];
created_at: string;
updated_at: string;
documents?: MikeDocument[];
folders?: MikeFolder[];
documents?: Document[];
folders?: Folder[];
document_count?: number;
chat_count?: number;
review_count?: number;
}
export interface MikeDocument {
export interface Document {
id: string;
user_id?: string;
project_id: string | null;
@ -41,7 +41,9 @@ export interface MikeDocument {
status: "pending" | "processing" | "ready" | "error";
created_at: string | null;
updated_at?: string | null;
/** Max version_number across assistant_edit rows, null if doc is unedited. */
/** Version number of the document row pointed to by current_version_id. */
active_version_number?: number | null;
/** Legacy: max version_number across assistant_edit rows, null if doc is unedited. */
latest_version_number?: number | null;
}
@ -53,7 +55,7 @@ export interface StructureNode {
children: StructureNode[];
}
export interface MikeChat {
export interface Chat {
id: string;
project_id: string | null;
user_id: string;
@ -61,7 +63,7 @@ export interface MikeChat {
created_at: string;
}
export interface MikeEditAnnotation {
export interface EditAnnotation {
type?: "edit_data";
kind?: "edit";
edit_id: string;
@ -82,161 +84,315 @@ export interface MikeEditAnnotation {
export type AssistantEvent =
| { type: "reasoning"; text: string; isStreaming?: boolean }
| { type: "error"; message: string }
| {
type: "tool_call_start";
name: string;
isStreaming?: boolean;
type: "tool_call_start";
name: string;
isStreaming?: boolean;
}
| { type: "thinking"; isStreaming?: boolean }
| {
type: "doc_read";
filename: string;
document_id?: string;
isStreaming?: boolean;
type: "doc_read";
filename: string;
document_id?: string;
isStreaming?: boolean;
}
| {
type: "doc_find";
filename: string;
query: string;
total_matches: number;
isStreaming?: boolean;
type: "doc_find";
filename: string;
query: string;
total_matches: number;
isStreaming?: boolean;
}
| {
type: "doc_created";
filename: string;
download_url: string;
/** Set when the generated doc is persisted as a first-class document. */
document_id?: string;
version_id?: string;
version_number?: number | null;
isStreaming?: boolean;
type: "doc_created";
filename: string;
download_url: string;
/** Set when the generated doc is persisted as a first-class document. */
document_id?: string;
version_id?: string;
version_number?: number | null;
isStreaming?: boolean;
}
| { type: "doc_download"; filename: string; download_url: string }
| {
type: "doc_replicated";
/** Source document filename. */
filename: string;
/** How many copies were produced in this single tool call. */
count: number;
/** One entry per new copy. Empty while streaming. */
copies?: {
new_filename: string;
document_id: string;
version_id: string;
}[];
error?: string;
isStreaming?: boolean;
type: "doc_replicated";
/** Source document filename. */
filename: string;
/** How many copies were produced in this single tool call. */
count: number;
/** One entry per new copy. Empty while streaming. */
copies?: {
new_filename: string;
document_id: string;
version_id: string;
}[];
error?: string;
isStreaming?: boolean;
}
| { type: "workflow_applied"; workflow_id: string; title: string }
| {
type: "doc_edited";
filename: string;
document_id: string;
version_id: string;
/** Per-document monotonic Vn written at emit time. */
version_number?: number | null;
download_url: string;
annotations: MikeEditAnnotation[];
type: "doc_edited";
filename: string;
document_id: string;
version_id: string;
/** Per-document monotonic Vn written at emit time. */
version_number?: number | null;
download_url: string;
annotations: EditAnnotation[];
error?: string;
isStreaming?: boolean;
}
| {
type: "courtlistener_search_case_law";
query: string;
result_count?: number;
error?: string;
isStreaming?: boolean;
}
| {
type: "courtlistener_get_cases";
cluster_ids: number[];
case_count?: number;
opinion_count?: number;
cases?: {
cluster_id: number;
case_name: string | null;
citation: string | null;
dateFiled?: string | null;
url?: string | null;
}[];
error?: string;
isStreaming?: boolean;
}
| {
type: "courtlistener_find_in_case";
cluster_id: number | null;
query: string;
total_matches?: number;
case_name?: string | null;
citation?: string | null;
searches?: {
cluster_id: number | null;
query: string;
total_matches?: number;
case_name?: string | null;
citation?: string | null;
error?: string;
isStreaming?: boolean;
}[];
error?: string;
isStreaming?: boolean;
}
| {
type: "courtlistener_read_case";
cluster_id: number | null;
case_name?: string | null;
citation?: string | null;
opinion_count?: number;
error?: string;
isStreaming?: boolean;
}
| {
type: "courtlistener_verify_citations";
citation_count?: number;
match_count?: number;
error?: string;
isStreaming?: boolean;
}
| {
type: "case_citation";
cluster_id: number | null;
case_name: string | null;
citation: string | null;
url: string;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
case?: Extract<AssistantEvent, { type: "case_opinions" }>["case"];
}
| {
type: "case_opinions";
cluster_id: number;
case: {
id: number | null;
caseName?: string | null;
dateFiled?: string | null;
citations?: string[];
url?: string | null;
pdfUrl?: string | null;
opinions: {
opinionId: number | null;
apiUrl?: string | null;
type: string | null;
author: string | null;
url: string | null;
text?: string | null;
html?: string | null;
}[];
};
}
| { type: "content"; text: string; isStreaming?: boolean };
export interface MikeMessage {
export type CaseCitationQuote = {
opinionId: number | null;
type: string | null;
author: string | null;
quote: string;
};
export interface Message {
role: "user" | "assistant";
content: string;
files?: { filename: string; document_id?: string }[];
workflow?: { id: string; title: string };
model?: string;
annotations?: MikeCitationAnnotation[];
annotations?: CitationAnnotation[];
citationStatus?: "started" | "partial" | "final";
events?: AssistantEvent[];
/** Set when streaming failed; rendered as a red error block. */
error?: string;
}
export interface CitationQuote {
page: number;
page?: number;
quote: string;
}
/**
* A citation emitted by the assistant. Single-page citations have a numeric
* `page` and a plain `quote`. A citation that spans a page break (one
* continuous sentence cut by a page boundary) has `page` as a range string
* like "41-42" and a `quote` containing the `[[PAGE_BREAK]]` sentinel at the
* break point (text before is on page 41, text after is on page 42).
*/
export interface MikeCitationAnnotation {
export type DocumentCitationQuote = {
page: number | string;
quote: string;
};
export type DocumentCitationAnnotation = {
type: "citation_data";
kind?: "document";
ref: number;
doc_id: string;
document_id: string;
version_id?: string | null;
version_number?: number | null;
filename: string;
/** Legacy single-quote fields. Prefer `quotes` for new annotations. */
page: number | string;
quote: string;
}
quotes?: DocumentCitationQuote[];
};
export type CaseCitationAnnotation = {
type: "citation_data";
kind: "case";
ref: number;
cluster_id: number;
case_name?: string | null;
citation?: string | null;
url?: string | null;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
quotes: CaseCitationQuote[];
};
/**
* A citation emitted by the assistant. Document citations have doc/page
* anchors. Case citations anchor to a CourtListener cluster and include a
* quoted opinion passage.
*/
export type CitationAnnotation =
| DocumentCitationAnnotation
| CaseCitationAnnotation;
const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]";
function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[] {
const rangeMatch =
typeof entry.page === "string"
? entry.page.match(/^(\d+)\s*-\s*(\d+)$/)
: null;
if (rangeMatch && entry.quote.includes(PAGE_BREAK_SENTINEL)) {
const startPage = parseInt(rangeMatch[1], 10);
const endPage = parseInt(rangeMatch[2], 10);
const [before, after] = entry.quote.split(PAGE_BREAK_SENTINEL);
return [
{ page: startPage, quote: before.trim() },
{ page: endPage, quote: after.trim() },
].filter((e) => e.quote.length > 0);
}
const pageNum =
typeof entry.page === "number"
? entry.page
: parseInt(String(entry.page), 10);
if (!Number.isFinite(pageNum)) return [];
return [{ page: pageNum, quote: entry.quote }];
}
export function getDocumentCitationQuotes(
a: CitationAnnotation,
): DocumentCitationQuote[] {
if (a.kind === "case") return [];
if (Array.isArray(a.quotes) && a.quotes.length) {
return a.quotes.filter((entry) => entry.quote.trim().length > 0);
}
return [{ page: a.page, quote: a.quote }];
}
/**
* Expand a citation into one or more (page, quote) entries suitable for
* highlighting in the PDF viewer. A single-page citation yields one entry; a
* cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two.
*/
export function expandCitationToEntries(
a: MikeCitationAnnotation,
a: CitationAnnotation,
): CitationQuote[] {
const rangeMatch =
typeof a.page === "string"
? a.page.match(/^(\d+)\s*-\s*(\d+)$/)
: null;
if (rangeMatch && a.quote.includes(PAGE_BREAK_SENTINEL)) {
const startPage = parseInt(rangeMatch[1], 10);
const endPage = parseInt(rangeMatch[2], 10);
const [before, after] = a.quote.split(PAGE_BREAK_SENTINEL);
return [
{ page: startPage, quote: before.trim() },
{ page: endPage, quote: after.trim() },
].filter((e) => e.quote.length > 0);
}
const pageNum =
typeof a.page === "number" ? a.page : parseInt(String(a.page), 10);
if (!Number.isFinite(pageNum)) return [];
return [{ page: pageNum, quote: a.quote }];
if (a.kind === "case") return [];
return getDocumentCitationQuotes(a).flatMap(expandDocumentQuoteEntry);
}
/** Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42". */
export function formatCitationPage(a: MikeCitationAnnotation): string {
export function formatCitationPage(a: CitationAnnotation): string {
if (a.kind === "case") {
return a.citation || a.case_name || `Case ${a.cluster_id}`;
}
const quotes = getDocumentCitationQuotes(a);
const pages = Array.from(
new Set(quotes.map((q) => String(q.page)).filter(Boolean)),
);
if (pages.length > 1) return `Pages ${pages.join(", ")}`;
if (pages.length === 1) return `Page ${pages[0]}`;
if (typeof a.page === "string") return `Page ${a.page}`;
return `Page ${a.page}`;
}
/** Produce a reader-friendly version of the quote (replaces [[PAGE_BREAK]] with "..."). */
export function displayCitationQuote(a: MikeCitationAnnotation): string {
return a.quote.replaceAll(PAGE_BREAK_SENTINEL, "...");
export function displayCitationQuote(a: CitationAnnotation): string {
if (a.kind === "case") {
return a.quotes
.map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "..."))
.join(" / ");
}
return getDocumentCitationQuotes(a)
.map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "..."))
.join(" / ");
}
// Tabular Review
export type ColumnFormat =
| "text"
| "bulleted_list"
| "number"
| "currency"
| "yes_no"
| "date"
| "tag"
| "percentage"
| "monetary_amount";
| "text"
| "bulleted_list"
| "number"
| "currency"
| "yes_no"
| "date"
| "tag"
| "percentage"
| "monetary_amount";
export interface ColumnConfig {
index: number;
name: string;
prompt: string;
format?: ColumnFormat;
tags?: string[];
index: number;
name: string;
prompt: string;
format?: ColumnFormat;
tags?: string[];
}
export interface TabularReview {
@ -273,7 +429,7 @@ export interface TabularCell {
// Workflows
export interface MikeWorkflow {
export interface Workflow {
id: string;
user_id: string | null;
title: string;
@ -290,13 +446,13 @@ export interface MikeWorkflow {
// API helpers
export interface MikeChatDetailOut {
chat: MikeChat;
messages: MikeMessage[];
export interface ChatDetailOut {
chat: Chat;
messages: Message[];
}
export interface TabularReviewDetailOut {
review: TabularReview;
cells: TabularCell[];
documents: MikeDocument[];
documents: Document[];
}

View file

@ -2,13 +2,13 @@
import { useEffect, useState } from "react";
import { getProject, listProjects, listStandaloneDocuments } from "@/app/lib/mikeApi";
import type { MikeDocument, MikeProject } from "./types";
import type { Document, Project } from "./types";
const CACHE_TTL_MS = 30_000;
interface DirectoryCache {
standaloneDocuments: MikeDocument[];
projects: MikeProject[];
standaloneDocuments: Document[];
projects: Project[];
fetchedAt: number;
}
@ -20,8 +20,8 @@ export function invalidateDirectoryCache() {
export function useDirectoryData(enabled: boolean) {
const [loading, setLoading] = useState(true);
const [standaloneDocuments, setStandaloneDocuments] = useState<MikeDocument[]>([]);
const [projects, setProjects] = useState<MikeProject[]>([]);
const [standaloneDocuments, setStandaloneDocuments] = useState<Document[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => {
if (!enabled) return;