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

@ -9,15 +9,21 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
import type { MikeDocument } from "../shared/types";
import type { Document } from "../shared/types";
interface Props {
onSelectDoc: (doc: MikeDocument) => void;
onSelectDoc: (doc: Document) => void;
onBrowseAll: () => void;
selectedDocIds?: string[];
hideLabel?: boolean;
}
export function AddDocButton({ onSelectDoc, onBrowseAll, selectedDocIds = [] }: Props) {
export function AddDocButton({
onSelectDoc,
onBrowseAll,
selectedDocIds = [],
hideLabel = false,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -67,7 +73,7 @@ export function AddDocButton({ onSelectDoc, onBrowseAll, selectedDocIds = [] }:
className={`h-4 w-4 shrink-0 transition-transform duration-300 ${isOpen ? "rotate-[135deg]" : ""}`}
/>
)}
<span className="hidden sm:inline">
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
{selectedDocIds.length === 1
? "Document"
: "Documents"}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,23 @@
"use client";
import { useCallback, useRef, useState } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { X } from "lucide-react";
import { DocPanel, type DocPanelMode } from "../shared/DocPanel";
import type {
MikeCitationAnnotation,
MikeEditAnnotation,
CitationAnnotation,
EditAnnotation,
} from "../shared/types";
import {
CaseLawPanel,
type CaseTab,
} from "./CaseLawPanel";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Tab data
@ -34,15 +45,19 @@ export type DocumentTab = CommonTab & { kind: "document" };
export type CitationTab = CommonTab & {
kind: "citation";
citation: MikeCitationAnnotation;
citation: CitationAnnotation;
};
export type EditTab = CommonTab & {
kind: "edit";
edit: MikeEditAnnotation;
edit: EditAnnotation;
};
export type AssistantSidePanelTab = DocumentTab | CitationTab | EditTab;
export type AssistantSidePanelTab =
| DocumentTab
| CitationTab
| EditTab
| CaseTab;
interface Props {
tabs: AssistantSidePanelTab[];
@ -86,6 +101,22 @@ interface Props {
const MIN_WIDTH = 300;
const MAX_WIDTH_OFFSET = 56; // sidebar width
const MIN_CHAT_WIDTH = 400;
function maxPanelWidth() {
if (typeof window === "undefined") return 600;
return Math.max(
MIN_WIDTH,
window.innerWidth - MAX_WIDTH_OFFSET - MIN_CHAT_WIDTH,
);
}
function tabTitle(tab: AssistantSidePanelTab): string {
if (tab.kind === "case") {
return tab.caseName || tab.citation || "Case";
}
return tab.filename;
}
export function AssistantSidePanel({
tabs,
@ -104,7 +135,10 @@ export function AssistantSidePanel({
const panelRef = useRef<HTMLDivElement>(null);
const [panelWidth, setPanelWidth] = useState(() =>
typeof window !== "undefined"
? Math.round((window.innerWidth - MAX_WIDTH_OFFSET) / 2)
? Math.min(
maxPanelWidth(),
Math.round((window.innerWidth - MAX_WIDTH_OFFSET) / 2),
)
: 600,
);
@ -120,10 +154,9 @@ export function AssistantSidePanel({
const onMouseMove = (ev: MouseEvent) => {
const delta = dragStartX.current - ev.clientX;
const maxWidth = window.innerWidth - MAX_WIDTH_OFFSET - 200;
setPanelWidth(
Math.min(
maxWidth,
maxPanelWidth(),
Math.max(MIN_WIDTH, dragStartWidth.current + delta),
),
);
@ -143,46 +176,73 @@ export function AssistantSidePanel({
[panelWidth],
);
useEffect(() => {
const onResize = () => {
setPanelWidth((width) =>
Math.min(maxPanelWidth(), Math.max(MIN_WIDTH, width)),
);
};
window.addEventListener("resize", onResize);
onResize();
return () => window.removeEventListener("resize", onResize);
}, []);
const active = tabs.find((t) => t.id === activeTabId) ?? tabs[0] ?? null;
if (!active) return null;
return (
<div
ref={panelRef}
className="flex h-full shrink-0 flex-col bg-white relative border-l border-gray-200 shadow-[-4px_0_12px_rgba(0,0,0,0.02)]"
style={{ width: panelWidth }}
className={cn(
"relative flex h-full w-full shrink-0 flex-col md:my-3 md:mr-3 md:h-[calc(100%-1.5rem)] md:w-[var(--assistant-panel-width)]",
"rounded-2xl border border-white/70 bg-white shadow-[0_6px_18px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
)}
style={{
"--assistant-panel-width": `${panelWidth}px`,
} as CSSProperties}
>
{/* Drag handle */}
<div
onMouseDown={onMouseDown}
className="absolute left-0 top-0 h-full w-1 cursor-col-resize hover:bg-blue-400 transition-colors z-10"
className={cn(
"absolute left-0 top-0 z-10 hidden h-full w-1 cursor-col-resize transition-colors md:block",
"hover:bg-blue-400/70",
)}
style={{ marginLeft: -2 }}
/>
{/* Tab strip (Chrome-style) */}
<div className="flex items-end gap-1 pr-2 pt-2 bg-gray-100">
<div className="flex-1 flex items-end gap-1 overflow-x-auto pl-2 pr-2">
<div
className={cn(
"flex items-end gap-1 px-1 pt-2",
"bg-gray-200/80",
)}
>
<div className="flex-1 flex items-end gap-1 overflow-hidden px-2">
{tabs.map((tab) => {
const isActive = tab.id === active.id;
const showVersionBadge =
tab.kind !== "case" &&
typeof tab.versionNumber === "number" &&
Number.isFinite(tab.versionNumber) &&
tab.versionNumber > 1;
const title = tabTitle(tab);
return (
<div
key={tab.id}
onClick={() => onActivateTab(tab.id)}
className={`group relative flex items-center gap-1.5 pl-3 pr-1.5 h-8 min-w-0 max-w-[220px] rounded-t-lg cursor-pointer select-none transition-colors ${
className={cn(
"group relative flex items-center gap-1.5 pl-3 pr-1.5 h-8 min-w-0 max-w-[220px] rounded-t-lg cursor-pointer select-none transition-colors",
isActive
? "bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:w-2 before:h-2 before:bg-[radial-gradient(circle_at_top_left,transparent_8px,white_9px)] after:content-[''] after:absolute after:bottom-0 after:-right-2 after:w-2 after:h-2 after:bg-[radial-gradient(circle_at_top_right,transparent_8px,white_9px)]"
: "bg-gray-200/70 text-gray-600 hover:bg-gray-200"
}`}
? "z-20 bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:z-20 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_white] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:z-20 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_white] after:transition-shadow"
: "z-10 bg-gray-100 text-gray-600 hover:bg-gray-100 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_#f3f4f6] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_#f3f4f6] after:transition-shadow",
)}
>
<span
className={`min-w-0 flex-1 truncate text-xs ${isActive ? "font-medium" : "font-normal"}`}
title={tab.filename}
title={title}
>
{tab.filename}
{title}
</span>
{showVersionBadge && (
<span
@ -200,7 +260,7 @@ export function AssistantSidePanel({
e.stopPropagation();
onCloseTab(tab.id);
}}
className="shrink-0 rounded-full p-0.5 text-gray-400 hover:bg-gray-300 hover:text-gray-700"
className="shrink-0 rounded-full p-0.5 text-gray-400 hover:text-gray-700"
>
<X className="h-3 w-3" />
</button>
@ -210,7 +270,7 @@ export function AssistantSidePanel({
</div>
<button
onClick={onCloseAll}
className="shrink-0 mb-1 ml-1 rounded-lg p-1.5 text-gray-400 hover:bg-gray-200 hover:text-gray-700"
className="shrink-0 mb-1 ml-1 rounded-lg p-1.5 text-gray-400 hover:text-gray-700"
title="Close panel"
>
<X className="h-4 w-4" />
@ -223,6 +283,20 @@ export function AssistantSidePanel({
<div className="flex-1 min-h-0 relative">
{tabs.map((tab) => {
const isActive = tab.id === active.id;
if (tab.kind === "case") {
return (
<div
key={tab.id}
className={`absolute inset-0 flex flex-col ${isActive ? "" : "invisible pointer-events-none"}`}
aria-hidden={!isActive}
>
<CaseLawPanel
tab={tab}
compactActions={panelWidth < 600}
/>
</div>
);
}
const mode: DocPanelMode =
tab.kind === "citation"
? {

View file

@ -1,18 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, Search, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { MikeWorkflow } from "../shared/types";
import type { Workflow } from "../shared/types";
import { listWorkflows } from "@/app/lib/mikeApi";
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
import { Modal } from "../shared/Modal";
interface Props {
open: boolean;
onClose: () => void;
onSelect: (workflow: MikeWorkflow) => void;
onSelect: (workflow: Workflow) => void;
projectName?: string;
projectCmNumber?: string | null;
initialWorkflowId?: string;
@ -26,9 +26,9 @@ export function AssistantWorkflowModal({
projectCmNumber,
initialWorkflowId,
}: Props) {
const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]);
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<MikeWorkflow | null>(null);
const [selected, setSelected] = useState<Workflow | null>(null);
const [search, setSearch] = useState("");
const [rightVisible, setRightVisible] = useState(false);
@ -87,45 +87,28 @@ export function AssistantWorkflowModal({
onClose();
}
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
<div
className={`w-full rounded-2xl bg-white shadow-2xl flex flex-col h-[600px] ${selected ? "max-w-4xl" : "max-w-2xl"}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 shrink-0 border-b border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{projectName ? (
<>
<span>Projects</span>
<span></span>
<span>
{projectName}
{projectCmNumber
? ` (#${projectCmNumber})`
: ""}
</span>
<span></span>
<span>Assistant</span>
<span></span>
<span>Add workflow</span>
</>
) : (
<>
<span>Assistant</span>
<span></span>
<span>Add workflow</span>
</>
)}
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
const breadcrumbs = projectName
? [
"Projects",
`${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`,
"Assistant",
"Add workflow",
]
: ["Assistant", "Add workflow"];
return (
<Modal
open={open}
onClose={onClose}
size={selected ? "xl" : "lg"}
breadcrumbs={breadcrumbs}
primaryAction={{
label: "Use",
type: "button",
onClick: handleUse,
disabled: !selected,
}}
>
{/* Content */}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Left panel — workflow list */}
@ -133,7 +116,7 @@ export function AssistantWorkflowModal({
className={`overflow-y-auto ${selected ? "w-80 shrink-0" : "flex-1"}`}
>
{/* Search */}
<div className="px-4 pt-3 pb-2 shrink-0">
<div className="pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
@ -152,7 +135,7 @@ export function AssistantWorkflowModal({
</div>
{loading ? (
<div className="space-y-px px-4 pt-1">
<div className="space-y-px pt-1">
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
<div
key={i}
@ -167,7 +150,7 @@ export function AssistantWorkflowModal({
))}
</div>
) : filteredWorkflows.length === 0 ? (
<p className="px-4 py-8 text-sm text-center text-gray-400">
<p className="py-8 text-sm text-center text-gray-400">
{search ? "No matches found" : "No assistant workflows found"}
</p>
) : (
@ -268,26 +251,6 @@ export function AssistantWorkflowModal({
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-end gap-2 shrink-0">
<button
type="button"
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleUse}
disabled={!selected}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
Use
</button>
</div>
</div>
</div>,
document.body,
</Modal>
);
}

View file

@ -0,0 +1,623 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import DOMPurify from "dompurify";
import {
Download,
ExternalLink,
} from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
import type { CaseCitationQuote } from "../shared/types";
import {
clearDocxQuoteHighlights,
highlightDocxQuote,
} from "../shared/highlightDocxQuote";
import {
RelevantQuotes,
type RelevantQuoteItem,
} from "../shared/RelevantQuotes";
import {
getCourtlistenerOpinions,
type CaseLawOpinion,
} from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
export type CaseTab = {
kind: "case";
id: `case:${number}`;
chatId: string;
clusterId: number;
citationRef?: number;
caseName: string | null;
citation: string | null;
url: string | null;
dateFiled: string | null;
pdfUrl: string | null;
judges: string | null;
quotes?: CaseCitationQuote[];
opinions?: CaseLawOpinion[];
};
const courtlistenerOpinionsCache = new Map<number, CaseLawOpinion[]>();
const caseOpinionsRequestCache = new Map<
string,
ReturnType<typeof getCourtlistenerOpinions>
>();
const CASE_OPINION_SANITIZER_CONFIG = {
ALLOWED_TAGS: [
"a",
"blockquote",
"br",
"code",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"i",
"li",
"ol",
"p",
"pre",
"small",
"span",
"strong",
"sub",
"sup",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"u",
"ul",
],
ALLOWED_ATTR: [
"aria-label",
"class",
"colspan",
"href",
"id",
"rel",
"rowspan",
"target",
"title",
],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: true,
ALLOWED_URI_REGEXP: /^(?:https:\/\/www\.courtlistener\.com\/|#)/i,
FORBID_ATTR: ["style"],
FORBID_TAGS: [
"embed",
"form",
"iframe",
"math",
"object",
"script",
"style",
"svg",
],
RETURN_TRUSTED_TYPE: false,
};
function sanitizeCaseOpinionHtml(value: string): string {
const sanitized = DOMPurify.sanitize(
value,
CASE_OPINION_SANITIZER_CONFIG,
);
if (typeof document === "undefined") return sanitized;
const template = document.createElement("template");
template.innerHTML = sanitized;
template.content.querySelectorAll("a[href]").forEach((anchor) => {
const href = anchor.getAttribute("href") ?? "";
if (href.startsWith("#")) return;
anchor.setAttribute("target", "_blank");
anchor.setAttribute("rel", "noopener noreferrer");
});
return template.innerHTML;
}
function friendlyCaseError(message: string): string {
try {
const parsed = JSON.parse(message) as { detail?: unknown };
if (typeof parsed.detail === "string") {
message = parsed.detail;
}
} catch {
/* keep original message */
}
if (message.includes("429") || /rate limit|throttled/i.test(message)) {
const waitMatch = message.match(/available in\s+(\d+)\s+seconds/i);
const wait = waitMatch?.[1];
return wait
? `CourtListener is rate limiting requests. Please try again in about ${wait} seconds.`
: "CourtListener is rate limiting requests. Please try again shortly.";
}
if (message.includes("401") || /credentials|token|auth/i.test(message)) {
return "CourtListener authentication is not configured correctly.";
}
return "Could not load this case from CourtListener. Please try again shortly.";
}
function formatCaseDate(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date);
}
function hashString(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}
function caseTabQuoteKey(tab: CaseTab): string {
const quoteKey =
tab.quotes
?.map((quote) => quote.quote)
.filter(Boolean)
.join("\n---\n") ?? "";
return [tab.clusterId, tab.citationRef ?? "source", hashString(quoteKey)].join(":");
}
function relevantQuoteKey(quote: CaseCitationQuote, index: number): string {
return `${quote.opinionId ?? "unknown"}:${index}:${hashString(quote.quote)}`;
}
function caseCitationRequestKey(tab: CaseTab) {
return String(tab.clusterId);
}
export function CaseLawPanel({
tab,
compactActions = false,
}: {
tab: CaseTab;
compactActions?: boolean;
}) {
const cachedOpinions = courtlistenerOpinionsCache.get(tab.clusterId);
const [opinions, setOpinions] = useState<CaseLawOpinion[]>(
tab.opinions?.length ? tab.opinions : (cachedOpinions ?? []),
);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [activeOpinionId, setActiveOpinionId] = useState<number | null>(null);
const [relevantQuotes, setRelevantQuotes] = useState<CaseCitationQuote[]>(
tab.quotes ?? [],
);
const [activeQuoteKey, setActiveQuoteKey] = useState<string | null>(null);
const [quoteIndexState, setQuoteIndexState] = useState({
cacheKey: "",
index: 0,
});
const opinionScrollRef = useRef<HTMLDivElement | null>(null);
const opinionContentRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (tab.opinions?.length) {
setOpinions(tab.opinions);
setLoading(false);
setError(null);
return;
}
const cached = courtlistenerOpinionsCache.get(tab.clusterId);
if (cached?.length) {
setOpinions(cached);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
const requestKey = caseCitationRequestKey(tab);
let request = caseOpinionsRequestCache.get(requestKey);
if (!request) {
request = getCourtlistenerOpinions(tab.clusterId).finally(() => {
caseOpinionsRequestCache.delete(requestKey);
});
caseOpinionsRequestCache.set(requestKey, request);
}
request
.then((nextOpinions) => {
if (!cancelled) {
setOpinions(nextOpinions);
courtlistenerOpinionsCache.set(tab.clusterId, nextOpinions);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(
err instanceof Error
? friendlyCaseError(err.message)
: "Failed to load case",
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [tab]);
useEffect(() => {
const firstOpinionId =
orderOpinions(opinions).find(
({ opinion }) => typeof opinion.opinionId === "number",
)?.opinion.opinionId ?? null;
setActiveOpinionId(firstOpinionId);
}, [opinions]);
useEffect(() => {
setRelevantQuotes(tab.quotes ?? []);
}, [tab.quotes]);
const title = tab.caseName;
const citation = tab.citation;
const courtlistenerUrl = tab.url;
const filedDate = formatCaseDate(tab.dateFiled);
const judges = tab.judges?.trim() || null;
const orderedOpinions = orderOpinions(opinions);
const activeOpinion = opinions.find(
(opinion) => opinion.opinionId === activeOpinionId,
);
const quoteCacheKey = caseTabQuoteKey(tab);
const currentQuoteIndex =
quoteIndexState.cacheKey === quoteCacheKey
? Math.min(
quoteIndexState.index,
Math.max(relevantQuotes.length - 1, 0),
)
: 0;
const relevantQuoteItems: RelevantQuoteItem[] = relevantQuotes.map(
(quote, index) => ({
id: relevantQuoteKey(quote, index),
quote: quote.quote,
eyebrow:
quote.author || quote.type
? opinionTitle({
opinionId: quote.opinionId,
type: quote.type,
author: quote.author,
url: null,
})
: null,
}),
);
const selectRelevantQuote = useCallback(
(quote: CaseCitationQuote, index: number) => {
const key = relevantQuoteKey(quote, index);
setQuoteIndexState({ cacheKey: quoteCacheKey, index });
setActiveQuoteKey((current) => (current === key ? null : key));
if (typeof quote.opinionId === "number") {
setActiveOpinionId(quote.opinionId);
}
},
[quoteCacheKey],
);
useEffect(() => {
setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 });
const firstQuote = relevantQuotes[0];
setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null);
if (typeof firstQuote?.opinionId === "number") {
setActiveOpinionId(firstQuote.opinionId);
}
}, [quoteCacheKey, relevantQuotes]);
useEffect(() => {
const root = opinionContentRef.current;
if (!root) return;
clearDocxQuoteHighlights(root);
if (!activeQuoteKey) return;
const activeEntry = relevantQuotes
.map((quote, index) => ({ quote, index }))
.find(
({ quote, index }) =>
relevantQuoteKey(quote, index) === activeQuoteKey,
);
if (!activeEntry) return;
if (
typeof activeEntry.quote.opinionId === "number" &&
activeEntry.quote.opinionId !== activeOpinionId
) {
return;
}
const match = highlightDocxQuote(root, activeEntry.quote.quote);
if (!match) return;
window.setTimeout(() => {
match.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}, [
activeOpinionId,
activeOpinion?.html,
activeOpinion?.opinionId,
activeOpinion?.text,
activeQuoteKey,
relevantQuotes,
]);
const opinionSurfaceClassName = "bg-white/60 backdrop-blur-xl";
return (
<div className="flex h-full flex-col">
<div className="flex items-start gap-3 px-3 pt-4 pb-3">
<div className="min-w-0 flex-1">
<h2 className="font-serif text-xl text-gray-900">
{title}
{citation && (
<span className="text-gray-500">, {citation}</span>
)}
</h2>
{filedDate || judges ? (
<p className="mt-1 font-serif text-sm text-gray-600">
{filedDate && <>Date: {filedDate}</>}
{filedDate && judges && (
<span className="mx-1.5 text-gray-300">|</span>
)}
{judges && <>Judges: {judges}</>}
</p>
) : null}
</div>
<div className="flex min-w-0 shrink flex-wrap items-center justify-end gap-2">
{tab.pdfUrl && (
<a
href={tab.pdfUrl}
target="_blank"
rel="noopener noreferrer"
download
aria-label="Download PDF"
title="Download PDF"
className={`inline-flex min-w-0 shrink items-center justify-center rounded-lg border border-gray-200 text-xs text-gray-700 hover:bg-gray-50 ${
compactActions
? "h-8 w-8 p-0"
: "gap-1.5 px-2.5 py-1.5"
}`}
>
<span
className={
compactActions ? "sr-only" : "truncate"
}
>
PDF
</span>
<Download className="h-3.5 w-3.5" />
</a>
)}
{courtlistenerUrl && (
<a
href={courtlistenerUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Open in CourtListener"
title="Open in CourtListener"
className={`inline-flex min-w-0 shrink items-center justify-center rounded-lg border border-gray-200 text-xs text-gray-700 hover:bg-gray-50 ${
compactActions
? "h-8 w-8 p-0"
: "gap-1.5 px-2.5 py-1.5"
}`}
>
<span
className={
compactActions ? "sr-only" : "truncate"
}
>
CourtListener
</span>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
</div>
{relevantQuoteItems.length > 0 && (
<RelevantQuotes
quotes={relevantQuoteItems}
activeQuoteId={activeQuoteKey}
currentIndex={currentQuoteIndex}
citationRef={tab.citationRef}
citationText={[title, citation].filter(Boolean).join(", ")}
onSelect={(_quote, index) => {
const quote = relevantQuotes[index];
if (quote) selectRelevantQuote(quote, index);
}}
onIndexChange={(index) => {
const quote = relevantQuotes[index];
if (quote) selectRelevantQuote(quote, index);
}}
/>
)}
{!loading && !error && opinions.length > 1 && (
<div className="relative mt-2 px-1 shadow-[inset_0_-1px_0_rgb(229_231_235)]">
<div className="relative z-10 flex items-end gap-1 overflow-hidden px-2 pt-1">
{orderedOpinions.map(({ opinion, index }) => {
const opinionId = opinion.opinionId;
const isActive =
opinionId !== null &&
opinionId === activeOpinionId;
return (
<button
key={opinionId ?? index}
type="button"
disabled={opinionId === null}
onClick={() => {
if (opinionId === null) return;
setActiveOpinionId(opinionId);
setActiveQuoteKey(null);
}}
style={
isActive
? {
filter: "drop-shadow(0 -1px 0 #e5e7eb) drop-shadow(-1px 0 0 #e5e7eb) drop-shadow(1px 0 0 #e5e7eb)",
}
: undefined
}
className={`group relative flex h-8 max-w-[180px] shrink-0 items-center rounded-t-lg px-3 font-serif text-[13px] transition-colors ${
isActive
? "z-20 bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:z-20 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_white] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:z-20 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_white] after:transition-shadow"
: "z-10 bg-gray-100 text-gray-600 hover:bg-gray-100 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_#f3f4f6] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_#f3f4f6] after:transition-shadow"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
<span className="truncate">
{opinionTitle(opinion, index)}
</span>
</button>
);
})}
</div>
</div>
)}
<div className="flex flex-1 min-h-0 flex-col px-3 py-3">
{loading && (
<div className={cn("h-full min-h-0 rounded-lg border border-gray-200", opinionSurfaceClassName)}>
<div className="flex h-full items-center justify-center p-5">
<MikeIcon spin mike size={28} />
</div>
</div>
)}
{error && (
<p className={cn("rounded-md p-4 font-serif text-sm text-red-600", opinionSurfaceClassName)}>
{error}
</p>
)}
{!loading && !error && opinions.length === 0 && (
<p className={cn("rounded-md p-4 font-serif text-sm text-gray-500", opinionSurfaceClassName)}>
No opinions were returned for this case.
</p>
)}
{!loading && !error && opinions.length > 0 && (
<div className={cn("h-full min-h-0 border border-gray-200 rounded-lg overflow-hidden", opinionSurfaceClassName)}>
{activeOpinion && (
<div
ref={opinionScrollRef}
className={cn("h-full overflow-y-auto p-5", opinionSurfaceClassName)}
>
<OpinionBlock
opinion={activeOpinion}
contentRef={opinionContentRef}
/>
</div>
)}
</div>
)}
</div>
</div>
);
}
function opinionTypeLabel(value: string | null): string {
if (!value) return "Opinion";
const type = value.replace(/^\d+/, "").replace(/_/g, " ").trim();
const compactType = type.toLowerCase().replace(/\s+/g, "");
if (compactType === "lead") return "Lead Opinion";
if (
compactType === "concurrentinpart" ||
compactType === "concurrenceinpart" ||
compactType === "concurinpart"
) {
return "Concurrence in part";
}
if (compactType === "combined") return "Combined Opinion";
return type.replace(/\b\w/g, (char) => char.toUpperCase());
}
function opinionOrderRank(value: string | null): number {
const type = value?.replace(/^\d+/, "").toLowerCase() ?? "";
if (
type.includes("lead") ||
type.includes("majority") ||
type.includes("unanimous") ||
type.includes("plurality")
) {
return 0;
}
if (type.includes("concurr")) return 1;
if (type.includes("dissent")) return 2;
if (type.includes("combined")) return 4;
return 3;
}
function orderOpinions(opinions: CaseLawOpinion[]) {
return opinions
.map((opinion, index) => ({ opinion, index }))
.sort((a, b) => {
const rankDelta =
opinionOrderRank(a.opinion.type) -
opinionOrderRank(b.opinion.type);
return rankDelta || a.index - b.index;
});
}
function opinionTitle(opinion: CaseLawOpinion, index?: number): string {
const type = opinionTypeLabel(opinion.type);
const fallbackType = opinion.type ? type : `Opinion ${index ?? ""}`.trim();
return opinion.author
? `${fallbackType} by ${opinion.author}`
: fallbackType;
}
function OpinionBlock({
opinion,
contentRef,
}: {
opinion: CaseLawOpinion;
contentRef?: RefObject<HTMLElement | null>;
}) {
const sanitizedHtml = useMemo(
() =>
opinion.html
? sanitizeCaseOpinionHtml(opinion.html)
: "",
[opinion.html],
);
return (
<article
ref={contentRef}
className="case-opinion-content border-b border-gray-100 pb-6 last:border-b-0"
>
<div className="mb-3">
<h3 className="font-serif text-lg font-semibold text-gray-900">
{opinionTitle(opinion)}
</h3>
</div>
{sanitizedHtml ? (
<div
className="prose prose-sm max-w-none font-serif leading-7 text-gray-900 [&_*]:font-serif [&_.case-page-number]:mx-1 [&_.case-page-number]:text-xs [&_.case-page-number]:text-gray-400 [&_a]:text-blue-600 [&_a]:underline [&_a:hover]:text-blue-700 [&_p]:my-3"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
) : (
<div className="whitespace-pre-wrap font-serif text-sm leading-7 text-gray-900 [&_p]:my-3">
{opinion.text || "No opinion text returned."}
</div>
)}
</article>
);
}

View file

@ -3,6 +3,7 @@
import {
useState,
useCallback,
useEffect,
useRef,
forwardRef,
useImperativeHandle,
@ -29,14 +30,15 @@ import {
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { MikeDocument, MikeMessage } from "../shared/types";
import type { Document, Message } from "../shared/types";
import { cn } from "@/lib/utils";
export interface ChatInputHandle {
addDoc: (doc: MikeDocument) => void;
addDoc: (doc: Document) => void;
}
interface Props {
onSubmit: (message: MikeMessage) => void;
onSubmit: (message: Message) => void;
onCancel: () => void;
isLoading: boolean;
hideAddDocButton?: boolean;
@ -60,7 +62,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
ref,
) {
const [value, setValue] = useState("");
const [attachedDocs, setAttachedDocs] = useState<MikeDocument[]>([]);
const [attachedDocs, setAttachedDocs] = useState<Document[]>([]);
const [selectedWorkflow, setSelectedWorkflow] = useState<{
id: string;
title: string;
@ -69,13 +71,15 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
const { profile } = useUserProfile();
const apiKeys = profile?.apiKeys;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const controlsRef = useRef<HTMLDivElement>(null);
const [compactControls, setCompactControls] = useState(false);
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
const [apiKeyModalProvider, setApiKeyModalProvider] =
useState<ModelProvider | null>(null);
useImperativeHandle(ref, () => ({
addDoc: (doc: MikeDocument) => {
addDoc: (doc: Document) => {
setAttachedDocs((prev) => {
if (prev.some((d) => d.id === doc.id)) return prev;
return [...prev, doc];
@ -83,7 +87,17 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
},
}));
const handleAddDocFromProject = useCallback((doc: MikeDocument) => {
useEffect(() => {
const el = controlsRef.current;
if (!el) return;
const update = () => setCompactControls(el.offsetWidth < 430);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const handleAddDocFromProject = useCallback((doc: Document) => {
setAttachedDocs((prev) => {
if (prev.some((d) => d.id === doc.id)) return prev;
return [...prev, doc];
@ -91,7 +105,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
}, []);
const handleAddDocsFromSelector = useCallback(
(selectedDocs: MikeDocument[]) => {
(selectedDocs: Document[]) => {
setAttachedDocs((prev) => {
const existing = new Set(prev.map((d) => d.id));
return [
@ -157,7 +171,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
return (
<>
<div className="w-full">
<div className="border border-gray-300 rounded-[16px] md:rounded-[20px] bg-white">
<div className="rounded-[18px] border border-white/65 bg-white/60 shadow-[0_4px_10px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl md:rounded-[22px]">
{/* Attached chips */}
{(selectedWorkflow || attachedDocs.length > 0) && (
<div className="flex flex-wrap gap-1.5 px-2 pt-2">
@ -184,12 +198,12 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
return (
<div
key={doc.id}
className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full text-xs text-white shadow border border-white/20 bg-black backdrop-blur-sm"
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
>
{isPdf ? (
<FileText className="h-2.5 w-2.5 shrink-0 text-red-400" />
<FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
) : (
<File className="h-2.5 w-2.5 shrink-0 text-blue-400" />
<File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
)}
<span className="max-w-[140px] truncate">
{doc.filename}
@ -203,7 +217,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
),
)
}
className="rounded-full p-0.5 ml-0.5 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
className="ml-0.5 rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-900/5 hover:text-gray-700"
>
<X className="h-2.5 w-2.5" />
</button>
@ -227,7 +241,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
</div>
{/* Controls */}
<div className="flex items-center justify-between md:p-2.5 p-2">
<div
ref={controlsRef}
className="flex items-center justify-between md:p-2.5 p-2"
>
<div className="flex items-center gap-1">
{!hideAddDocButton && (
<AddDocButton
@ -236,6 +253,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
selectedDocIds={attachedDocs.map(
(d) => d.id,
)}
hideLabel={compactControls}
/>
)}
{!hideWorkflowButton && (
@ -243,14 +261,25 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
type="button"
onClick={() => setWorkflowModalOpen(true)}
aria-label="Open workflows"
className={`flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors ${selectedWorkflow ? "text-blue-600 hover:bg-blue-50" : "text-gray-400 hover:bg-gray-100 hover:text-gray-700"}`}
className={cn(
"flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors",
selectedWorkflow
? "text-blue-600 hover:bg-white/55"
: "text-gray-400 hover:bg-white/55 hover:text-gray-700",
)}
>
{selectedWorkflow ? (
<Check className="h-3.5 w-3.5" />
) : (
<Library className="h-3.5 w-3.5" />
)}
<span className="hidden sm:inline">
<span
className={
compactControls
? "hidden"
: "hidden sm:inline"
}
>
Workflows
</span>
</button>
@ -260,7 +289,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
type="button"
onClick={onProjectsClick}
aria-label="Open projects"
className="flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors"
className={cn(
"flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:text-gray-700 transition-colors",
"hover:bg-white/55",
)}
>
<FolderOpen className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
@ -278,7 +310,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
/>
<button
type="button"
className="relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-8 w-8 flex items-center justify-center cursor-pointer disabled:cursor-default disabled:from-neutral-600 disabled:to-black backdrop-blur-xl border border-white/30 active:enabled:scale-95 transition-all duration-150"
className={cn(
"relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-8 w-8 flex items-center justify-center cursor-pointer disabled:cursor-default disabled:from-neutral-600 disabled:to-black backdrop-blur-xl border border-white/30 active:enabled:scale-95 transition-all duration-150",
"shadow-[0_5px_14px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.24)]",
)}
onClick={handleActionClick}
disabled={!isLoading && !value.trim()}
>

View file

@ -1,6 +1,7 @@
"use client";
import { useCallback, useState, useRef, useEffect } from "react";
import { flushSync } from "react-dom";
import { ArrowDown } from "lucide-react";
import { UserMessage } from "./UserMessage";
import { AssistantMessage } from "./AssistantMessage";
@ -11,21 +12,35 @@ import {
} from "./AssistantSidePanel";
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
import type {
MikeCitationAnnotation,
MikeEditAnnotation,
MikeMessage,
AssistantEvent,
CitationAnnotation,
EditAnnotation,
Message,
} from "../shared/types";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { invalidateDocxBytes } from "@/app/hooks/useFetchDocxBytes";
import { cn } from "@/lib/utils";
interface Props {
messages: MikeMessage[];
chatId?: string | null;
messages: Message[];
isResponseLoading: boolean;
handleChat: (message: MikeMessage) => Promise<string | null>;
handleChat: (message: Message) => Promise<string | null>;
cancel: () => void;
}
const ASSISTANT_PANEL_TRANSITION_MS = 500;
const MOBILE_BREAKPOINT_PX = 768;
function isSmallScreen() {
return (
typeof window !== "undefined" &&
window.innerWidth < MOBILE_BREAKPOINT_PX
);
}
export function ChatView({
chatId,
messages,
isResponseLoading,
handleChat,
@ -49,38 +64,86 @@ export function ChatView({
() => new Set(),
);
const { setSidebarOpen } = useSidebar();
const panelCloseTimerRef = useRef<number | null>(null);
const showPanel = useCallback(() => {
if (panelCloseTimerRef.current !== null) {
window.clearTimeout(panelCloseTimerRef.current);
panelCloseTimerRef.current = null;
}
flushSync(() => {
setSidebarOpen(false);
});
if (panelMounted) {
setPanelVisible(true);
return;
}
setPanelVisible(false);
setPanelMounted(true);
setSidebarOpen(false);
requestAnimationFrame(() =>
requestAnimationFrame(() => setPanelVisible(true)),
);
}, [panelMounted, setSidebarOpen]);
const restoreSidebarAfterPanelClose = useCallback(() => {
if (!isSmallScreen()) setSidebarOpen(true);
}, [setSidebarOpen]);
const closeAllTabs = useCallback(() => {
setPanelVisible(false);
setTimeout(() => {
setTabs([]);
setActiveTabId(null);
useEffect(
() => () => {
if (panelCloseTimerRef.current !== null) {
window.clearTimeout(panelCloseTimerRef.current);
}
},
[],
);
const hidePanel = useCallback(
(afterHidden: () => void) => {
if (panelCloseTimerRef.current !== null) {
window.clearTimeout(panelCloseTimerRef.current);
}
setPanelVisible(false);
panelCloseTimerRef.current = window.setTimeout(() => {
panelCloseTimerRef.current = null;
afterHidden();
}, ASSISTANT_PANEL_TRANSITION_MS);
},
[],
);
const unmountPanel = useCallback(
(afterUnmount?: () => void) => {
setPanelMounted(false);
setSidebarOpen(true);
}, 300);
}, [setSidebarOpen]);
restoreSidebarAfterPanelClose();
afterUnmount?.();
},
[restoreSidebarAfterPanelClose],
);
const closeAllTabs = useCallback(() => {
hidePanel(() =>
unmountPanel(() => {
setTabs([]);
setActiveTabId(null);
}),
);
}, [hidePanel, unmountPanel]);
const closeTab = useCallback(
(id: string) => {
setTabs((prev) => {
const next = prev.filter((t) => t.id !== id);
if (next.length === 0) {
setPanelVisible(false);
setTimeout(() => {
setActiveTabId(null);
setPanelMounted(false);
setSidebarOpen(true);
}, 300);
return next;
hidePanel(() =>
unmountPanel(() => {
setActiveTabId(null);
setTabs([]);
}),
);
return prev;
}
if (activeTabId === id) {
const idx = prev.findIndex((t) => t.id === id);
@ -90,7 +153,7 @@ export function ChatView({
return next;
});
},
[activeTabId, setSidebarOpen],
[activeTabId, hidePanel, unmountPanel],
);
/**
@ -104,18 +167,23 @@ export function ChatView({
const upsertTab = useCallback(
(tab: AssistantSidePanelTab) => {
setTabs((prev) => {
const idx = prev.findIndex(
(t) => t.documentId === tab.documentId,
const idx = prev.findIndex((t) =>
tab.kind === "case"
? t.kind === "case" && t.id === tab.id
: t.kind !== "case" && t.documentId === tab.documentId,
);
if (idx >= 0) {
const existing = prev[idx];
const copy = prev.slice();
copy[idx] = {
...tab,
id: existing.id,
warning: existing.warning,
initialScrollTop: existing.initialScrollTop,
};
copy[idx] =
tab.kind === "case" || existing.kind === "case"
? tab
: {
...tab,
id: existing.id,
warning: existing.warning,
initialScrollTop: existing.initialScrollTop,
};
return copy;
}
return [...prev, tab];
@ -131,7 +199,38 @@ export function ChatView({
* AssistantMessage when the user clicks a numbered citation pill.
*/
const openCitation = useCallback(
(citation: MikeCitationAnnotation) => {
(citation: CitationAnnotation, options?: { showQuotes?: boolean }) => {
const showQuotes = options?.showQuotes ?? true;
if (citation.kind === "case") {
if (!chatId) return;
upsertTab({
kind: "case",
id: `case:${citation.cluster_id}`,
chatId,
clusterId: citation.cluster_id,
citationRef: citation.ref,
caseName: citation.case_name ?? null,
citation: citation.citation ?? null,
url: citation.url ?? null,
dateFiled: citation.dateFiled ?? null,
pdfUrl: citation.pdfUrl ?? null,
judges: citation.judges ?? null,
quotes: showQuotes ? citation.quotes : undefined,
opinions: undefined,
});
return;
}
if (!showQuotes) {
upsertTab({
kind: "document",
id: citation.document_id,
documentId: citation.document_id,
filename: citation.filename,
versionId: citation.version_id ?? null,
versionNumber: citation.version_number ?? null,
});
return;
}
upsertTab({
kind: "citation",
id: citation.document_id,
@ -142,7 +241,30 @@ export function ChatView({
citation,
});
},
[upsertTab],
[chatId, upsertTab],
);
const openCase = useCallback(
(citation: Extract<AssistantEvent, { type: "case_citation" }>) => {
if (!citation.cluster_id) return;
if (!chatId) return;
upsertTab({
kind: "case",
id: `case:${citation.cluster_id}`,
chatId,
clusterId: citation.cluster_id,
citationRef: undefined,
caseName: citation.case_name,
citation: citation.citation,
url: citation.url,
dateFiled: citation.dateFiled ?? null,
pdfUrl: citation.pdfUrl ?? null,
judges: citation.judges ?? null,
quotes: undefined,
opinions: citation.case?.opinions,
});
},
[chatId, upsertTab],
);
/**
@ -150,7 +272,7 @@ export function ChatView({
* AssistantMessage when the user clicks an EditCard's View button.
*/
const openEditor = useCallback(
(ann: MikeEditAnnotation, filename: string) => {
(ann: EditAnnotation, filename: string) => {
upsertTab({
kind: "edit",
id: ann.document_id,
@ -260,15 +382,18 @@ export function ChatView({
[],
);
const patchTab = useCallback(
(
tabId: string,
patch: Partial<Pick<AssistantSidePanelTab, "warning" | "initialScrollTop">>,
patch: {
warning?: string | null;
initialScrollTop?: number | null;
},
) => {
setTabs((prev) => {
const idx = prev.findIndex((t) => t.id === tabId);
if (idx < 0) return prev;
if (prev[idx].kind === "case") return prev;
const copy = prev.slice();
copy[idx] = { ...copy[idx], ...patch };
return copy;
@ -287,7 +412,7 @@ export function ChatView({
// Surface the warning on every tab tied to this document.
setTabs((prev) =>
prev.map((t) =>
t.documentId === args.documentId
t.kind !== "case" && t.documentId === args.documentId
? { ...t, warning: args.message }
: t,
),
@ -328,8 +453,15 @@ export function ChatView({
const messagesEndRef = useRef<HTMLDivElement>(null);
const latestUserMessageRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false);
const [messagesVisible, setMessagesVisible] = useState(false);
// Seed "already in place" when messages exist at mount (a freshly created
// chat arrives with its first message in hand). Otherwise the skeleton +
// opacity-0 gate would flash the message out and fade it back in on every
// remount. Existing chats mount with messages === [] and fetch async, so
// they still start hidden and reveal once loaded.
const hasScrolledRef = useRef(messages.length > 0);
const [messagesVisible, setMessagesVisible] = useState(
() => messages.length > 0,
);
const [showScrollButton, setShowScrollButton] = useState(false);
const [inputHeight, setInputHeight] = useState(0);
const [minHeight, setMinHeight] = useState("0px");
@ -446,7 +578,7 @@ export function ChatView({
return (
<div className="h-full w-full flex relative">
{/* Chat column */}
<div className="flex flex-col h-full flex-1 relative">
<div className="flex min-w-0 flex-col h-full flex-1 relative">
{/* Scrollable messages */}
<div
ref={messagesContainerRef}
@ -507,13 +639,28 @@ export function ChatView({
}
isError={!!(msg as any).error}
errorMessage={
typeof (msg as any).error ===
"string"
typeof (msg as any)
.error === "string"
? (msg as any).error
: undefined
}
annotations={msg.annotations}
onCitationClick={openCitation}
citationStatus={
msg.citationStatus
}
onCitationClick={(citation) =>
openCitation(citation)
}
onOpenCitationSource={(
citation,
) =>
openCitation(citation, {
showQuotes: false,
})
}
onCaseClick={(citation) =>
openCase(citation)
}
minHeight={
i === lastAssistantIndex
? minHeight
@ -561,7 +708,10 @@ export function ChatView({
>
<button
onClick={scrollToBottom}
className="p-2 rounded-full bg-white/70 backdrop-blur-xs shadow-lg cursor-pointer border border-gray-300"
className={cn(
"rounded-full p-2 cursor-pointer transition-all",
"bg-white/30 shadow-[0_5px_16px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-8px_18px_rgba(255,255,255,0.26)] backdrop-blur-xl hover:bg-white/45 hover:shadow-[0_7px_20px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-8px_18px_rgba(255,255,255,0.32)]",
)}
>
<ArrowDown className="h-6 w-6 text-gray-500" />
</button>
@ -573,8 +723,19 @@ export function ChatView({
ref={chatInputRef}
className="absolute bottom-0 left-0 right-0 w-full z-30"
>
<div className="w-full max-w-4xl mx-auto px-4 md:px-6">
<div className="w-full rounded-t-[20px] bg-white">
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 z-0",
"right-4 h-28 bg-gradient-to-t from-white/50 via-white/25 to-transparent backdrop-blur-[1px]",
)}
/>
<div className="relative z-20 w-full max-w-4xl mx-auto px-4 md:px-6">
<div
className={cn(
"w-full rounded-t-[20px]",
"bg-transparent",
)}
>
<ChatInput
onSubmit={handleChat}
onCancel={cancel}
@ -600,7 +761,7 @@ export function ChatView({
{panelMounted && (
<div
className={`fixed md:relative inset-0 md:inset-auto md:h-full md:flex-shrink-0 z-40 md:z-auto transition-transform duration-300 ease-in-out ${panelVisible ? "translate-x-0" : "translate-x-full"}`}
className={`fixed inset-0 z-40 flex justify-center p-3 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] md:relative md:inset-auto md:z-auto md:block md:h-full md:min-w-0 md:flex-shrink-0 md:p-0 ${panelVisible ? "translate-x-0" : "translate-x-full"}`}
>
<AssistantSidePanel
tabs={tabs}

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { supabase } from "@/lib/supabase";
import type { MikeEditAnnotation } from "../shared/types";
import type { EditAnnotation } from "../shared/types";
function normalizeText(s: string) {
return s.replace(/\s+/g, " ").trim();
@ -19,13 +19,6 @@ function findMatch(
const byId = container.querySelector(
`${tag}[data-w-id="${opts.w_id}"]`,
) as HTMLElement | null;
console.log("[EditCard] findMatch by w_id", {
tag,
w_id: opts.w_id,
found: !!byId,
totalTagged: container.querySelectorAll(`${tag}[data-w-id]`).length,
totalAny: container.querySelectorAll(tag).length,
});
if (byId) return byId;
}
const text = opts.text ?? "";
@ -42,12 +35,6 @@ function findMatch(
normalizeText(el.textContent ?? "").includes(target),
) ??
null;
console.log("[EditCard] findMatch by text", {
tag,
target,
found: !!byText,
candidateCount: candidates.length,
});
return byText;
}
@ -63,7 +50,7 @@ function findMatch(
* so if the backend call later fails we can restore the original look.
*/
export function applyOptimisticResolution(
annotation: MikeEditAnnotation,
annotation: EditAnnotation,
verb: "accept" | "reject",
): () => void {
const reverts: (() => void)[] = [];
@ -117,13 +104,6 @@ export function applyOptimisticResolution(
const scrolls = document.querySelectorAll(
`[data-document-id="${CSS.escape(annotation.document_id)}"]`,
);
console.log("[EditCard] optimistic scrolls found:", scrolls.length, {
document_id: annotation.document_id,
ins_w_id: annotation.ins_w_id,
del_w_id: annotation.del_w_id,
inserted_text: annotation.inserted_text?.slice(0, 40),
deleted_text: annotation.deleted_text?.slice(0, 40),
});
scrolls.forEach((scroll) => {
const container = scroll.querySelector(".docx-view-container");
if (!container) return;
@ -150,7 +130,7 @@ export function applyOptimisticResolution(
}
interface Props {
annotation: MikeEditAnnotation;
annotation: EditAnnotation;
/**
* External override for this edit's status. When set, takes
* precedence over the annotation's DB status and the card's own
@ -164,7 +144,7 @@ interface Props {
* Accept/Reject buttons disable so the user can't race resolutions.
*/
isReloading?: boolean;
onViewClick?: (ann: MikeEditAnnotation) => void;
onViewClick?: (ann: EditAnnotation) => void;
/**
* Fires immediately when the user clicks Accept or Reject, before the
* backend round-trip. Parents use this to show an in-progress spinner

View file

@ -6,14 +6,14 @@ import { useUserProfile } from "@/contexts/UserProfileContext";
import { MikeIcon } from "@/components/chat/mike-icon";
import { ChatInput } from "./ChatInput";
import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal";
import type { MikeMessage } from "../shared/types";
import type { Message } from "../shared/types";
interface InitialViewProps {
onSubmit: (message: MikeMessage) => void;
onSubmit: (message: Message) => void;
}
const ICON_SIZE = 35;
const GAP = 16; // gap-4 = 1rem = 16px
const ICON_SIZE = 30;
const GAP = 12; // gap-4 = 1rem = 16px
export function InitialView({ onSubmit }: InitialViewProps) {
const { user } = useAuth();
@ -46,7 +46,7 @@ export function InitialView({ onSubmit }: InitialViewProps) {
<div className="flex-col items-center w-full max-w-4xl relative px-0 xl:px-8">
<div className="mb-10 relative flex items-center justify-center">
<div
className="absolute h-[35px]"
className="absolute h-[30px] w-[30px] top-[-14px]"
style={{
left: "50%",
transform: loaded

View file

@ -25,7 +25,18 @@ export const MODELS: ModelOption[] = [
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" },
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" },
{ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" },
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini", group: "OpenAI" },
{ id: "gpt-5.4", label: "GPT-5.4", group: "OpenAI" },
];
export const SETTINGS_MODELS: ModelOption[] = [
...MODELS,
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5", group: "Anthropic" },
{
id: "gemini-3.1-flash-lite-preview",
label: "Gemini 3.1 Flash Lite",
group: "Google",
},
{ id: "gpt-5.4-lite", label: "GPT-5.4 Lite", group: "OpenAI" },
];
export const DEFAULT_MODEL_ID = "gemini-3-flash-preview";
@ -69,7 +80,7 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) {
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 z-50" side="top" align="start">
<DropdownMenuContent className="w-56 z-50" side="top" align="end">
{GROUP_ORDER.map((group, gi) => {
const items = MODELS.filter((m) => m.group === group);
if (items.length === 0) return null;

View file

@ -1,12 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useDirectoryData } from "../shared/useDirectoryData";
import { ProjectPicker } from "../shared/ProjectPicker";
import { Modal } from "../shared/Modal";
interface Props {
open: boolean;
@ -40,53 +39,23 @@ export function SelectAssistantProjectModal({ open, onClose }: 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-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">
<span>Assistant</span>
<span></span>
<span>Start Chat in a Project</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>
<ProjectPicker
projects={projects}
loading={loading}
selectedId={selectedId}
onSelect={setSelectedId}
/>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-end 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={handleContinue}
disabled={!selectedId || creating}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
>
{creating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
"Continue"
)}
</button>
</div>
</div>
</div>,
document.body,
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={["Assistant", "Start Chat in a Project"]}
primaryAction={{
label: creating ? "Creating…" : "Continue",
onClick: handleContinue,
disabled: !selectedId || creating,
}}
>
<ProjectPicker
projects={projects}
loading={loading}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</Modal>
);
}

View file

@ -0,0 +1,769 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
AlertCircle,
Check,
Download,
Loader2,
Pencil,
Trash2,
Upload,
X,
} from "lucide-react";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
import { DocView } from "@/app/components/shared/DocView";
import { DocFileIcon } from "@/app/components/shared/FileDirectory";
import { WarningPopup } from "@/app/components/shared/WarningPopup";
import type { Document } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
import { formatBytes, formatDate } from "./ProjectPageParts";
const MIN_DOC_COLUMN_WIDTH = 420;
const DEFAULT_DOC_COLUMN_WIDTH = 620;
const MIN_DATA_COLUMN_WIDTH = 280;
const DEFAULT_DATA_COLUMN_WIDTH = 340;
const RESIZER_WIDTH = 6;
const MAX_PANEL_WIDTH = 1180;
interface DocumentSidePanelProps {
doc: Document | null;
versionId?: string | null;
currentVersionId?: string | null;
versions: DocumentVersion[];
versionsLoading: boolean;
onClose: () => void;
onLoadVersions: (docId: string) => Promise<void> | void;
onSelectVersion: (versionId: string, label: string) => void;
onDownloadDocument: (docId: string) => Promise<void> | void;
onDownloadVersion: (
docId: string,
versionId: string,
filename: string,
) => Promise<void> | void;
onRenameVersion: (
docId: string,
versionId: string,
filename: string,
) => Promise<void> | void;
onDeleteVersion: (
docId: string,
versionId: string,
) => Promise<void> | void;
onUploadNewVersion: (
doc: Document,
file: File,
filename: string,
) => Promise<void>;
onDelete: (doc: Document) => Promise<void> | void;
}
export function DocumentSidePanel({
doc,
versionId,
currentVersionId,
versions,
versionsLoading,
onClose,
onLoadVersions,
onSelectVersion,
onDownloadDocument,
onDownloadVersion,
onRenameVersion,
onDeleteVersion,
onUploadNewVersion,
onDelete,
}: DocumentSidePanelProps) {
const [mounted, setMounted] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameError, setNameError] = useState<string | null>(null);
const [extensionWarningOpen, setExtensionWarningOpen] = useState(false);
const [deletingVersion, setDeletingVersion] = useState(false);
const [deletingDocument, setDeletingDocument] = useState(false);
const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] =
useState(false);
const [deleteDocumentStatus, setDeleteDocumentStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
const [dataColumnWidth, setDataColumnWidth] = useState(
DEFAULT_DATA_COLUMN_WIDTH,
);
const [panelWidth, setPanelWidth] = useState(
DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH,
);
const panelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragStartX = useRef(0);
const dragStartDataWidth = useRef(DEFAULT_DATA_COLUMN_WIDTH);
const dragStartPanelWidth = useRef(
DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH,
);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!mounted) return;
function handleWindowResize() {
setPanelWidth((width) => clampPanelWidth(width, dataColumnWidth));
}
handleWindowResize();
window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener("resize", handleWindowResize);
}, [dataColumnWidth, mounted]);
useEffect(() => {
if (!doc) return;
setUploadError(null);
void onLoadVersions(doc.id);
}, [doc?.id]);
useEffect(() => {
setEditingName(false);
setNameDraft("");
setNameError(null);
setExtensionWarningOpen(false);
}, [doc?.id, versionId, currentVersionId]);
if (!mounted || !doc) return null;
const activeDoc = doc;
const documentId = activeDoc.id;
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const orderedVersions = [...versions].reverse();
const selectedVersion =
versions.find((version) => version.id === versionId) ??
versions.find((version) => version.id === currentVersionId) ??
orderedVersions[0] ??
null;
const selectedVersionId = selectedVersion?.id ?? versionId ?? null;
const selectedFilename =
selectedVersion?.filename?.trim() || doc.filename;
const selectedFileType =
selectedVersion != null
? fileTypeForVersion(selectedVersion, doc.file_type)
: doc.file_type;
const selectedSizeBytes =
selectedVersion?.size_bytes === undefined
? doc.size_bytes
: selectedVersion.size_bytes;
const selectedPageCount =
selectedVersion?.page_count === undefined
? doc.page_count
: selectedVersion.page_count;
const selectedVersionNumber =
selectedVersion?.version_number ?? doc.active_version_number ?? null;
const selectedUploadedAt = selectedVersion?.created_at ?? doc.created_at;
const selectedExtension = filenameExtension(selectedFilename);
async function handleSaveName() {
if (!selectedVersionId) return;
const trimmed = nameDraft.trim();
if (!trimmed) {
setNameError("Name is required.");
return;
}
if (hasExtensionChange(selectedFilename, trimmed)) {
setExtensionWarningOpen(true);
return;
}
if (trimmed === selectedFilename) {
setEditingName(false);
setNameError(null);
return;
}
setSavingName(true);
setNameError(null);
try {
await onRenameVersion(documentId, selectedVersionId, trimmed);
setEditingName(false);
} catch (err) {
console.error("rename version failed", err);
setNameError("Could not save name.");
} finally {
setSavingName(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;
if (fileInputRef.current) fileInputRef.current.value = "";
if (!file || !doc) return;
setUploadError(null);
setUploading(true);
try {
await onUploadNewVersion(doc, file, file.name);
} catch (err) {
console.error("upload new version failed", err);
setUploadError("Could not upload the new version.");
} finally {
setUploading(false);
}
}
async function handleDeleteSelectedVersion() {
if (!selectedVersionId) return;
setDeletingVersion(true);
try {
await onDeleteVersion(documentId, selectedVersionId);
} catch (err) {
console.error("delete version failed", err);
} finally {
setDeletingVersion(false);
}
}
async function handleDeleteDocument() {
if (deleteDocumentStatus === "deleting") return;
setDeleteDocumentStatus("deleting");
setDeletingDocument(true);
try {
await onDelete(activeDoc);
setDeleteDocumentStatus("deleted");
window.setTimeout(() => {
setConfirmDeleteDocumentOpen(false);
setDeleteDocumentStatus("idle");
onClose();
}, 650);
} catch (err) {
console.error("delete document failed", err);
setDeleteDocumentStatus("idle");
} finally {
setDeletingDocument(false);
}
}
function requestDeleteDocument() {
if (versions.length > 1) {
setDeleteDocumentStatus("idle");
setConfirmDeleteDocumentOpen(true);
return;
}
void handleDeleteDocument();
}
function handleResizeMouseDown(e: React.MouseEvent<HTMLDivElement>) {
e.preventDefault();
dragStartX.current = e.clientX;
dragStartDataWidth.current = dataColumnWidth;
const handleMouseMove = (event: MouseEvent) => {
const panelWidth =
panelRef.current?.clientWidth ?? window.innerWidth;
const maxDataWidth = Math.max(
MIN_DATA_COLUMN_WIDTH,
panelWidth - MIN_DOC_COLUMN_WIDTH - RESIZER_WIDTH,
);
const nextWidth =
dragStartDataWidth.current + (dragStartX.current - event.clientX);
setDataColumnWidth(
Math.min(
maxDataWidth,
Math.max(MIN_DATA_COLUMN_WIDTH, nextWidth),
),
);
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
function handlePanelResizeMouseDown(e: React.MouseEvent<HTMLDivElement>) {
e.preventDefault();
dragStartX.current = e.clientX;
dragStartPanelWidth.current = panelWidth;
const handleMouseMove = (event: MouseEvent) => {
const nextWidth =
dragStartPanelWidth.current + (dragStartX.current - event.clientX);
setPanelWidth(clampPanelWidth(nextWidth, dataColumnWidth));
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return createPortal(
<div
ref={panelRef}
className={cn(
"fixed z-[190] flex flex-col",
"inset-y-3 right-3 rounded-2xl border border-white/70 bg-white/72 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
)}
style={{ width: panelWidth }}
>
<div
onMouseDown={handlePanelResizeMouseDown}
className="absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60"
title="Resize document view"
/>
<div
className={cn(
"flex h-11 shrink-0 items-center justify-between px-4",
"border-b border-white/60 bg-white/35",
)}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-700">
{selectedFilename}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-gray-500 transition-colors hover:text-gray-900"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div
className="grid min-h-0 flex-1"
style={{
gridTemplateColumns: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
}}
>
<section
className={cn(
"flex min-h-0 min-w-0 pb-3 pl-3",
"bg-white/20",
)}
>
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
"rounded-xl border border-white/60 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
)}
>
<DocView
key={selectedVersionId ?? "current"}
doc={{
document_id: doc.id,
version_id: selectedVersionId,
}}
/>
</div>
</section>
<div
onMouseDown={handleResizeMouseDown}
className={cn(
"relative cursor-col-resize transition-colors",
"bg-white/25 hover:bg-blue-400/60",
)}
title="Resize document panel"
/>
<aside
className={cn(
"flex min-h-0 flex-col",
"bg-white/25",
)}
>
<div
className={cn(
"shrink-0 px-4 pb-3 pt-0",
"border-b border-white/60",
)}
>
<div className="mb-4">
<div className="mb-3 text-xs font-medium text-gray-900">
Name
</div>
{editingName ? (
<div className="space-y-1.5">
<div className="flex min-h-6 items-center gap-2">
<input
value={nameDraft}
onChange={(e) => {
setNameDraft(e.target.value);
setNameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSaveName();
}
if (e.key === "Escape") {
setEditingName(false);
setNameError(null);
}
}}
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
autoFocus
/>
<button
type="button"
onClick={() => void handleSaveName()}
disabled={savingName}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40"
title="Save name"
>
{savingName ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
</button>
</div>
{nameError && (
<div className="text-xs text-red-600">
{nameError}
</div>
)}
</div>
) : (
<div className="flex min-h-6 items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800">
{selectedFilename}
</div>
{selectedVersionId && (
<button
type="button"
onClick={() => {
setNameDraft(selectedFilename);
setEditingName(true);
setNameError(null);
}}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900"
title="Edit name"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
</div>
<div className="mb-3 text-xs font-medium text-gray-900">
Document Data
</div>
<div className="space-y-1.5">
<DataRow label="Type" value={selectedFileType ?? "—"} />
<DataRow
label="Size"
value={
selectedSizeBytes != null
? formatBytes(selectedSizeBytes)
: "—"
}
/>
<DataRow
label="Version"
value={
selectedVersionNumber != null
? String(selectedVersionNumber)
: "—"
}
/>
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDate(selectedUploadedAt)
: "—"
}
/>
{selectedPageCount != null && (
<DataRow
label="Pages"
value={String(selectedPageCount)}
/>
)}
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={() =>
void handleDeleteSelectedVersion()
}
disabled={
!selectedVersionId ||
versions.length <= 1 ||
deletingVersion
}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40",
)}
>
{deletingVersion ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Delete version
</button>
<button
type="button"
onClick={() =>
selectedVersionId
? void onDownloadVersion(
doc.id,
selectedVersionId,
selectedFilename,
)
: void onDownloadDocument(doc.id)
}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:border-gray-400 hover:bg-white hover:text-gray-900"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
"border border-white/60 bg-white/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]",
)}
>
<div
className={cn(
"shrink-0 py-2 text-xs font-medium text-gray-900",
"border-b border-white/60",
)}
>
Versions
</div>
<div className="-mx-2 min-h-0 flex-1 overflow-y-auto px-2 py-2">
{versionsLoading && versions.length === 0 ? (
<div className="flex items-center gap-2 py-2 text-xs text-gray-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading versions
</div>
) : orderedVersions.length === 0 ? (
<div className="py-2 text-xs text-gray-400">
No version history.
</div>
) : (
<div className="space-y-1">
{orderedVersions.map((version) => {
const title =
versionTitleFor(version);
const filename =
versionFilenameFor(version);
const selected =
selectedVersionId === version.id;
const fileType =
fileTypeForVersion(
version,
doc.file_type,
);
return (
<button
key={version.id}
type="button"
onClick={() =>
onSelectVersion(
version.id,
filename,
)
}
className={cn(
"group -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors",
selected
? "bg-gray-100"
: "hover:bg-white/55",
)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<DocFileIcon
fileType={
fileType
}
/>
<div className="min-w-0 flex-1 truncate text-xs font-medium text-gray-800">
{title}
</div>
</div>
<div className="truncate pl-[22px] text-[11px] text-gray-400">
{filename}
</div>
<div className="truncate pl-[22px] text-[11px] text-gray-400">
{version.created_at
? new Date(
version.created_at,
).toLocaleString()
: "—"}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
{uploadError && (
<div className="mx-4 mb-2 flex items-center gap-2 rounded-lg 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>{uploadError}</span>
</div>
)}
<div
className={cn(
"flex shrink-0 items-center justify-between px-4 py-3",
"border-t border-white/60 bg-white/25",
)}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleUpload}
/>
<button
type="button"
onClick={requestDeleteDocument}
disabled={deletingDocument}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
>
{deletingDocument ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Delete
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-gray-800 transition-colors hover:border-gray-400 hover:bg-white/60 disabled:cursor-not-allowed disabled:opacity-40"
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
Upload new version
</button>
</div>
</aside>
</div>
<WarningPopup
open={extensionWarningOpen}
onClose={() => setExtensionWarningOpen(false)}
message={
selectedExtension
? `File extensions cannot be changed here. Keep ${selectedExtension} at the end of the name.`
: "File extensions cannot be changed here."
}
/>
<ConfirmPopup
open={confirmDeleteDocumentOpen}
title="Delete document?"
message={`${selectedFilename} has ${versions.length} versions. Deleting this document will delete all of its versions.`}
confirmLabel="Delete"
confirmStatus={
deleteDocumentStatus === "deleting"
? "loading"
: deleteDocumentStatus === "deleted"
? "complete"
: "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (deleteDocumentStatus === "deleting") return;
setConfirmDeleteDocumentOpen(false);
setDeleteDocumentStatus("idle");
}}
onConfirm={() => void handleDeleteDocument()}
/>
</div>,
document.body,
);
}
function DataRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-xs">
<span className="text-gray-400">{label}</span>
<span className="truncate text-gray-800">{value}</span>
</div>
);
}
function clampPanelWidth(width: number, dataColumnWidth: number) {
const minWidth = MIN_DOC_COLUMN_WIDTH + RESIZER_WIDTH + dataColumnWidth;
const maxWidth =
typeof window === "undefined"
? MAX_PANEL_WIDTH
: Math.min(MAX_PANEL_WIDTH, window.innerWidth - 16);
return Math.min(maxWidth, Math.max(minWidth, width));
}
function versionTitleFor(version: DocumentVersion) {
if (
typeof version.version_number === "number" &&
version.version_number >= 1
) {
return `Version ${version.version_number}`;
}
return "Version";
}
function versionFilenameFor(version: DocumentVersion) {
if (version.filename?.trim()) return version.filename.trim();
return version.source === "upload" ? "Original" : "—";
}
function fileTypeForVersion(
version: DocumentVersion,
fallback: string | null,
) {
const name = version.filename?.trim().toLowerCase() ?? "";
if (name.endsWith(".pdf")) return "pdf";
if (name.endsWith(".doc") || name.endsWith(".docx")) return "docx";
return fallback;
}
function filenameExtension(filename: string) {
const trimmed = filename.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null;
return trimmed.slice(dotIndex);
}
function hasExtensionChange(previous: string, next: string) {
const previousExtension = filenameExtension(previous);
if (previousExtension == null) return false;
return (
filenameExtension(next)?.toLowerCase() !==
previousExtension.toLowerCase()
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useRef, useState } from "react";
import { X, Users, Upload } from "lucide-react";
import { Users, Upload } from "lucide-react";
import {
addDocumentToProject,
createProject,
@ -10,13 +10,14 @@ import {
import { useDirectoryData } from "../shared/useDirectoryData";
import { FileDirectory } from "../shared/FileDirectory";
import { EmailPillInput } from "../shared/EmailPillInput";
import type { MikeProject } from "../shared/types";
import type { Project } from "../shared/types";
import { useAuth } from "@/contexts/AuthContext";
import { Modal } from "../shared/Modal";
interface Props {
open: boolean;
onClose: () => void;
onCreated: (project: MikeProject) => void;
onCreated: (project: Project) => void;
}
export function NewProjectModal({ open, onClose, onCreated }: Props) {
@ -31,6 +32,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { user } = useAuth();
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
const formId = "new-project-modal-form";
const { loading: dirLoading, standaloneDocuments, projects: dirProjects } = useDirectoryData(open);
@ -86,129 +88,93 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
}
return (
<div className="fixed inset-0 z-101 flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-2">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Projects</span>
<span></span>
<span>New project</span>
</div>
<Modal
open={open}
onClose={handleClose}
breadcrumbs={["Projects", "New project"]}
secondaryAction={{
label: `Upload files${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
icon: <Upload className="h-3.5 w-3.5" />,
onClick: () => fileInputRef.current?.click(),
}}
primaryAction={{
label: loading ? "Creating…" : "Create project",
type: "submit",
form: formId,
disabled: !name.trim() || loading,
}}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<form
id={formId}
onSubmit={handleSubmit}
className="flex flex-col flex-1 min-h-0"
>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent"
autoFocus
/>
<input
type="text"
value={cmNumber}
onChange={(e) => setCmNumber(e.target.value)}
placeholder="Add a CM number..."
className="mt-1.5 w-full text-sm text-gray-500 placeholder-gray-300 focus:outline-none bg-transparent"
/>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
type="button"
onClick={() => setShowMembers((v) => !v)}
className="flex items-center gap-1.5 rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<X className="h-4 w-4" />
<Users className="h-3 w-3 text-gray-400" />
Members{sharedEmails.length > 0 ? ` (${sharedEmails.length})` : ""}
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="px-6 pt-3 pb-5 flex-1 overflow-y-auto">
{/* Title */}
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent"
autoFocus
{showMembers && (
<div className="mt-3">
<EmailPillInput
emails={sharedEmails}
onChange={setSharedEmails}
validate={async (email) =>
ownEmail && email === ownEmail
? "You cannot share a project with yourself."
: null
}
placeholder="Add colleagues by email…"
/>
{/* CM Number */}
<input
type="text"
value={cmNumber}
onChange={(e) => setCmNumber(e.target.value)}
placeholder="Add a CM number..."
className="mt-1.5 w-full text-sm text-gray-500 placeholder-gray-300 focus:outline-none bg-transparent"
/>
{/* Attribute pills */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setShowMembers((v) => !v)}
className="flex items-center gap-1.5 rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Users className="h-3 w-3 text-gray-400" />
Members{sharedEmails.length > 0 ? ` (${sharedEmails.length})` : ""}
</button>
</div>
{/* Members panel */}
{showMembers && (
<div className="mt-3">
<EmailPillInput
emails={sharedEmails}
onChange={setSharedEmails}
validate={async (email) =>
ownEmail && email === ownEmail
? "You cannot share a project with yourself."
: null
}
placeholder="Add colleagues by email…"
/>
</div>
)}
{/* Documents */}
<div className="mt-4 space-y-2">
<p className="text-xs font-medium text-gray-700">Select documents</p>
<FileDirectory
standaloneDocs={standaloneDocuments}
directoryProjects={dirProjects}
loading={dirLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
emptyMessage="No existing documents"
/>
</div>
{error && (
<p className="mt-3 text-sm text-red-500">{error}</p>
)}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 shrink-0">
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 transition-colors"
>
<Upload className="h-3.5 w-3.5" />
Upload files{pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || loading}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
{loading ? "Creating…" : "Create project"}
</button>
</div>
</div>
</form>
</div>
</div>
<div className="mt-4 space-y-2">
<p className="text-xs font-medium text-gray-700">Select documents</p>
<FileDirectory
standaloneDocs={standaloneDocuments}
directoryProjects={dirProjects}
loading={dirLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
emptyMessage="No existing documents"
/>
</div>
{error && (
<p className="mt-3 text-sm text-red-500">{error}</p>
)}
</form>
</Modal>
);
}

View file

@ -3,8 +3,8 @@
import { type Dispatch, type SetStateAction } from "react";
import { MessageSquare } from "lucide-react";
import { RowActions } from "@/app/components/shared/RowActions";
import type { MikeChat } from "@/app/components/shared/types";
import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts";
import type { Chat } from "@/app/components/shared/types";
import { formatDate, NAME_COL_W } from "./ProjectPageParts";
export function ProjectAssistantTab({
chats,
@ -24,8 +24,8 @@ export function ProjectAssistantTab({
setRenamingChatId,
setRenameChatValue,
}: {
chats: MikeChat[];
filteredChats: MikeChat[];
chats: Chat[];
filteredChats: Chat[];
selectedChatIds: string[];
allChatsSelected: boolean;
someChatsSelected: boolean;
@ -34,19 +34,19 @@ export function ProjectAssistantTab({
currentUserId?: string | null;
onCreateChat: () => void;
onOpenChat: (chatId: string) => void;
onDeleteChat: (chat: MikeChat) => Promise<void> | void;
onDeleteChat: (chat: Chat) => Promise<void> | void;
onOwnerOnlyAction: (action: string) => void;
submitChatRename: (chatId: string) => Promise<void> | void;
setSelectedChatIds: Dispatch<SetStateAction<string[]>>;
setRenamingChatId: Dispatch<SetStateAction<string | null>>;
setRenameChatValue: Dispatch<SetStateAction<string>>;
}) {
const stickyCellBg = "bg-[#fcfcfd]";
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}
>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
<input
type="checkbox"
checked={allChatsSelected}
@ -59,11 +59,7 @@ export function ProjectAssistantTab({
}}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}
>
Chats
<span>Chats</span>
</div>
<div className="ml-auto w-32 shrink-0 text-left">Created</div>
<div className="w-8 shrink-0" />
@ -94,54 +90,48 @@ export function ProjectAssistantTab({
if (renamingChatId === chat.id) return;
onOpenChat(chat.id);
}}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${
selectedChatIds.includes(chat.id)
? "bg-gray-50"
: "bg-white"
} group-hover:bg-gray-50`}
onClick={(e) => e.stopPropagation()}
className={`sticky left-0 z-[60] ${NAME_COL_W} ${selectedChatIds.includes(chat.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}
>
<input
type="checkbox"
checked={selectedChatIds.includes(chat.id)}
onChange={() =>
setSelectedChatIds((prev) =>
prev.includes(chat.id)
? prev.filter((x) => x !== chat.id)
: [...prev, chat.id],
)
}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
>
{renamingChatId === chat.id ? (
<div className="flex min-w-0 items-center gap-4">
<input
autoFocus
value={renameChatValue}
onChange={(e) =>
setRenameChatValue(e.target.value)
type="checkbox"
checked={selectedChatIds.includes(chat.id)}
onChange={() =>
setSelectedChatIds((prev) =>
prev.includes(chat.id)
? prev.filter((x) => x !== chat.id)
: [...prev, chat.id],
)
}
onKeyDown={(e) => {
if (e.key === "Enter")
void submitChatRename(chat.id);
if (e.key === "Escape")
setRenamingChatId(null);
}}
onBlur={() => void submitChatRename(chat.id)}
onClick={(e) => e.stopPropagation()}
className="w-full text-sm text-gray-800 bg-transparent outline-none"
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
) : (
<span className="text-sm text-gray-800 truncate block">
{chat.title ?? "Untitled Chat"}
</span>
)}
{renamingChatId === chat.id ? (
<input
autoFocus
value={renameChatValue}
onChange={(e) =>
setRenameChatValue(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter")
void submitChatRename(chat.id);
if (e.key === "Escape")
setRenamingChatId(null);
}}
onBlur={() => void submitChatRename(chat.id)}
onClick={(e) => e.stopPropagation()}
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none"
/>
) : (
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{chat.title ?? "Untitled Chat"}
</span>
)}
</div>
</div>
<div className="ml-auto w-32 shrink-0 text-sm text-gray-500 truncate">
{formatDate(chat.created_at)}

View file

@ -11,15 +11,18 @@ import {
FolderPlus,
Trash2,
} from "lucide-react";
import type { MikeDocument, MikeFolder } from "@/app/components/shared/types";
import type {
Document,
Folder as ProjectFolder,
} from "@/app/components/shared/types";
import { VersionChip } from "@/app/components/shared/VersionChip";
interface Props {
projectName?: string | null;
documents: MikeDocument[];
folders?: MikeFolder[];
documents: Document[];
folders?: ProjectFolder[];
selectedDocId?: string | null;
onDocClick: (doc: MikeDocument) => void;
onDocClick: (doc: Document) => void;
onCreateFolder?: (parentFolderId: string | null, name: string) => Promise<void>;
onRenameFolder?: (folderId: string, name: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
@ -131,7 +134,7 @@ export function ProjectExplorer({
}
function wouldCreateCycle(movingId: string, targetId: string): boolean {
let cur: MikeFolder | undefined = folders.find((f) => f.id === targetId);
let cur: ProjectFolder | undefined = folders.find((f) => f.id === targetId);
while (cur) {
if (cur.id === movingId) return true;
if (!cur.parent_folder_id) break;
@ -299,8 +302,15 @@ export function ProjectExplorer({
style={{ paddingLeft: basePadding }}
>
<DocIcon fileType={doc.file_type} />
<span className="text-xs truncate">{doc.filename}</span>
<VersionChip n={doc.latest_version_number} />
<span className="text-xs truncate">
{doc.filename}
</span>
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
</li>
);
})}

File diff suppressed because it is too large Load diff

View file

@ -2,18 +2,20 @@
import { type CSSProperties, useState } from "react";
import {
Download,
CornerDownRight,
File,
FileText,
Loader2,
Pencil,
Plus,
MessageSquare,
Search,
Table2,
Users,
} from "lucide-react";
import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
import type { MikeProject } from "@/app/components/shared/types";
import type { MikeDocumentVersion } from "@/app/lib/mikeApi";
import type { Project } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { RowActions } from "@/app/components/shared/RowActions";
export type ProjectTab = "documents" | "assistant" | "reviews";
@ -25,32 +27,18 @@ export type ProjectContextMenu = {
showFolderActions: boolean;
};
export const CHECK_W = "w-8 shrink-0";
export const NAME_COL_W = "w-[300px] shrink-0";
export const NAME_COL_W = "w-[332px] shrink-0";
export const DOC_NAME_COL_W =
"w-[260px] sm:w-[300px] md:w-[360px] lg:w-[420px] xl:w-[500px] 2xl:w-[560px] shrink-0";
"w-[292px] sm:w-[332px] md:w-[392px] lg:w-[452px] xl:w-[532px] 2xl:w-[592px] shrink-0";
const TREE_CONTROL_WIDTH_PX = 32;
const TREE_NAME_PADDING_PX = 8;
function treeControlWidth(depth: number) {
return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1);
}
export function treeControlCellStyle(depth: number): CSSProperties | undefined {
if (depth <= 0) return undefined;
const width = treeControlWidth(depth);
return {
justifyContent: "flex-start",
minWidth: width,
paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX,
width,
};
}
const TREE_NAME_PADDING_PX = 16;
export function treeNameCellStyle(depth: number): CSSProperties | undefined {
if (depth <= 0) return undefined;
return { left: treeControlWidth(depth) };
return {
paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX,
};
}
export function formatBytes(bytes: number): string {
@ -78,17 +66,24 @@ export function DocIcon({ fileType }: { fileType: string | null }) {
export function DocVersionHistory({
docId,
filename,
fileType,
activeVersionNumber,
currentVersionId,
loading,
versions,
depth = 0,
onDownloadVersion,
onOpenVersion,
onRenameVersion,
onExtensionChangeBlocked,
}: {
docId: string;
filename: string;
fileType: string | null;
activeVersionNumber: number | null;
currentVersionId: string | null;
loading: boolean;
versions: MikeDocumentVersion[];
versions: DocumentVersion[];
depth?: number;
onDownloadVersion: (
docId: string,
@ -98,8 +93,9 @@ export function DocVersionHistory({
onOpenVersion?: (versionId: string, versionLabel: string) => void;
onRenameVersion?: (
versionId: string,
displayName: string | null,
filename: string | null,
) => Promise<void> | void;
onExtensionChangeBlocked?: (filename: string) => void;
}) {
const [editingVersionId, setEditingVersionId] = useState<string | null>(
null,
@ -108,40 +104,69 @@ export function DocVersionHistory({
const commit = async (versionId: string) => {
const trimmed = editingValue.trim();
const previousFilename = versions
.find((version) => version.id === versionId)
?.filename?.trim();
if (
previousFilename &&
(trimmed.length === 0 ||
hasFilenameExtensionChange(previousFilename, trimmed))
) {
onExtensionChangeBlocked?.(previousFilename);
return;
}
setEditingVersionId(null);
const next = trimmed.length > 0 ? trimmed : null;
await onRenameVersion?.(versionId, next);
};
if (loading && versions.length === 0) {
const skeletonCount = Math.max(0, (activeVersionNumber ?? 1) - 1);
return (
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-500 bg-gray-50/60">
<div
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`}
style={treeControlCellStyle(depth)}
/>
<div
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 p-2`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
<span>Loading versions</span>
<>
{Array.from({ length: skeletonCount }).map((_, index) => (
<div
key={`ver-skeleton-${docId}-${index}`}
className="flex h-10 items-center pr-8 bg-gray-100"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} bg-gray-100 py-2 pl-4 pr-2`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-200 animate-pulse" />
<div className="h-4 w-4 shrink-0 rounded bg-gray-200 animate-pulse" />
<div className="h-3 w-32 rounded bg-gray-200 animate-pulse" />
</div>
</div>
<div className="ml-auto w-20 shrink-0">
<div className="h-3 w-8 rounded bg-gray-200 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-10 rounded bg-gray-200 animate-pulse" />
</div>
<div className="w-20 shrink-0 pl-1">
<div className="h-3 w-5 rounded bg-gray-200 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-200 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-10 rounded bg-gray-200 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
</div>
</div>
))}
</>
);
}
if (versions.length === 0) {
return (
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/60">
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/80">
<div
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`}
style={treeControlCellStyle(depth)}
/>
<div
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 p-2`}
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} bg-gray-50/80 py-2 pl-4 pr-2`}
style={treeNameCellStyle(depth)}
>
<div>No version history.</div>
@ -150,7 +175,10 @@ export function DocVersionHistory({
);
}
const ordered = [...versions].reverse();
const olderVersions = versions.filter((v) => v.id !== currentVersionId);
if (olderVersions.length === 0) return null;
const ordered = [...olderVersions].reverse();
return (
<>
{ordered.map((v) => {
@ -161,7 +189,7 @@ export function DocVersionHistory({
: v.source === "upload"
? "Original"
: "—";
const displayLabel = v.display_name?.trim() || numberLabel;
const displayLabel = v.filename?.trim() || numberLabel;
const dt = new Date(v.created_at);
const dateLabel = Number.isNaN(dt.valueOf())
? ""
@ -173,7 +201,7 @@ export function DocVersionHistory({
minute: "2-digit",
});
const isEditing = editingVersionId === v.id;
const rowBg = "bg-gray-100";
return (
<div
key={`ver-${docId}-${v.id}`}
@ -181,20 +209,20 @@ export function DocVersionHistory({
if (isEditing) return;
onOpenVersion?.(v.id, displayLabel);
}}
className="group flex items-center h-9 pr-3 md:pr-10 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors"
className={`group flex h-10 cursor-pointer items-center pr-8 text-sm text-gray-500 transition-colors hover:bg-gray-200 ${rowBg}`}
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`}
style={treeControlCellStyle(depth)}
/>
<div
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 group-hover:bg-gray-100/80 p-2`}
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-200`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-2">
<span className="shrink-0 text-gray-400">
<div className="flex items-center gap-4">
<span className="flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<CornerDownRight
className="h-3.5 w-3.5 text-gray-400"
aria-hidden="true"
/>
</span>
<DocIcon fileType={fileType} />
{isEditing ? (
<input
autoFocus
@ -212,53 +240,48 @@ export function DocVersionHistory({
}
}}
onBlur={() => void commit(v.id)}
className="min-w-0 flex-1 max-w-[240px] border-b border-gray-300 bg-transparent text-xs text-gray-800 outline-none focus:border-gray-500"
className="min-w-0 flex-1 border-b border-gray-300 bg-transparent text-sm text-gray-800 outline-none focus:border-gray-500"
/>
) : (
<span className="font-medium text-gray-700 truncate">
<span className="truncate text-sm text-gray-700">
{displayLabel}
</span>
)}
{!isEditing && onRenameVersion && (
<button
onClick={(e) => {
e.stopPropagation();
setEditingVersionId(v.id);
setEditingValue(
v.display_name ?? "",
);
}}
title="Rename version"
className="shrink-0 rounded p-0.5 text-gray-400 opacity-0 group-hover:opacity-100 hover:text-gray-700 hover:bg-gray-200 transition"
>
<Pencil className="h-3 w-3" />
</button>
)}
<span className="text-gray-400 truncate">
{dateLabel}
</span>
<span className="text-gray-300 shrink-0">
·
</span>
<span className="text-gray-400 truncate">
{v.source}
</span>
</div>
</div>
<div className="ml-auto w-20 shrink-0" />
<div className="w-24 shrink-0" />
<div className="ml-auto w-20 shrink-0" />
<div className="w-8 shrink-0 flex justify-end">
<button
onClick={(e) => {
e.stopPropagation();
onDownloadVersion(docId, v.id, filename);
}}
title="Download this version"
className="flex items-center justify-center w-6 h-6 rounded text-gray-500 hover:text-gray-800 hover:bg-gray-100 transition-colors"
>
<Download className="h-3.5 w-3.5" />
</button>
<div className="ml-auto w-20 shrink-0 truncate text-xs uppercase text-gray-500">
{fileType ?? <span className="text-gray-300"></span>}
</div>
<div className="w-24 shrink-0 truncate text-sm text-gray-400">
</div>
<div className="w-20 shrink-0 truncate pl-1 text-sm text-gray-500">
{numberLabel}
</div>
<div className="w-32 shrink-0 truncate text-sm text-gray-500">
{dateLabel ? formatDate(v.created_at) : <span className="text-gray-300"></span>}
</div>
<div className="w-32 shrink-0 truncate text-sm text-gray-400">
</div>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={
onRenameVersion
? () => {
setEditingVersionId(v.id);
setEditingValue(v.filename ?? "");
}
: undefined
}
renameLabel="Rename version"
onDownload={() =>
onDownloadVersion(docId, v.id, filename)
}
/>
</div>
</div>
);
@ -269,20 +292,43 @@ export function DocVersionHistory({
export function ProjectPageSkeleton() {
return (
<div className="flex-1 overflow-y-auto bg-white">
<div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
<span className="text-gray-400">Projects</span>
<span className="text-gray-300"></span>
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
</div>
<div className="flex items-center gap-4">
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-11 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-28 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="flex-1 overflow-y-auto">
<PageHeader
align="start"
actionGap="lg"
breadcrumbs={[
{ label: "Projects" },
{ loading: true, skeletonClassName: "w-40" },
]}
actionGroups={[
[
{
disabled: true,
iconOnly: true,
title: "Search",
icon: <Search className="h-4 w-4" />,
},
{
disabled: true,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
},
],
[
{
disabled: true,
icon: <MessageSquare className="h-4 w-4" />,
label: <span className="hidden sm:inline">New Chat</span>,
},
{
disabled: true,
icon: <Table2 className="h-4 w-4" />,
label: <span className="hidden sm:inline">New Review</span>,
},
],
]}
/>
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-5">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse" />
@ -293,8 +339,8 @@ export function ProjectPageSkeleton() {
</div>
</div>
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200">
<div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4">
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
@ -310,8 +356,8 @@ export function ProjectPageSkeleton() {
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
>
<div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4">
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-56 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-20 shrink-0">
@ -335,21 +381,19 @@ export function ProjectPageHeader({
creatingReview,
docsCount,
onBackToProjects,
onOpenDocuments,
onTitleCommit,
onSearchChange,
onOpenPeople,
onNewChat,
onNewReview,
}: {
project: MikeProject;
project: Project;
tab: ProjectTab;
search: string;
creatingChat: boolean;
creatingReview: boolean;
docsCount: number;
onBackToProjects: () => void;
onOpenDocuments: () => void;
onTitleCommit: (newName: string) => void | Promise<void>;
onSearchChange: (search: string) => void;
onOpenPeople: () => void;
@ -357,109 +401,88 @@ export function ProjectPageHeader({
onNewReview: () => void;
}) {
return (
<div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
<div>
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
<button
onClick={onBackToProjects}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
Projects
</button>
<span className="text-gray-300"></span>
{tab !== "documents" ? (
<button
onClick={onOpenDocuments}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
{project.name}
{project.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null}
</button>
) : (
<PageHeader
breadcrumbs={[
{
label: "Projects",
onClick: onBackToProjects,
title: "Back to Projects",
},
{
label: (
<RenameableTitle
value={project.name}
onCommit={onTitleCommit}
suffix={
project.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null
}
/>
)}
{tab !== "documents" && (
<>
<span className="text-gray-300"></span>
<span className="text-gray-900">
{tab === "assistant"
? "Assistant"
: "Tabular Reviews"}
),
suffix: project.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null,
},
]}
align="start"
actionGap="lg"
actionGroups={[
[
{
type: "search",
value: search,
onChange: onSearchChange,
placeholder: "Search…",
},
{
onClick: onOpenPeople,
iconOnly: true,
title: "People with access",
icon: <Users className="h-4 w-4" />,
},
],
[
{
onClick: onNewChat,
disabled: creatingChat,
icon: creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
),
label: <span className="hidden sm:inline">New Chat</span>,
},
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
icon: creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Table2 className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
New Review
</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-4">
<HeaderSearchBtn
value={search}
onChange={onSearchChange}
placeholder="Search…"
/>
<button
onClick={onOpenPeople}
className="flex h-8 w-8 items-center justify-center text-sm text-gray-500 transition-colors hover:text-gray-900 cursor-pointer"
title="People with access"
aria-label="People with access"
>
<Users className="h-4 w-4" />
</button>
<div className="relative group">
<button
onClick={() => !creatingChat && onNewChat()}
className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
!creatingChat
? "text-gray-500 hover:text-gray-900 cursor-pointer"
: "text-gray-300 cursor-default"
}`}
>
{creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
Chat
</button>
</div>
<div className="relative group">
<button
onClick={() =>
docsCount > 0 && !creatingReview && onNewReview()
}
className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
docsCount > 0
? "text-gray-500 hover:text-gray-900 cursor-pointer"
: "text-gray-300 cursor-default"
}`}
>
{creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
Tabular Review
</button>
{docsCount === 0 && (
<div className="pointer-events-none absolute right-0 top-full mt-1.5 z-10 hidden group-hover:flex items-center whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg">
Upload a document first
</div>
)}
</div>
</div>
</div>
),
tooltip: docsCount === 0 ? "Upload a document first" : null,
},
],
]}
/>
);
}
function filenameExtension(filename: string) {
const trimmed = filename.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null;
return trimmed.slice(dotIndex);
}
function hasFilenameExtensionChange(previous: string, next: string) {
const previousExtension = filenameExtension(previous);
if (previousExtension == null) return false;
return (
filenameExtension(next)?.toLowerCase() !==
previousExtension.toLowerCase()
);
}

View file

@ -3,8 +3,8 @@
import { type Dispatch, type SetStateAction } from "react";
import { Table2 } from "lucide-react";
import { RowActions } from "@/app/components/shared/RowActions";
import type { MikeDocument, TabularReview } from "@/app/components/shared/types";
import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts";
import type { Document, TabularReview } from "@/app/components/shared/types";
import { formatDate, NAME_COL_W } from "./ProjectPageParts";
export function ProjectReviewsTab({
docs,
@ -26,7 +26,7 @@ export function ProjectReviewsTab({
setRenamingReviewId,
setRenameReviewValue,
}: {
docs: MikeDocument[];
docs: Document[];
reviews: TabularReview[];
filteredReviews: TabularReview[];
selectedReviewIds: string[];
@ -45,12 +45,12 @@ export function ProjectReviewsTab({
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
setRenameReviewValue: Dispatch<SetStateAction<string>>;
}) {
const stickyCellBg = "bg-[#fcfcfd]";
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}
>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
<input
type="checkbox"
checked={allReviewsSelected}
@ -66,11 +66,7 @@ export function ProjectReviewsTab({
}}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}
>
Name
<span>Name</span>
</div>
<div className="ml-auto w-24 shrink-0 text-left">Columns</div>
<div className="w-24 shrink-0 text-left">Documents</div>
@ -103,58 +99,52 @@ export function ProjectReviewsTab({
if (renamingReviewId === review.id) return;
onOpenReview(review.id);
}}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${
selectedReviewIds.includes(review.id)
? "bg-gray-50"
: "bg-white"
} group-hover:bg-gray-50`}
onClick={(e) => e.stopPropagation()}
className={`sticky left-0 z-[60] ${NAME_COL_W} ${selectedReviewIds.includes(review.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}
>
<input
type="checkbox"
checked={selectedReviewIds.includes(review.id)}
onChange={() =>
setSelectedReviewIds((prev) =>
prev.includes(review.id)
? prev.filter(
(x) => x !== review.id,
)
: [...prev, review.id],
)
}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
>
{renamingReviewId === review.id ? (
<div className="flex min-w-0 items-center gap-4">
<input
autoFocus
value={renameReviewValue}
onChange={(e) =>
setRenameReviewValue(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter")
void submitReviewRename(review.id);
if (e.key === "Escape")
setRenamingReviewId(null);
}}
onBlur={() =>
void submitReviewRename(review.id)
type="checkbox"
checked={selectedReviewIds.includes(review.id)}
onChange={() =>
setSelectedReviewIds((prev) =>
prev.includes(review.id)
? prev.filter(
(x) => x !== review.id,
)
: [...prev, review.id],
)
}
onClick={(e) => e.stopPropagation()}
className="w-full text-sm text-gray-800 bg-transparent outline-none"
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
) : (
<span className="text-sm text-gray-800 truncate block">
{review.title ?? "Untitled Review"}
</span>
)}
{renamingReviewId === review.id ? (
<input
autoFocus
value={renameReviewValue}
onChange={(e) =>
setRenameReviewValue(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter")
void submitReviewRename(review.id);
if (e.key === "Escape")
setRenamingReviewId(null);
}}
onBlur={() =>
void submitReviewRename(review.id)
}
onClick={(e) => e.stopPropagation()}
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none"
/>
) : (
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{review.title ?? "Untitled Review"}
</span>
)}
</div>
</div>
<div className="ml-auto w-24 shrink-0 text-sm text-gray-500 truncate">
{review.columns_config?.length ?? 0}

View file

@ -2,15 +2,15 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, FolderOpen, ChevronDown } from "lucide-react";
import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn";
import { FolderOpen, ChevronDown } from "lucide-react";
import { listProjects, updateProject, deleteProject } from "@/app/lib/mikeApi";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
import type { MikeProject } from "@/app/components/shared/types";
import type { Project } from "@/app/components/shared/types";
import { NewProjectModal } from "./NewProjectModal";
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
import { RowActions } from "@/app/components/shared/RowActions";
import { PageHeader } from "@/app/components/shared/PageHeader";
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
@ -22,11 +22,10 @@ function formatDate(iso: string) {
type Tab = "all" | "mine" | "shared-with-me";
const CHECK_W = "w-8 shrink-0";
const NAME_COL_W = "w-[300px] shrink-0";
const NAME_COL_W = "w-[332px] shrink-0";
export function ProjectsOverview() {
const [projects, setProjects] = useState<MikeProject[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
@ -42,6 +41,7 @@ export function ProjectsOverview() {
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { user, isAuthenticated, authLoading } = useAuth();
const stickyCellBg = "bg-[#fcfcfd]";
useEffect(() => {
if (authLoading) {
@ -203,26 +203,27 @@ export function ProjectsOverview() {
);
return (
<div className="flex-1 overflow-y-auto bg-white">
<div className="flex-1 overflow-y-auto">
{/* Page header */}
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10">
<PageHeader
actions={[
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search projects…",
},
{
type: "new",
onClick: () => setModalOpen(true),
title: "New project",
},
]}
>
<h1 className="text-2xl font-medium font-serif text-gray-900">
Projects
</h1>
<div className="flex items-center gap-2">
<HeaderSearchBtn
value={search}
onChange={setSearch}
placeholder="Search projects…"
/>
<button
onClick={() => setModalOpen(true)}
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
</PageHeader>
<ToolbarTabs
tabs={tabs}
@ -236,8 +237,10 @@ export function ProjectsOverview() {
<div className="min-w-max">
{/* Column headers */}
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
{!loading && (
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
{loading ? (
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
) : (
<input
type="checkbox"
checked={allSelected}
@ -248,9 +251,7 @@ export function ProjectsOverview() {
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
)}
</div>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}>
Name
<span>Name</span>
</div>
<div className="ml-auto w-32 shrink-0 text-left">CM</div>
<div className="w-24 shrink-0 text-left">Files</div>
@ -269,8 +270,8 @@ export function ProjectsOverview() {
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
>
<div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4">
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
@ -333,7 +334,7 @@ export function ProjectsOverview() {
{filtered.map((project) => {
const rowBg = selectedIds.includes(project.id)
? "bg-gray-50"
: "bg-white";
: stickyCellBg;
return (
<div
key={project.id}
@ -341,50 +342,47 @@ export function ProjectsOverview() {
if (renamingId === project.id) return;
router.push(`/projects/${project.id}`);
}}
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selectedIds.includes(
project.id,
)}
onChange={() => toggleOne(project.id)}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
{/* Project Name */}
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
{renamingId === project.id ? (
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}>
<div className="flex min-w-0 items-center gap-4">
<input
autoFocus
value={renameValue}
onChange={(e) =>
setRenameValue(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter")
handleRenameSubmit(
project.id,
);
if (e.key === "Escape")
setRenamingId(null);
}}
onBlur={() =>
handleRenameSubmit(project.id)
}
type="checkbox"
checked={selectedIds.includes(
project.id,
)}
onChange={() => toggleOne(project.id)}
onClick={(e) => e.stopPropagation()}
className="w-full text-sm text-gray-800 bg-transparent outline-none"
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
) : (
<span className="text-sm text-gray-800 truncate block">
{project.name}
</span>
)}
{renamingId === project.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) =>
setRenameValue(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter")
handleRenameSubmit(
project.id,
);
if (e.key === "Escape")
setRenamingId(null);
}}
onBlur={() =>
handleRenameSubmit(project.id)
}
onClick={(e) => e.stopPropagation()}
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none"
/>
) : (
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{project.name}
</span>
)}
</div>
</div>
<div

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;

View file

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

View file

@ -4,13 +4,13 @@ import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
X,
Clock,
MessageSquarePlus,
Search,
Square,
ArrowRight,
ChevronDown,
ChevronLeft,
Trash2,
} from "lucide-react";
import { MikeIcon } from "@/components/chat/mike-icon";
@ -23,11 +23,7 @@ import {
type TRChat,
type TRCitationAnnotation,
} from "@/app/lib/mikeApi";
import type {
AssistantEvent,
ColumnConfig,
MikeDocument,
} from "../shared/types";
import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
import { ModelToggle } from "../assistant/ModelToggle";
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
@ -38,6 +34,7 @@ import {
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
@ -51,12 +48,64 @@ interface TRMessage {
isStreaming?: boolean;
}
function parseCourtlistenerEventCases(value: unknown) {
if (!Array.isArray(value)) return undefined;
return value
.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const row = item as Record<string, unknown>;
return {
cluster_id:
typeof row.cluster_id === "number" ? row.cluster_id : 0,
case_name:
typeof row.case_name === "string" ? row.case_name : null,
citation:
typeof row.citation === "string" ? row.citation : null,
dateFiled:
typeof row.dateFiled === "string" ? row.dateFiled : null,
url: typeof row.url === "string" ? row.url : null,
};
})
.filter(
(item): item is NonNullable<typeof item> =>
!!item && item.cluster_id > 0,
);
}
function parseCourtlistenerCaseSearches(value: unknown) {
if (!Array.isArray(value)) return undefined;
return value
.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const row = item as Record<string, unknown>;
return {
cluster_id:
typeof row.cluster_id === "number" ? row.cluster_id : null,
query: typeof row.query === "string" ? row.query : "",
total_matches:
typeof row.total_matches === "number"
? row.total_matches
: 0,
case_name:
typeof row.case_name === "string" ? row.case_name : null,
citation:
typeof row.citation === "string" ? row.citation : null,
error: typeof row.error === "string" ? row.error : undefined,
};
})
.filter((item): item is NonNullable<typeof item> => !!item);
}
interface Props {
reviewId: string;
reviewTitle?: string | null;
projectName?: string | null;
columns: ColumnConfig[];
documents: MikeDocument[];
documents: Document[];
onCitationClick: (colIdx: number, rowIdx: number) => void;
onClose: () => void;
initialChatId?: string | null;
@ -73,6 +122,8 @@ const THINKING_PHRASES = [
"Analyzing...",
"Reasoning...",
];
const REASONING_COLLAPSED_MAX_LINES = 6;
const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9;
function ReasoningBlock({
text,
@ -82,7 +133,11 @@ function ReasoningBlock({
isStreaming: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const [userToggled, setUserToggled] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const [hasMeasured, setHasMeasured] = useState(false);
const [phraseIdx, setPhraseIdx] = useState(0);
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isStreaming) return;
@ -93,10 +148,28 @@ function ReasoningBlock({
return () => clearInterval(interval);
}, [isStreaming]);
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24;
const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES;
const nextOverflowing = el.scrollHeight > maxHeight + 2;
setIsOverflowing(nextOverflowing);
setHasMeasured(true);
if (nextOverflowing && !userToggled) setIsOpen(false);
}, [text, userToggled]);
const showContent = isOpen || isStreaming || isOverflowing || !hasMeasured;
const isCollapsed = isOverflowing && !isOpen;
return (
<div className="ml-1">
<button
onClick={() => !isStreaming && setIsOpen((v) => !v)}
onClick={() => {
if (isStreaming) return;
setUserToggled(true);
setIsOpen((v) => !v);
}}
className="flex items-center text-sm text-gray-400 hover:text-gray-500 transition-colors"
>
{isStreaming ? (
@ -116,11 +189,56 @@ function ReasoningBlock({
/>
)}
</button>
{(isOpen || isStreaming) && (
<div className="mt-1.5 ml-[14px] text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text}
</ReactMarkdown>
{showContent && (
<div className="mt-1.5 ml-[14px]">
<div
className={`relative ${isCollapsed ? "overflow-hidden" : ""}`}
style={
isCollapsed
? {
maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`,
}
: undefined
}
>
<div
ref={contentRef}
className="text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text}
</ReactMarkdown>
</div>
{isCollapsed && (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" />
<button
type="button"
onClick={() => {
setUserToggled(true);
setIsOpen(true);
}}
className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label="Expand thought process"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
{isOverflowing && isOpen && (
<button
type="button"
onClick={() => {
setUserToggled(true);
setIsOpen(false);
}}
className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600"
aria-label="Minimise thought process"
>
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
)}
</div>
)}
</div>
@ -507,9 +625,17 @@ function TRChatInput({
return (
<div
ref={rootRef}
className="absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white"
className={cn(
"absolute bottom-0 left-0 right-0 px-4 pb-3",
"bg-transparent",
)}
>
<div className="border border-gray-300 rounded-xl bg-white pt-2 pb-1.5 flex flex-col gap-1">
<div
className={cn(
"pt-2 pb-1.5 flex flex-col gap-1",
"rounded-[18px] border border-white/65 bg-white/60 shadow-[0_6px_18px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl",
)}
>
<textarea
ref={textareaRef}
rows={1}
@ -537,7 +663,10 @@ function TRChatInput({
type="button"
onClick={handleAction}
disabled={!isLoading && !value.trim()}
className="relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-7 w-7 shrink-0 flex items-center justify-center disabled:cursor-default disabled:from-neutral-600 disabled:to-black border border-white/30 active:enabled:scale-95 transition-all duration-150"
className={cn(
"relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-7 w-7 shrink-0 flex items-center justify-center disabled:cursor-default disabled:from-neutral-600 disabled:to-black border border-white/30 active:enabled:scale-95 transition-all duration-150",
"shadow-[0_5px_14px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.24)]",
)}
>
{isLoading ? (
<Square
@ -930,7 +1059,7 @@ export function TRChatPanel({
.map((_, i) => i)
.reverse()
.find((i) => predicate(events[i]));
if (idx === undefined) return;
if (idx === undefined) return false;
const newEvents = [...events];
newEvents[idx] = updater(events[idx]);
eventsRef.current = newEvents;
@ -943,6 +1072,7 @@ export function TRChatPanel({
}
return updated;
});
return true;
}
// ---- chat actions ----
@ -1225,6 +1355,295 @@ export function TRChatPanel({
continue;
}
if (
data.type === "courtlistener_search_case_law_start"
) {
pushEvent({
type: "courtlistener_search_case_law",
query: (data.query as string) ?? "",
isStreaming: true,
});
continue;
}
if (data.type === "courtlistener_search_case_law") {
updateMatchingEvent(
(e) =>
e.type ===
"courtlistener_search_case_law" &&
e.query === (data.query as string) &&
!!e.isStreaming,
() => ({
type: "courtlistener_search_case_law",
query: (data.query as string) ?? "",
result_count:
typeof data.result_count === "number"
? (data.result_count as number)
: 0,
error:
typeof data.error === "string"
? (data.error as string)
: undefined,
isStreaming: false,
}),
);
pushThinkingPlaceholder();
continue;
}
if (data.type === "courtlistener_get_cases_start") {
pushEvent({
type: "courtlistener_get_cases",
cluster_ids: Array.isArray(data.cluster_ids)
? (data.cluster_ids as unknown[]).filter(
(value: unknown): value is number =>
typeof value === "number",
)
: [],
isStreaming: true,
});
continue;
}
if (data.type === "courtlistener_get_cases") {
updateMatchingEvent(
(e) =>
e.type === "courtlistener_get_cases" &&
!!e.isStreaming,
() => ({
type: "courtlistener_get_cases",
cluster_ids: Array.isArray(data.cluster_ids)
? (
data.cluster_ids as unknown[]
).filter(
(
value: unknown,
): value is number =>
typeof value === "number",
)
: [],
case_count:
typeof data.case_count === "number"
? (data.case_count as number)
: 0,
opinion_count:
typeof data.opinion_count === "number"
? (data.opinion_count as number)
: 0,
cases: parseCourtlistenerEventCases(
data.cases,
),
error:
typeof data.error === "string"
? (data.error as string)
: undefined,
isStreaming: false,
}),
);
pushThinkingPlaceholder();
continue;
}
if (
data.type === "courtlistener_find_in_case_start"
) {
const searches = parseCourtlistenerCaseSearches(
data.searches,
);
pushEvent({
type: "courtlistener_find_in_case",
cluster_id: searches?.length
? null
: typeof data.cluster_id === "number"
? (data.cluster_id as number)
: null,
query: searches?.length
? ""
: ((data.query as string) ?? ""),
searches,
isStreaming: true,
});
continue;
}
if (data.type === "courtlistener_find_in_case") {
const searches = parseCourtlistenerCaseSearches(
data.searches,
);
updateMatchingEvent(
(e) =>
e.type ===
"courtlistener_find_in_case" &&
(searches?.length
? Array.isArray(e.searches)
: e.cluster_id ===
(typeof data.cluster_id ===
"number"
? (data.cluster_id as number)
: null) &&
e.query ===
(data.query as string)) &&
!!e.isStreaming,
() => ({
type: "courtlistener_find_in_case",
cluster_id: searches?.length
? null
: typeof data.cluster_id === "number"
? (data.cluster_id as number)
: null,
query: searches?.length
? ""
: ((data.query as string) ?? ""),
total_matches:
typeof data.total_matches === "number"
? (data.total_matches as number)
: 0,
searches,
case_name:
typeof data.case_name === "string"
? (data.case_name as string)
: null,
citation:
typeof data.citation === "string"
? (data.citation as string)
: null,
error:
typeof data.error === "string"
? (data.error as string)
: undefined,
isStreaming: false,
}),
);
pushThinkingPlaceholder();
continue;
}
if (data.type === "courtlistener_read_case_start") {
pushEvent({
type: "courtlistener_read_case",
cluster_id:
typeof data.cluster_id === "number"
? (data.cluster_id as number)
: null,
isStreaming: true,
});
continue;
}
if (data.type === "courtlistener_read_case") {
updateMatchingEvent(
(e) =>
e.type === "courtlistener_read_case" &&
e.cluster_id ===
(typeof data.cluster_id === "number"
? (data.cluster_id as number)
: null) &&
!!e.isStreaming,
() => ({
type: "courtlistener_read_case",
cluster_id:
typeof data.cluster_id === "number"
? (data.cluster_id as number)
: null,
case_name:
typeof data.case_name === "string"
? (data.case_name as string)
: null,
citation:
typeof data.citation === "string"
? (data.citation as string)
: null,
opinion_count:
typeof data.opinion_count === "number"
? (data.opinion_count as number)
: 0,
error:
typeof data.error === "string"
? (data.error as string)
: undefined,
isStreaming: false,
}),
);
pushThinkingPlaceholder();
continue;
}
if (
data.type === "courtlistener_verify_citations_start"
) {
pushEvent({
type: "courtlistener_verify_citations",
citation_count:
typeof data.citation_count === "number"
? (data.citation_count as number)
: 0,
isStreaming: true,
});
continue;
}
if (data.type === "courtlistener_verify_citations") {
updateMatchingEvent(
(e) =>
e.type ===
"courtlistener_verify_citations" &&
!!e.isStreaming,
() => ({
type: "courtlistener_verify_citations",
citation_count:
typeof data.citation_count === "number"
? (data.citation_count as number)
: 0,
match_count:
typeof data.match_count === "number"
? (data.match_count as number)
: 0,
error:
typeof data.error === "string"
? (data.error as string)
: undefined,
isStreaming: false,
}),
);
pushThinkingPlaceholder();
continue;
}
if (data.type === "case_citation") {
pushEvent({
type: "case_citation",
cluster_id:
typeof data.cluster_id === "number"
? (data.cluster_id as number)
: null,
case_name:
typeof data.case_name === "string"
? (data.case_name as string)
: null,
citation:
typeof data.citation === "string"
? (data.citation as string)
: null,
url: data.url as string,
});
continue;
}
if (data.type === "case_opinions") {
pushEvent({
type: "case_opinions",
cluster_id:
typeof data.cluster_id === "number"
? (data.cluster_id as number)
: 0,
case: data.case as Extract<
AssistantEvent,
{ type: "case_opinions" }
>["case"],
});
continue;
}
if (data.type === "doc_read_start") {
pushEvent({
type: "doc_read",
@ -1337,7 +1756,10 @@ export function TRChatPanel({
return (
<div
style={{ width: panelWidth }}
className="shrink-0 flex flex-col border-r border-gray-200 bg-white h-full relative"
className={cn(
"shrink-0 flex flex-col border-r border-gray-200 h-full relative",
"bg-transparent",
)}
>
{/* Resize handle */}
<div
@ -1352,9 +1774,15 @@ export function TRChatPanel({
}`}
/>
{/* Header */}
<div className="flex items-center justify-between h-8 px-2 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-1.5 px-2 min-w-0">
<MikeIcon mike size={14} />
<div className="flex items-center justify-between h-8 pr-2 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-1 pl-2 pr-2 min-w-0">
<button
onClick={onClose}
title="Close"
className="flex items-center justify-center h-7 w-7 shrink-0 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<div
onMouseEnter={(e) => {
const el = e.currentTarget;
@ -1374,7 +1802,7 @@ export function TRChatPanel({
className="min-w-0 overflow-x-hidden whitespace-nowrap scrollbar-none"
>
<span className="text-xs font-medium text-gray-700">
{currentChatTitle ?? "Assistant"}
{currentChatTitle ?? "New chat"}
</span>
</div>
</div>
@ -1383,7 +1811,7 @@ export function TRChatPanel({
<button
onClick={() => setHistoryOpen((v) => !v)}
title="Chat history"
className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-400 hover:text-gray-700"}`}
className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-600 hover:text-gray-900"}`}
>
<Clock className="h-3.5 w-3.5" />
</button>
@ -1400,7 +1828,7 @@ export function TRChatPanel({
<button
onClick={handleNewChat}
title="New chat"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
@ -1408,18 +1836,11 @@ export function TRChatPanel({
<button
onClick={handleDeleteChat}
title="Delete chat"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-red-600 transition-colors"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-red-600 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={onClose}
title="Close"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
@ -1432,7 +1853,7 @@ export function TRChatPanel({
{messages.length === 0 && !isLoadingMessages && (
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<MikeIcon size={24} />
<p className="text-sm text-gray-400 text-center">
<p className="text-gray-400 font-serif text-center">
Ask a question about this tabular review.
</p>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,18 +12,21 @@ import {
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { MikeDocument, MikeWorkflow } from "../shared/types";
import type {
Document,
Workflow,
} from "../shared/types";
import { createTabularReview } from "@/app/lib/mikeApi";
import { useRouter } from "next/navigation";
import { formatIcon, formatLabel } from "../tabular/columnFormat";
import { useDirectoryData } from "../shared/useDirectoryData";
import { FileDirectory } from "../shared/FileDirectory";
import type { MikeProject } from "../shared/types";
import type { Project } from "../shared/types";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
interface Props {
workflows: MikeWorkflow[];
workflow: MikeWorkflow | null;
workflows: Workflow[];
workflow: Workflow | null;
onClose: () => void;
}
@ -52,7 +55,7 @@ function SimpleProjectPicker({
selectedId,
onSelect,
}: {
projects: MikeProject[];
projects: Project[];
selectedId: string | null;
onSelect: (id: string | null) => void;
}) {
@ -172,7 +175,7 @@ function MarkdownBody({ content }: { content: string }) {
// ---------------------------------------------------------------------------
// Right panel for assistant workflows (select screen)
// ---------------------------------------------------------------------------
function AssistantPanel({ workflow }: { workflow: MikeWorkflow }) {
function AssistantPanel({ workflow }: { workflow: Workflow }) {
return (
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
<div className="py-3 shrink-0">
@ -192,7 +195,7 @@ function AssistantPanel({ workflow }: { workflow: MikeWorkflow }) {
// ---------------------------------------------------------------------------
// Right panel for tabular workflows — accordion column list (select screen)
// ---------------------------------------------------------------------------
function TabularPanel({ workflow }: { workflow: MikeWorkflow }) {
function TabularPanel({ workflow }: { workflow: Workflow }) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const columns = (workflow.columns_config ?? []).sort(
(a, b) => a.index - b.index,
@ -283,7 +286,7 @@ function TabularPanel({ workflow }: { workflow: MikeWorkflow }) {
// ---------------------------------------------------------------------------
export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
const [screen, setScreen] = useState<"select" | "configure">("select");
const [selected, setSelected] = useState<MikeWorkflow | null>(workflow);
const [selected, setSelected] = useState<Workflow | null>(workflow);
const [listSearch, setListSearch] = useState("");
const selectedRowRef = useRef<HTMLButtonElement>(null);
@ -352,13 +355,16 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
const projectId = inProject ? selectedProjectId! : undefined;
const chatId = await saveChat(projectId);
if (!chatId) return;
const allDocs: MikeDocument[] = [
const allDocs: Document[] = [
...standaloneDocuments,
...projects.flatMap((p) => p.documents || []),
];
const files = allDocs
.filter((d) => selectedDocIds.has(d.id))
.map((d) => ({ filename: d.filename, document_id: d.id }));
.map((d) => ({
filename: d.filename,
document_id: d.id,
}));
const content = assistantPrompt.trim()
? `implement workflow\n\n${assistantPrompt.trim()}`
: "implement workflow";
@ -381,7 +387,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
}
async function handleCreateReview() {
const allDocs: MikeDocument[] = [
const allDocs: Document[] = [
...standaloneDocuments,
...projects.flatMap((p) => p.documents || []),
];
@ -418,7 +424,9 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
const projectDocs = selectedProject?.documents ?? [];
const filteredProjectDocs = q
? projectDocs.filter((d) => d.filename.toLowerCase().includes(q))
? projectDocs.filter((d) =>
d.filename.toLowerCase().includes(q),
)
: projectDocs;
const filteredStandalone = q
@ -431,7 +439,8 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
.map((p) => ({
...p,
documents: (p.documents || []).filter(
(d) => !q || d.filename.toLowerCase().includes(q),
(d) =>
!q || d.filename.toLowerCase().includes(q),
),
}))
.filter(

View file

@ -1,17 +1,18 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { X, MessageSquare, Table2 } from "lucide-react";
import { MessageSquare, Table2 } from "lucide-react";
import { createWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
import type { MikeWorkflow } from "../shared/types";
import type { Workflow } from "../shared/types";
import { PRACTICE_OPTIONS } from "./practices";
import { Modal } from "../shared/Modal";
interface Props {
open: boolean;
onClose: () => void;
onCreated: (workflow: MikeWorkflow) => void;
editWorkflow?: MikeWorkflow;
onUpdated?: (workflow: MikeWorkflow) => void;
onCreated: (workflow: Workflow) => void;
editWorkflow?: Workflow;
onUpdated?: (workflow: Workflow) => void;
}
export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpdated }: Props) {
@ -26,6 +27,7 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd
const isEditing = !!editWorkflow;
const isOthers = practice === "Others";
const effectivePractice = isOthers ? (customPractice.trim() || null) : (practice || null);
const formId = "workflow-modal-form";
useEffect(() => {
if (open && editWorkflow) {
@ -95,124 +97,106 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd
}
return (
<div className="fixed inset-0 z-101 flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl overflow-hidden flex flex-col" style={{ height: 600 }}>
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Workflows</span>
<span></span>
<span>{isEditing ? "Edit workflow" : "New workflow"}</span>
<Modal
open={open}
onClose={handleClose}
breadcrumbs={[
"Workflows",
isEditing ? "Edit workflow" : "New workflow",
]}
primaryAction={{
label: loading
? isEditing
? "Saving…"
: "Creating…"
: isEditing
? "Save changes"
: "Create workflow",
type: "submit",
form: formId,
disabled: !title.trim() || loading,
}}
>
<form
id={formId}
onSubmit={handleSubmit}
className="flex flex-col flex-1 min-h-0"
>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Workflow name"
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent"
autoFocus
/>
{!isEditing && (
<div className="mt-5">
<p className="mb-2 text-sm font-medium text-gray-500">Type</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setType("assistant")}
className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
type === "assistant"
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
<MessageSquare className="h-3 w-3" />
Assistant
</button>
<button
type="button"
onClick={() => setType("tabular")}
className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
type === "tabular"
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
<Table2 className="h-3 w-3" />
Tabular
</button>
</div>
</div>
<button
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
<div className="mt-5">
<p className="mb-2 text-sm font-medium text-gray-500">Practice Area</p>
<div className="flex flex-wrap gap-2">
{PRACTICE_OPTIONS.map((p) => (
<button
key={p}
type="button"
onClick={() => setPractice(practice === p ? "" : p)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
practice === p
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{p}
</button>
))}
</div>
{isOthers && (
<input
ref={customInputRef}
type="text"
value={customPractice}
onChange={(e) => setCustomPractice(e.target.value)}
placeholder="Enter practice area…"
className="mt-3 w-full rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none"
/>
)}
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
{/* Body */}
<div className="px-6 pt-3 pb-5 flex-1 overflow-y-auto">
{/* Title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Workflow name"
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent"
autoFocus
/>
{/* Type pills — only shown when creating */}
{!isEditing && (
<div className="mt-5">
<p className="mb-2 text-sm font-medium text-gray-500">Type</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setType("assistant")}
className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
type === "assistant"
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
<MessageSquare className="h-3 w-3" />
Assistant
</button>
<button
type="button"
onClick={() => setType("tabular")}
className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
type === "tabular"
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
<Table2 className="h-3 w-3" />
Tabular
</button>
</div>
</div>
)}
{/* Practice */}
<div className="mt-5">
<p className="mb-2 text-sm font-medium text-gray-500">Practice Area</p>
<div className="flex flex-wrap gap-2">
{PRACTICE_OPTIONS.map((p) => (
<button
key={p}
type="button"
onClick={() => setPractice(practice === p ? "" : p)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
practice === p
? "border-gray-900 bg-gray-900 text-white"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{p}
</button>
))}
</div>
{isOthers && (
<input
ref={customInputRef}
type="text"
value={customPractice}
onChange={(e) => setCustomPractice(e.target.value)}
placeholder="Enter practice area…"
className="mt-3 w-full rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none"
/>
)}
</div>
{error && (
<p className="mt-4 text-sm text-red-500">{error}</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4 shrink-0">
<button
type="button"
onClick={handleClose}
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!title.trim() || loading}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
{loading ? (isEditing ? "Saving…" : "Creating…") : (isEditing ? "Save changes" : "Create workflow")}
</button>
</div>
</form>
</div>
</div>
{error && (
<p className="mt-4 text-sm text-red-500">{error}</p>
)}
</form>
</Modal>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import {
deleteWorkflowShare,
@ -10,6 +9,7 @@ import {
} from "@/app/lib/mikeApi";
import { useAuth } from "@/contexts/AuthContext";
import { EmailPillInput } from "../shared/EmailPillInput";
import { Modal } from "../shared/Modal";
interface Share {
id: string;
@ -67,103 +67,74 @@ export function ShareWorkflowModal({
}
}
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Workflows</span>
<span></span>
<span className="truncate max-w-[220px]">
{workflowName}
</span>
<span></span>
<span>People</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" />
return (
<Modal
open
onClose={onClose}
breadcrumbs={["Workflows", workflowName, "People"]}
primaryAction={{
label: saving ? "Sharing…" : "Share",
onClick: handleConfirm,
disabled: saving || pendingEmails.length === 0,
}}
>
<EmailPillInput
emails={pendingEmails}
onChange={setPendingEmails}
validate={async (email) =>
ownEmail && email === ownEmail
? "You cannot share a workflow with yourself."
: null
}
placeholder="Add people by email…"
autoFocus
/>
{/* Permission toggle */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
<button
type="button"
onClick={() => setAllowEdit((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${allowEdit ? "bg-gray-900" : "bg-gray-200"}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} />
</button>
</div>
<div className="px-5 py-4 flex flex-col gap-4 flex-1 overflow-y-auto">
<EmailPillInput
emails={pendingEmails}
onChange={setPendingEmails}
validate={async (email) =>
ownEmail && email === ownEmail
? "You cannot share a workflow with yourself."
: null
}
placeholder="Add people by email…"
autoFocus
/>
{/* Permission toggle */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
<button
type="button"
onClick={() => setAllowEdit((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${allowEdit ? "bg-gray-900" : "bg-gray-200"}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} />
</button>
</div>
{/* Existing access */}
<div>
<p className="text-xs font-medium text-gray-700 mb-2">People with access</p>
{loading ? (
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="h-3 w-40 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
))}
</div>
) : existingShares.length === 0 ? (
<p className="text-sm text-gray-400">None</p>
) : (
<div className="space-y-1">
{existingShares.map((share) => (
<div key={share.id} className="flex items-center justify-between py-1">
<span className="text-sm text-gray-700 truncate">{share.shared_with_email}</span>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-gray-400">{share.allow_edit ? "Can edit" : "Read-only"}</span>
<button
onClick={() => handleRemoveShare(share.id)}
className="text-gray-300 hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
{/* Existing access */}
<div>
<p className="text-xs font-medium text-gray-700 mb-2">People with access</p>
{loading ? (
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="h-3 w-40 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
))}
</div>
) : existingShares.length === 0 ? (
<p className="text-sm text-gray-400">None</p>
) : (
<div className="space-y-1">
{existingShares.map((share) => (
<div key={share.id} className="flex items-center justify-between py-1">
<span className="text-sm text-gray-700 truncate">{share.shared_with_email}</span>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-gray-400">{share.allow_edit ? "Can edit" : "Read-only"}</span>
<button
onClick={() => handleRemoveShare(share.id)}
className="text-gray-300 hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-5 py-3 flex justify-end gap-2 mt-auto shrink-0">
<button
onClick={onClose}
className="rounded-lg px-5 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={saving || pendingEmails.length === 0}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
{saving ? "Sharing…" : "Share"}
</button>
</div>
</div>
</div>,
document.body,
</Modal>
);
}

View file

@ -1,11 +1,10 @@
"use client";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ColumnConfig } from "../shared/types";
import { formatIcon, formatLabel } from "../tabular/columnFormat";
import { Modal } from "../shared/Modal";
interface Props {
col: ColumnConfig;
@ -14,55 +13,46 @@ interface Props {
export function WFColumnViewModal({ col, onClose }: Props) {
const FormatIcon = formatIcon(col.format ?? "text");
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
<div className="flex items-center justify-between px-6 pt-5 pb-2">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Workflows</span>
<span></span>
<span className="truncate max-w-[200px] text-gray-600">{col.name}</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>
return (
<Modal
open
onClose={onClose}
breadcrumbs={["Workflows", col.name]}
primaryAction={{
label: "Close",
onClick: onClose,
}}
cancelAction={false}
>
<div className="flex flex-col gap-4">
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Column Title</p>
<p className="text-sm text-gray-800">{col.name}</p>
</div>
<div className="px-6 pt-3 pb-5 flex flex-col gap-4 overflow-y-auto flex-1">
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Format</p>
<span className="inline-flex items-center gap-1.5 text-sm text-gray-700">
<FormatIcon className="h-3.5 w-3.5 text-gray-400" />
{formatLabel(col.format ?? "text")}
</span>
</div>
{col.tags && col.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Column Title</p>
<p className="text-sm text-gray-800">{col.name}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Format</p>
<span className="inline-flex items-center gap-1.5 text-sm text-gray-700">
<FormatIcon className="h-3.5 w-3.5 text-gray-400" />
{formatLabel(col.format ?? "text")}
</span>
</div>
{col.tags && col.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-500 mb-2.5">Tags</p>
<div className="flex flex-wrap gap-1.5">
{col.tags.map((tag) => (
<span key={tag} className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{tag}</span>
))}
</div>
</div>
)}
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Prompt</p>
<div className="text-base text-gray-700 leading-relaxed font-serif prose prose-base max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{col.prompt || "_No prompt defined._"}</ReactMarkdown>
<p className="text-sm font-medium text-gray-500 mb-2.5">Tags</p>
<div className="flex flex-wrap gap-1.5">
{col.tags.map((tag) => (
<span key={tag} className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{tag}</span>
))}
</div>
</div>
</div>
<div className="border-t border-gray-100 px-6 py-4 flex justify-end shrink-0">
<button onClick={onClose} className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700">
Close
</button>
)}
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Prompt</p>
<div className="text-base text-gray-700 leading-relaxed font-serif prose prose-base max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{col.prompt || "_No prompt defined._"}</ReactMarkdown>
</div>
</div>
</div>
</div>,
document.body,
</Modal>
);
}

View file

@ -3,7 +3,6 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Plus,
Library,
Table2,
MessageSquare,
@ -11,7 +10,6 @@ import {
ChevronDown,
Check,
} from "lucide-react";
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
import {
listWorkflows,
deleteWorkflow,
@ -19,7 +17,7 @@ import {
hideWorkflow,
unhideWorkflow,
} from "@/app/lib/mikeApi";
import type { MikeWorkflow } from "../shared/types";
import type { Workflow } from "../shared/types";
import { BUILT_IN_WORKFLOWS, BUILT_IN_IDS } from "./builtinWorkflows";
import { DisplayWorkflowModal } from "./DisplayWorkflowModal";
import { NewWorkflowModal } from "./NewWorkflowModal";
@ -27,11 +25,11 @@ import { ToolbarTabs } from "../shared/ToolbarTabs";
import { RowActions } from "../shared/RowActions";
import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/contexts/AuthContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
type Tab = "all" | "builtin" | "custom" | "hidden";
const CHECK_W = "w-8 shrink-0";
const NAME_COL_W = "w-[300px] shrink-0";
const NAME_COL_W = "w-[332px] shrink-0";
const TABS: { id: Tab; label: string }[] = [
{ id: "all", label: "All" },
@ -43,9 +41,10 @@ const TABS: { id: Tab; label: string }[] = [
export function WorkflowList() {
const router = useRouter();
const { user } = useAuth();
const [custom, setCustom] = useState<MikeWorkflow[]>([]);
const stickyCellBg = "bg-[#fcfcfd]";
const [custom, setCustom] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<MikeWorkflow | null>(null);
const [selected, setSelected] = useState<Workflow | null>(null);
const [activeTab, setActiveTab] = useState<Tab>("all");
const [newModalOpen, setNewModalOpen] = useState(false);
const [hiddenBuiltinIds, setHiddenBuiltinIds] = useState<string[]>([]);
@ -53,7 +52,7 @@ export function WorkflowList() {
const [actionsOpen, setActionsOpen] = useState(false);
const [practiceFilter, setPracticeFilter] = useState<string | null>(null);
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false);
const [typeFilter, setTypeFilter] = useState<MikeWorkflow["type"] | null>(
const [typeFilter, setTypeFilter] = useState<Workflow["type"] | null>(
null,
);
const [typeFilterOpen, setTypeFilterOpen] = useState(false);
@ -199,7 +198,7 @@ export function WorkflowList() {
await Promise.all(ids.map((id) => unhideWorkflow(id).catch(() => {})));
}
const getTypeMeta = (type: MikeWorkflow["type"]) =>
const getTypeMeta = (type: Workflow["type"]) =>
type === "tabular"
? { label: "Tabular", Icon: Table2, className: "text-violet-700" }
: {
@ -358,26 +357,28 @@ export function WorkflowList() {
);
return (
<div className="flex flex-col flex-1 overflow-hidden bg-white">
<div className="flex flex-col flex-1 overflow-hidden">
{/* Page header */}
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10 shrink-0">
<PageHeader
shrink
actions={[
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search workflows…",
},
{
type: "new",
onClick: () => setNewModalOpen(true),
title: "New workflow",
},
]}
>
<h1 className="text-2xl font-medium font-serif text-gray-900">
Workflows
</h1>
<div className="flex items-center gap-2">
<HeaderSearchBtn
value={search}
onChange={setSearch}
placeholder="Search workflows…"
/>
<button
onClick={() => setNewModalOpen(true)}
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
</PageHeader>
<ToolbarTabs
tabs={TABS}
@ -391,8 +392,10 @@ export function WorkflowList() {
<div className="min-w-max">
{/* Column headers */}
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
{!loading && (
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
{loading ? (
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
) : (
<input
type="checkbox"
checked={allSelected}
@ -403,9 +406,7 @@ export function WorkflowList() {
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
)}
</div>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}>
Name
<span>Name</span>
</div>
<div className="ml-auto w-28 shrink-0">Type</div>
<div className="w-40 shrink-0">Practice</div>
@ -420,8 +421,8 @@ export function WorkflowList() {
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
>
<div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4">
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-28 shrink-0">
@ -486,28 +487,26 @@ export function WorkflowList() {
filtered.map((wf) => {
const rowBg = selectedIds.includes(wf.id)
? "bg-gray-50"
: "bg-white";
: stickyCellBg;
return (
<div
key={wf.id}
onClick={() => setSelected(wf)}
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selectedIds.includes(wf.id)}
onChange={() => toggleOne(wf.id)}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}>
<span className="text-sm text-gray-800 truncate block">
{wf.title}
</span>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${rowBg} transition-colors group-hover:bg-gray-100`}>
<div className="flex min-w-0 items-center gap-4">
<input
type="checkbox"
checked={selectedIds.includes(wf.id)}
onChange={() => toggleOne(wf.id)}
onClick={(e) => e.stopPropagation()}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{wf.title}
</span>
</div>
</div>
<div className="ml-auto w-28 shrink-0">
{(() => {

View file

@ -1,6 +1,6 @@
import type { MikeWorkflow } from "../shared/types";
import type { Workflow } from "../shared/types";
export const BUILT_IN_WORKFLOWS: MikeWorkflow[] = [
export const BUILT_IN_WORKFLOWS: Workflow[] = [
{
id: "builtin-cp-checklist",
user_id: null,