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

@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { Check, Eye, EyeOff, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useUserProfile } from "@/contexts/UserProfileContext";
const MODEL_API_KEY_FIELDS = [
{
provider: "claude",
label: "Anthropic (Claude) API Key",
placeholder: "sk-ant-...",
},
{
provider: "gemini",
label: "Google (Gemini) API Key",
placeholder: "AI...",
},
{
provider: "openai",
label: "OpenAI API Key",
placeholder: "sk-...",
},
{
provider: "openrouter",
label: "OpenRouter API Key",
placeholder: "sk-or-...",
},
] as const;
const OTHER_API_KEY_FIELDS = [
{
provider: "courtlistener",
label: "CourtListener API Key",
placeholder: "Token...",
description:
"Add a CourtListener API key if you want the latest CourtListener data. Otherwise, Mike will use the bulk data hosted by us.",
},
] as const;
export default function ApiKeysPage() {
const { profile, updateApiKey } = useUserProfile();
return (
<div>
<h2 className="mb-3 text-2xl font-medium font-serif text-gray-900">
API Keys
</h2>
<p className="text-sm text-gray-500 mb-4">
You must provide your own API keys for the app to work or add
your API keys into the .env file if you are running your own
instance of Mike. All API keys are encrypted in storage.
</p>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
{MODEL_API_KEY_FIELDS.map((field) => (
<ApiKeyField
key={field.provider}
label={field.label}
placeholder={field.placeholder}
hasSavedKey={
!!profile?.apiKeys[field.provider].configured
}
isServerConfigured={
profile?.apiKeys[field.provider].source === "env"
}
onSave={(value) =>
updateApiKey(field.provider, value.trim() || null)
}
onRemove={() => updateApiKey(field.provider, null)}
/>
))}
</div>
<div className="mt-8 overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
{OTHER_API_KEY_FIELDS.map((field) => (
<ApiKeyField
key={field.provider}
label={field.label}
description={field.description}
placeholder={field.placeholder}
hasSavedKey={
!!profile?.apiKeys[field.provider].configured
}
isServerConfigured={
profile?.apiKeys[field.provider].source === "env"
}
onSave={(value) =>
updateApiKey(field.provider, value.trim() || null)
}
onRemove={() => updateApiKey(field.provider, null)}
/>
))}
</div>
</div>
);
}
function ApiKeyField({
label,
description,
placeholder,
hasSavedKey,
isServerConfigured,
onSave,
onRemove,
}: {
label: string;
description?: string;
placeholder: string;
hasSavedKey: boolean;
isServerConfigured: boolean;
onSave: (value: string) => Promise<boolean>;
onRemove: () => Promise<boolean>;
}) {
const [value, setValue] = useState("");
const [reveal, setReveal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
setValue("");
}, [hasSavedKey]);
const dirty = value.trim().length > 0;
const handleSave = async () => {
setIsSaving(true);
const ok = await onSave(value);
setIsSaving(false);
if (ok) {
setValue("");
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
alert(`Failed to save ${label}.`);
}
};
const handleRemove = async () => {
setIsSaving(true);
const ok = await onRemove();
setIsSaving(false);
if (!ok) alert(`Failed to remove ${label}.`);
};
return (
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
{label}
</label>
{description && (
<p className="text-sm text-gray-500 mb-3">{description}</p>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={reveal ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
isServerConfigured
? "Server .env key configured"
: hasSavedKey
? "Saved key hidden"
: placeholder
}
className="bg-gray-50 pr-10 shadow-none disabled:text-gray-700 disabled:placeholder:text-gray-700"
autoComplete="off"
spellCheck={false}
disabled={isServerConfigured}
/>
<button
type="button"
onClick={() => setReveal((r) => !r)}
disabled={isServerConfigured}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={reveal ? "Hide key" : "Show key"}
>
{reveal ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
onClick={handleSave}
variant="outline"
disabled={isServerConfigured || isSaving || !dirty || saved}
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
>
{isSaving ? (
"Saving..."
) : saved ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save
</>
)}
</Button>
{hasSavedKey && !isServerConfigured && (
<Button
type="button"
variant="outline"
onClick={handleRemove}
disabled={isSaving}
className="h-9 gap-1.5 bg-white px-2.5 text-xs text-red-600 shadow-none hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
);
}

View file

@ -13,7 +13,8 @@ interface TabDef {
const TABS: TabDef[] = [
{ id: "general", label: "General", href: "/account" },
{ id: "models", label: "Models & API Keys", href: "/account/models" },
{ id: "models", label: "Model Preferences", href: "/account/models" },
{ id: "api-keys", label: "API Keys", href: "/account/api-keys" },
];
export default function AccountLayout({
@ -33,7 +34,7 @@ export default function AccountLayout({
if (authLoading) {
return (
<div className="h-dvh bg-white flex items-center justify-center">
<div className="h-dvh flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
</div>
);

View file

@ -1,9 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { AlertCircle, Check, ChevronDown, Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useEffect, useRef, useState } from "react";
import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@ -14,123 +12,133 @@ import {
} from "@/components/ui/dropdown-menu";
import { useUserProfile } from "@/contexts/UserProfileContext";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { MODELS } from "@/app/components/assistant/ModelToggle";
import {
MODELS,
SETTINGS_MODELS,
type ModelOption,
} from "@/app/components/assistant/ModelToggle";
import {
isModelAvailable,
modelGroupToProvider,
providerLabel,
} from "@/app/lib/modelAvailability";
const API_KEY_FIELDS = [
{
provider: "claude",
label: "Anthropic (Claude) API Key",
placeholder: "sk-ant-…",
},
{
provider: "gemini",
label: "Google (Gemini) API Key",
placeholder: "AI…",
},
{
provider: "openai",
label: "OpenAI API Key",
placeholder: "sk-…",
},
] as const;
type ModelPreferenceField = "titleModel" | "tabularModel";
export default function ModelsAndApiKeysPage() {
const { profile, updateModelPreference, updateApiKey } = useUserProfile();
export default function ModelPreferencesPage() {
const { profile, updateModelPreference } = useUserProfile();
const [savingField, setSavingField] = useState<ModelPreferenceField | null>(
null,
);
const [savedField, setSavedField] = useState<ModelPreferenceField | null>(
null,
);
const [optimisticValues, setOptimisticValues] = useState<
Partial<Record<ModelPreferenceField, string>>
>({});
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
};
}, []);
const handleModelChange = async (
field: ModelPreferenceField,
id: string,
) => {
setOptimisticValues((current) => ({ ...current, [field]: id }));
setSavedField(null);
setSavingField(field);
const ok = await updateModelPreference(field, id);
setSavingField((current) => (current === field ? null : current));
if (ok) {
setSavedField(field);
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
savedTimerRef.current = setTimeout(() => {
setSavedField((current) => (current === field ? null : current));
}, 1600);
} else {
setOptimisticValues((current) => {
const next = { ...current };
delete next[field];
return next;
});
}
};
return (
<div className="space-y-4">
{/* Model Preferences */}
<div className="pb-6">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-2xl font-medium font-serif">
Model Preferences
</h2>
</div>
<div className="space-y-4 max-w-md">
<div>
<label className="text-sm text-gray-600 block mb-2">
Tabular review model
</label>
<p className="text-xs text-gray-400 mb-2">
We recommend using a smaller model for tabular
reviews to reduce token costs.
</p>
<TabularModelDropdown
value={
profile?.tabularModel ??
"gemini-3-flash-preview"
}
apiKeys={profile?.apiKeys}
onChange={(id) =>
updateModelPreference("tabularModel", id)
}
/>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-2xl font-medium font-serif">
Model Preferences
</h2>
</div>
{/* API Keys */}
<div className="py-6">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-2xl font-medium font-serif">
API Keys
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
Title generation model
</label>
<p className="text-xs text-gray-400 mb-2">
Used for naming chats and other lightweight titles.
</p>
<ModelPreferenceDropdown
value={
optimisticValues.titleModel ??
profile?.titleModel ??
"gemini-3.1-flash-lite-preview"
}
options={SETTINGS_MODELS}
apiKeys={profile?.apiKeys}
isSaving={savingField === "titleModel"}
isSaved={savedField === "titleModel"}
onChange={(id) => handleModelChange("titleModel", id)}
/>
</div>
<p className="text-sm text-gray-500 mb-4 max-w-xl">
You must provide your own API keys for the app to work or
add your API keys into the .env file if you are running your
own instance of Mike.
</p>
<p className="text-xs text-gray-400 mb-4 max-w-xl">
Title generation automatically routes to the cheapest
configured provider model.
</p>
<div className="space-y-4 max-w-xl">
{API_KEY_FIELDS.map((field) => (
<ApiKeyField
key={field.provider}
label={field.label}
placeholder={field.placeholder}
hasSavedKey={
!!profile?.apiKeys[field.provider].configured
}
isServerConfigured={
profile?.apiKeys[field.provider].source ===
"env"
}
onSave={(value) =>
updateApiKey(
field.provider,
value.trim() || null,
)
}
onRemove={() =>
updateApiKey(field.provider, null)
}
/>
))}
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
Tabular review model
</label>
<p className="text-xs text-gray-400 mb-2">
We recommend using a smaller model for tabular reviews
to reduce token costs.
</p>
<ModelPreferenceDropdown
value={
optimisticValues.tabularModel ??
profile?.tabularModel ??
"gemini-3-flash-preview"
}
options={MODELS}
apiKeys={profile?.apiKeys}
isSaving={savingField === "tabularModel"}
isSaved={savedField === "tabularModel"}
onChange={(id) => handleModelChange("tabularModel", id)}
/>
</div>
</div>
</div>
);
}
function TabularModelDropdown({
function ModelPreferenceDropdown({
value,
onChange,
apiKeys,
options,
isSaving,
isSaved,
}: {
value: string;
onChange: (id: string) => void;
apiKeys?: ApiKeyState;
options: ModelOption[];
isSaving?: boolean;
isSaved?: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const selected = MODELS.find((m) => m.id === value);
const selected = options.find((m) => m.id === value);
const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true;
const groups: ("Anthropic" | "Google" | "OpenAI")[] = [
"Anthropic",
@ -143,7 +151,8 @@ function TabularModelDropdown({
<DropdownMenuTrigger asChild>
<button
type="button"
className="w-full h-9 rounded-md border border-gray-300 bg-white px-3 text-sm shadow-sm flex items-center justify-between gap-2 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black/10"
disabled={isSaving}
className="w-full h-9 rounded-md border border-gray-300 bg-gray-50 px-3 text-sm flex items-center justify-between gap-2 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black/10"
>
<span className="flex items-center gap-2 min-w-0">
{!selectedAvailable && (
@ -153,9 +162,15 @@ function TabularModelDropdown({
{selected?.label ?? "Select a model"}
</span>
</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-gray-500" />
) : isSaved ? (
<Check className="h-3.5 w-3.5 shrink-0 text-green-600" />
) : (
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -164,7 +179,7 @@ function TabularModelDropdown({
align="start"
>
{groups.map((group, gi) => {
const items = MODELS.filter((m) => m.group === group);
const items = options.filter((m) => m.group === group);
if (items.length === 0) return null;
return (
<div key={group}>
@ -209,133 +224,3 @@ function TabularModelDropdown({
</DropdownMenu>
);
}
function ApiKeyField({
label,
placeholder,
hasSavedKey,
isServerConfigured,
onSave,
onRemove,
}: {
label: string;
placeholder: string;
hasSavedKey: boolean;
isServerConfigured: boolean;
onSave: (value: string) => Promise<boolean>;
onRemove: () => Promise<boolean>;
}) {
const [value, setValue] = useState("");
const [reveal, setReveal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
setValue("");
}, [hasSavedKey]);
const dirty = value.trim().length > 0;
const handleSave = async () => {
setIsSaving(true);
const ok = await onSave(value);
setIsSaving(false);
if (ok) {
setValue("");
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
alert(`Failed to save ${label}.`);
}
};
const handleRemove = async () => {
setIsSaving(true);
const ok = await onRemove();
setIsSaving(false);
if (!ok) alert(`Failed to remove ${label}.`);
};
return (
<div>
<label className="text-sm text-gray-600 block mb-2">{label}</label>
{isServerConfigured && (
<div className="mb-2 rounded-md border border-blue-100 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-800">
A server .env key is configured for this provider.
Browser API-key edits are disabled.
</p>
{hasSavedKey && (
<p className="mt-1 text-xs text-blue-800">
The server key will be used for this provider.
</p>
)}
</div>
)}
{hasSavedKey && !isServerConfigured && (
<p className="text-xs text-gray-500 mb-2">
A key is saved. Paste a new key to replace it.
</p>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={reveal ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
isServerConfigured
? "Server .env key configured"
: hasSavedKey
? "Saved key hidden"
: placeholder
}
className="pr-10"
autoComplete="off"
spellCheck={false}
disabled={isServerConfigured}
/>
<button
type="button"
onClick={() => setReveal((r) => !r)}
disabled={isServerConfigured}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={reveal ? "Hide key" : "Show key"}
>
{reveal ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
onClick={handleSave}
disabled={isServerConfigured || isSaving || !dirty || saved}
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
>
{isSaving ? (
"Saving..."
) : saved ? (
<>
<Check className="h-4 w-3" />
Saved
</>
) : (
"Save"
)}
</Button>
{hasSavedKey && !isServerConfigured && (
<Button
type="button"
variant="outline"
onClick={handleRemove}
disabled={isSaving}
>
Remove
</Button>
)}
</div>
</div>
);
}

View file

@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LogOut, Check } from "lucide-react";
import { LogOut, Check, Save } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { deleteAccount } from "@/app/lib/mikeApi";
@ -78,163 +78,188 @@ export default function AccountPage() {
if (!user) return null;
return (
<div className="space-y-4">
<div className="space-y-8">
{/* Profile Settings */}
<div className="pb-6">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-2xl font-medium font-serif">Profile</h2>
</div>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-600 block mb-2">
Display Name
</label>
<div className="flex gap-2">
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Profile
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<div className="space-y-4">
<div>
<label className="text-sm text-gray-600 block mb-2">
Display Name
</label>
<div className="flex gap-2">
<Input
type="text"
value={displayName}
onChange={(e) =>
setDisplayName(e.target.value)
}
placeholder="Enter your name"
className="flex-1 bg-gray-50 shadow-none"
/>
<Button
onClick={handleSaveDisplayName}
variant="outline"
disabled={
isSavingName ||
!displayName.trim() ||
saved
}
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
>
{isSavingName ? (
"Saving..."
) : saved ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save
</>
)}
</Button>
</div>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2">
Organisation
</label>
<div className="flex gap-2">
<Input
type="text"
value={organisation}
onChange={(e) =>
setOrganisation(e.target.value)
}
placeholder="Enter your organisation"
className="flex-1 bg-gray-50 shadow-none"
/>
<Button
onClick={handleSaveOrganisation}
variant="outline"
disabled={
isSavingOrg ||
organisation.trim() ===
(profile?.organisation ?? "") ||
orgSaved
}
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
>
{isSavingOrg ? (
"Saving..."
) : orgSaved ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save
</>
)}
</Button>
</div>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2">
Email
</label>
<Input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Enter your name"
className="flex-1"
type="email"
value={user?.email ?? ""}
disabled
className="bg-gray-50 shadow-none disabled:text-gray-700 disabled:opacity-100"
/>
<Button
onClick={handleSaveDisplayName}
disabled={
isSavingName || !displayName.trim() || saved
}
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
>
{isSavingName ? (
"Saving..."
) : saved ? (
<>
<Check className="h-4 w-3" />
Saved
</>
) : (
"Save"
)}
</Button>
</div>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2">
Organisation
</label>
<div className="flex gap-2">
<Input
type="text"
value={organisation}
onChange={(e) =>
setOrganisation(e.target.value)
}
placeholder="Enter your organisation"
className="flex-1"
/>
<Button
onClick={handleSaveOrganisation}
disabled={
isSavingOrg ||
organisation.trim() ===
(profile?.organisation ?? "") ||
orgSaved
}
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
>
{isSavingOrg ? (
"Saving..."
) : orgSaved ? (
<>
<Check className="h-4 w-3" />
Saved
</>
) : (
"Save"
)}
</Button>
</div>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2">
Email
</label>
<p className="text-base">{user?.email}</p>
</div>
</div>
</div>
</section>
{/* Plan */}
<div className="py-6">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-2xl font-medium font-serif">
Usage Plan
</h2>
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Usage Plan
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<div>
<p className="text-base font-medium text-gray-500 capitalize">
{profile?.tier || "Free"}
</p>
</div>
</div>
<div>
<p className="text-base font-medium text-gray-500 capitalize">
{profile?.tier || "Free"}
</p>
</div>
</div>
</section>
{/* Actions */}
<div className="py-6">
<h2 className="text-2xl font-medium font-serif mb-4">
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Actions
</h2>
<Button
variant="outline"
onClick={handleLogout}
className="w-full sm:w-auto"
>
<LogOut className="h-4 w-4 mr-2" />
Sign Out
</Button>
</div>
{/* Danger Zone */}
<div className="py-6">
<h2 className="text-2xl font-medium font-serif mb-1 text-red-600">
Danger Zone
</h2>
<p className="text-sm text-gray-500 mb-4">
Permanently delete your account and all associated data.
This action cannot be undone.
</p>
{deleteConfirm ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 space-y-3 max-w-sm">
<p className="text-sm font-medium text-red-700">
Are you sure? This will permanently delete your
account.
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeleteConfirm(false)}
disabled={isDeleting}
className="text-sm"
>
Cancel
</Button>
<Button
onClick={handleDeleteAccount}
disabled={isDeleting}
className="text-sm bg-red-600 hover:bg-red-700 text-white"
>
{isDeleting ? "Deleting…" : "Delete Account"}
</Button>
</div>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<Button
variant="outline"
onClick={() => setDeleteConfirm(true)}
className="w-full sm:w-auto border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={handleLogout}
className="w-full shadow-none sm:w-auto"
>
Delete Account
<LogOut className="h-4 w-4 mr-2" />
Sign Out
</Button>
)}
</div>
</div>
</section>
{/* Danger Zone */}
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-red-600">
Danger Zone
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500 mb-4">
Permanently delete your account and all associated data.
This action cannot be undone.
</p>
{deleteConfirm ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 space-y-3 max-w-sm">
<p className="text-sm font-medium text-red-700">
Are you sure? This will permanently delete your
account.
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeleteConfirm(false)}
disabled={isDeleting}
className="text-sm shadow-none"
>
Cancel
</Button>
<Button
onClick={handleDeleteAccount}
disabled={isDeleting}
className="bg-red-600 text-sm text-white shadow-none hover:bg-red-700"
>
{isDeleting
? "Deleting…"
: "Delete Account"}
</Button>
</div>
</div>
) : (
<Button
variant="outline"
onClick={() => setDeleteConfirm(true)}
className="w-full border-red-200 text-red-600 shadow-none hover:bg-red-50 hover:text-red-700 sm:w-auto"
>
Delete Account
</Button>
)}
</div>
</section>
</div>
);
}

View file

@ -61,6 +61,7 @@ export default function AssistantChatPage() {
return (
<ChatView
chatId={id}
messages={messages}
isResponseLoading={isResponseLoading}
handleChat={handleChat}

View file

@ -4,14 +4,20 @@ import { useRouter } from "next/navigation";
import { useAssistantChat } from "@/app/hooks/useAssistantChat";
import { InitialView } from "@/app/components/assistant/InitialView";
import { ChatView } from "@/app/components/assistant/ChatView";
import type { MikeMessage } from "@/app/components/shared/types";
import type { Message } from "@/app/components/shared/types";
export default function AssistantPage() {
const router = useRouter();
const { messages, isResponseLoading, handleChat, handleNewChat, cancel } =
useAssistantChat();
const {
messages,
isResponseLoading,
handleChat,
handleNewChat,
cancel,
chatId,
} = useAssistantChat();
async function handleInitialSubmit(message: MikeMessage) {
async function handleInitialSubmit(message: Message) {
const chatId = await handleNewChat(message);
if (chatId) router.push(`/assistant/chat/${chatId}`);
}
@ -26,6 +32,7 @@ export default function AssistantPage() {
return (
<ChatView
chatId={chatId}
messages={messages}
isResponseLoading={isResponseLoading}
handleChat={handleChat}

View file

@ -79,13 +79,20 @@ export default function MikeLayout({
<SidebarContext.Provider
value={{
setSidebarOpen: (open) => {
const isSmall =
typeof window !== "undefined" &&
window.innerWidth < 768;
if (isSmall) {
if (!open) setIsSidebarOpen(false);
return;
}
setIsSidebarOpen(open);
setIsSidebarOpenDesktop(open);
},
}}
>
<div className="h-dvh bg-white flex flex-col">
<div className="flex-1 flex overflow-hidden">
<div className="h-dvh flex flex-col bg-gray-50/80">
<div className="flex-1 flex min-w-0 overflow-visible">
<AppSidebar
isOpen={isSidebarOpen}
onToggle={handleSidebarToggle}

View file

@ -15,8 +15,6 @@ import {
ChevronRight,
FileText,
Loader2,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
@ -46,13 +44,14 @@ import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import type {
CitationQuote,
MikeCitationAnnotation,
MikeDocument,
MikeEditAnnotation,
MikeMessage,
MikeProject,
CitationAnnotation,
Document,
EditAnnotation,
Message,
Project,
} from "@/app/components/shared/types";
import { expandCitationToEntries } from "@/app/components/shared/types";
@ -206,7 +205,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
const username =
profile?.displayName?.trim() || user?.email?.split("@")[0] || "there";
const [project, setProject] = useState<MikeProject | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [chatTitle, setChatTitle] = useState<string | null>(null);
const [chatOwnerId, setChatOwnerId] = useState<string | null>(null);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
@ -254,7 +253,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
chats,
saveChat,
} = useChatHistoryContext();
const [initialMessages] = useState<MikeMessage[]>(newChatMessages ?? []);
const [initialMessages] = useState<Message[]>(newChatMessages ?? []);
const { messages, isResponseLoading, handleChat, setMessages, cancel } =
useAssistantChat({ initialMessages, chatId, projectId });
@ -470,7 +469,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
// ── Handlers ──────────────────────────────────────────────────────────────
const handleSubmit = useCallback(
(message: MikeMessage) => {
(message: Message) => {
if (!activeTab) return handleChat(message);
return handleChat(message, {
displayedDoc: {
@ -482,11 +481,12 @@ export default function ProjectAssistantChatPage({ params }: Props) {
[activeTab, handleChat],
);
const handleDocClick = (doc: MikeDocument) => {
const handleDocClick = (doc: Document) => {
openTab(doc.id, doc.filename);
};
const handleCitationClick = (citation: MikeCitationAnnotation) => {
const handleCitationClick = (citation: CitationAnnotation) => {
if (citation.kind === "case") return;
openTab(
citation.document_id,
citation.filename,
@ -503,7 +503,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
openTab(args.documentId, args.filename, undefined, args.versionId);
};
const handleEditViewClick = (ann: MikeEditAnnotation, filename: string) => {
const handleEditViewClick = (ann: EditAnnotation, filename: string) => {
openTab(ann.document_id, filename, undefined, ann.version_id ?? null);
setEditScrollTarget({
key: `${ann.edit_id}-${Date.now()}`,
@ -753,77 +753,54 @@ export default function ProjectAssistantChatPage({ params }: Props) {
return (
<div className="flex flex-col h-full">
{/* Page header */}
<div className="flex items-center justify-between px-8 py-4 shrink-0">
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
<button
onClick={() => router.push("/projects")}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
Projects
</button>
<span className="text-gray-300"></span>
{project ? (
<button
onClick={() =>
router.push(`/projects/${projectId}`)
}
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>
)}
</button>
) : (
<div className="h-6 w-32 rounded bg-gray-100 animate-pulse" />
)}
<span className="text-gray-300"></span>
<button
onClick={() =>
router.push(`/projects/${projectId}?tab=assistant`)
}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
Assistant
</button>
<span className="text-gray-300"></span>
{chatLoaded ? (
<span className="text-gray-900 truncate max-w-xs">
{chatTitle ?? "Untitled New Chat"}
</span>
) : (
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleNewChat}
disabled={creatingChat}
title="New chat"
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors disabled:opacity-40"
>
{creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</button>
<button
onClick={handleDeleteChat}
disabled={deletingChat}
title="Delete chat"
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-red-600 transition-colors disabled:opacity-40"
>
{deletingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
</div>
<PageHeader
shrink
breadcrumbs={[
{
label: "Projects",
onClick: () => router.push("/projects"),
},
project
? {
label: project.name,
suffix: project.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null,
onClick: () => router.push(`/projects/${projectId}`),
title: "Back to project",
}
: {
loading: true,
skeletonClassName: "w-32",
onClick: () => router.push(`/projects/${projectId}`),
title: "Back to project",
},
chatLoaded
? {
label: chatTitle ?? "Untitled New Chat",
}
: {
loading: true,
skeletonClassName: "w-40",
},
]}
actions={[
{
type: "new",
onClick: handleNewChat,
loading: creatingChat,
title: "New chat",
},
{
type: "delete",
onClick: handleDeleteChat,
loading: deletingChat,
title: "Delete chat",
},
]}
/>
{/* Three-panel body */}
<div className="flex flex-1 min-h-0 border-t border-gray-200 overflow-hidden">
@ -1124,8 +1101,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
onDragOver={(e) => e.preventDefault()}
onDrop={handleChatDrop}
>
<div className="h-10 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
<MikeIcon size={16} />
<div className="h-10 flex items-center px-4 border-b border-gray-200 shrink-0">
<span className="text-xs text-gray-700">
Project Assistant
</span>
@ -1191,6 +1167,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
}
isError={!!(msg as any).error}
annotations={msg.annotations}
citationStatus={
msg.citationStatus
}
onCitationClick={
handleCitationClick
}

View file

@ -2,8 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Loader2, ChevronDown, Check, Table2 } from "lucide-react";
import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn";
import { ChevronDown, Check, Table2 } from "lucide-react";
import { RowActions } from "@/app/components/shared/RowActions";
import {
deleteTabularReview,
@ -12,16 +11,16 @@ import {
listProjects,
updateTabularReview,
} from "@/app/lib/mikeApi";
import type { TabularReview, MikeProject } from "@/app/components/shared/types";
import type { TabularReview, Project } from "@/app/components/shared/types";
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
type Tab = "all" | "in-project" | "standalone";
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" },
@ -39,7 +38,7 @@ function formatDate(iso: string) {
export default function TabularReviewsPage() {
const [reviews, setReviews] = useState<TabularReview[]>([]);
const [projects, setProjects] = useState<MikeProject[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newTROpen, setNewTROpen] = useState(false);
@ -56,6 +55,7 @@ export default function TabularReviewsPage() {
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { user } = useAuth();
const stickyCellBg = "bg-[#fcfcfd]";
useEffect(() => {
Promise.all([
@ -266,27 +266,28 @@ export default function TabularReviewsPage() {
);
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 reviews…",
},
{
type: "new",
onClick: () => setNewTROpen(true),
loading: creating,
title: "New tabular review",
},
]}
>
<h1 className="text-2xl font-medium font-serif text-gray-900">
Tabular Reviews
</h1>
<div className="flex items-center gap-2">
<HeaderSearchBtn value={search} onChange={setSearch} placeholder="Search reviews…" />
<button
onClick={() => setNewTROpen(true)}
disabled={creating}
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors disabled:opacity-40"
>
{creating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</button>
</div>
</div>
</PageHeader>
<ToolbarTabs
tabs={TABS}
@ -299,8 +300,10 @@ export default function TabularReviewsPage() {
<div className="w-full overflow-x-auto">
<div className="min-w-max">
<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}
@ -311,9 +314,7 @@ export default function TabularReviewsPage() {
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">Columns</div>
<div className="w-24 shrink-0">Documents</div>
@ -329,8 +330,8 @@ export default function TabularReviewsPage() {
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-24 shrink-0">
@ -383,7 +384,7 @@ export default function TabularReviewsPage() {
);
const rowBg = selectedIds.includes(review.id)
? "bg-gray-50"
: "bg-white";
: stickyCellBg;
return (
<div
key={review.id}
@ -395,57 +396,57 @@ export default function TabularReviewsPage() {
: `/tabular-reviews/${review.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(
review.id,
)}
onChange={() =>
toggleOne(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`}>
{renamingId === review.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(
review.id,
);
if (e.key === "Escape")
setRenamingId(null);
}}
onBlur={() =>
handleRenameSubmit(
review.id,
)
type="checkbox"
checked={selectedIds.includes(
review.id,
)}
onChange={() =>
toggleOne(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>
)}
{renamingId === review.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) =>
setRenameValue(
e.target.value,
)
}
onKeyDown={(e) => {
if (e.key === "Enter")
handleRenameSubmit(
review.id,
);
if (e.key === "Escape")
setRenamingId(null);
}}
onBlur={() =>
handleRenameSubmit(
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

@ -9,13 +9,14 @@ import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModa
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
import type { ColumnConfig, MikeWorkflow } from "@/app/components/shared/types";
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
import {
BUILT_IN_IDS,
BUILT_IN_WORKFLOWS,
} from "@/app/components/workflows/builtinWorkflows";
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
import { PageHeader } from "@/app/components/shared/PageHeader";
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
const WorkflowPromptEditor = dynamic(
() =>
@ -31,8 +32,7 @@ interface Props {
type SaveStatus = "idle" | "saving" | "saved";
const CHECK_W = "w-8 shrink-0";
const NAME_COL_W = "w-[300px] shrink-0";
const NAME_COL_W = "w-[332px] shrink-0";
// ---------------------------------------------------------------------------
// Page
@ -40,8 +40,9 @@ const NAME_COL_W = "w-[300px] shrink-0";
export default function WorkflowDetailPage({ params }: Props) {
const { id } = use(params);
const router = useRouter();
const stickyCellBg = "bg-[#fcfcfd]";
const [workflow, setWorkflow] = useState<MikeWorkflow | null>(null);
const [workflow, setWorkflow] = useState<Workflow | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
@ -191,13 +192,13 @@ export default function WorkflowDetailPage({ params }: Props) {
return (
<div className="flex flex-col h-full">
{/* Header skeleton */}
<div className="flex items-center justify-between px-8 py-4 shrink-0">
<div className="flex items-center gap-1.5">
<div className="h-6 w-24 rounded bg-gray-100 animate-pulse" />
<span className="text-gray-300"></span>
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<PageHeader
shrink
breadcrumbs={[
{ label: "Workflows" },
{ loading: true, skeletonClassName: "w-40" },
]}
/>
{/* Toolbar skeleton */}
<div className="flex items-center px-8 h-10 border-b border-gray-200 shrink-0">
@ -206,8 +207,8 @@ export default function WorkflowDetailPage({ params }: Props) {
{/* Table header skeleton */}
<div className="flex items-center h-8 pr-8 border-b border-gray-200 shrink-0">
<div className="w-8 shrink-0 border-r border-gray-100 self-stretch" />
<div className="flex-1 pl-3">
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<div className="h-2.5 w-20 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-36 shrink-0">
@ -223,8 +224,8 @@ export default function WorkflowDetailPage({ params }: Props) {
<div className="flex-1 overflow-hidden">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center h-10 pr-8 border-b border-gray-50">
<div className="w-8 shrink-0 border-r border-gray-100 self-stretch" />
<div className="flex-1 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 rounded bg-gray-100 animate-pulse" style={{ width: `${40 + (i * 13) % 35}%` }} />
</div>
<div className="w-36 shrink-0">
@ -252,52 +253,58 @@ export default function WorkflowDetailPage({ params }: Props) {
return (
<div className="flex flex-col h-full">
{/* Page header */}
<div className="flex items-center justify-between px-8 py-4 shrink-0">
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
<button
onClick={() => router.push("/workflows")}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
Workflows
</button>
<span className="text-gray-300"></span>
{readOnly ? (
<span className="text-gray-900 truncate max-w-xs">{workflow.title}</span>
) : (
<RenameableTitle value={workflow.title} onCommit={handleTitleCommit} />
)}
</div>
<div className="flex items-center gap-3">
{/* Save status */}
<span className="text-xs text-gray-400">
{saveStatus === "saving"
? "Saving…"
: saveStatus === "saved"
? "Saved"
: ""}
</span>
{/* Share button (custom workflows only) */}
{canShare && (
<button
onClick={() => setShareOpen(true)}
aria-label="Open workflow people"
title="People"
className="flex items-center text-gray-500 hover:text-gray-900 transition-colors"
>
<Users className="h-4 w-4" />
</button>
)}
{shareOpen && (
<ShareWorkflowModal
workflowId={id}
workflowName={workflow.title}
onClose={() => setShareOpen(false)}
/>
)}
</div>
</div>
<PageHeader
shrink
actionGap="md"
breadcrumbs={[
{
label: "Workflows",
onClick: () => router.push("/workflows"),
title: "Back to Workflows",
},
{
label: readOnly ? (
<span className="text-gray-900 truncate max-w-xs">
{workflow.title}
</span>
) : (
<RenameableTitle
value={workflow.title}
onCommit={handleTitleCommit}
/>
),
},
]}
actions={[
{
type: "custom",
render: (
<span className="text-xs text-gray-400">
{saveStatus === "saving"
? "Saving…"
: saveStatus === "saved"
? "Saved"
: ""}
</span>
),
},
canShare
? {
onClick: () => setShareOpen(true),
title: "Open workflow people",
iconOnly: true,
icon: <Users className="h-4 w-4" />,
}
: null,
]}
/>
{shareOpen && (
<ShareWorkflowModal
workflowId={id}
workflowName={workflow.title}
onClose={() => setShareOpen(false)}
/>
)}
{/* Read-only badge for built-in workflows */}
{readOnly && (
@ -366,7 +373,7 @@ export default function WorkflowDetailPage({ params }: Props) {
<div className="min-w-max flex min-h-full flex-col">
{/* Table header */}
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 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`}>
{columns.length > 0 && (
<input
type="checkbox"
@ -376,9 +383,7 @@ export default function WorkflowDetailPage({ params }: Props) {
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`}>
Column Title
<span>Column Title</span>
</div>
<div className="ml-auto w-36 shrink-0">Format</div>
<div className="flex-1 min-w-0">Prompt</div>
@ -413,23 +418,21 @@ export default function WorkflowDetailPage({ params }: Props) {
<div
key={col.index}
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
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 ${isChecked ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
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 ${isChecked ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`}>
<span className="text-sm text-gray-800 truncate block">
{col.name}
</span>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100`}>
<div className="flex min-w-0 items-center gap-4">
<input
type="checkbox"
checked={isChecked}
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
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">
{col.name}
</span>
</div>
</div>
<div className="ml-auto w-36 shrink-0">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">

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,

View file

@ -16,10 +16,10 @@ import {
listChats,
renameChat,
} from "@/app/lib/mikeApi";
import type { MikeChat, MikeMessage } from "@/app/components/shared/types";
import type { Chat, Message } from "@/app/components/shared/types";
interface ChatHistoryContextType {
chats: MikeChat[] | null;
chats: Chat[] | null;
hasMoreChats: boolean;
currentChatId: string | null;
setCurrentChatId: (chatId: string | null) => void;
@ -27,8 +27,8 @@ interface ChatHistoryContextType {
loadMoreChats: () => void;
saveChat: (projectId?: string) => Promise<string | null>;
renameChat: (chatId: string, title: string) => Promise<void>;
newChatMessages: MikeMessage[] | null;
setNewChatMessages: (messages: MikeMessage[] | null) => void;
newChatMessages: Message[] | null;
setNewChatMessages: (messages: Message[] | null) => void;
replaceChatId: (
oldChatId: string,
newChatId: string,
@ -46,13 +46,13 @@ const CHAT_LIMIT_INCREMENT = 10;
export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [chats, setChats] = useState<MikeChat[] | null>(null);
const [chats, setChats] = useState<Chat[] | null>(null);
const [chatLimit, setChatLimit] = useState(INITIAL_CHAT_LIMIT);
const [hasMoreChats, setHasMoreChats] = useState(false);
const [currentChatId, setCurrentChatId] = useState<string | null>(null);
const [newChatMessages, setNewChatMessages] = useState<
MikeMessage[] | null
>(null);
const [newChatMessages, setNewChatMessages] = useState<Message[] | null>(
null,
);
const loadChats = useCallback(async () => {
if (!user) {
@ -122,7 +122,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
projectId ? { project_id: projectId } : undefined,
);
const now = new Date().toISOString();
const newChat: MikeChat = {
const newChat: Chat = {
id,
project_id: projectId ?? null,
user_id: user?.id ?? "",

View file

@ -52,7 +52,7 @@
:root {
--radius: 0.625rem;
--color-azure: 0, 136, 255;
--background: oklch(1 0 0);
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
@ -442,6 +442,14 @@
color: inherit !important;
}
.case-opinion-content span.docx-text-highlight,
.case-opinion-content .docx-text-highlight {
background-color: rgba(96, 165, 250, 0.55) !important;
border-radius: 2px;
padding: 0 1px;
color: inherit !important;
}
/* docx-preview tracked-change styling */
.docx-view-container ins {
color: #16a34a;

File diff suppressed because it is too large Load diff

View file

@ -47,14 +47,6 @@ export function useFetchDocxBytes(
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
console.log("[useFetchDocxBytes] init", {
documentId,
versionId,
refetchKey,
initialKey,
cacheHit: initialKey ? bytesCache.has(initialKey) : null,
});
useEffect(() => {
if (!documentId) {
setBytes(null);

View file

@ -0,0 +1,27 @@
export const SUPPORTED_DOCUMENT_ACCEPT = ".pdf,.docx,.doc";
export const UNSUPPORTED_DOCUMENT_WARNING_MESSAGE =
"Unsupported file type. Only PDF, DOCX, and DOC files can be uploaded.";
const SUPPORTED_DOCUMENT_EXTENSIONS = new Set(["pdf", "docx", "doc"]);
export function isSupportedDocumentFile(file: File): boolean {
const extension = file.name.split(".").pop()?.toLowerCase();
return !!extension && SUPPORTED_DOCUMENT_EXTENSIONS.has(extension);
}
export function partitionSupportedDocumentFiles(files: File[]) {
const supported: File[] = [];
const unsupported: File[] = [];
for (const file of files) {
if (isSupportedDocumentFile(file)) supported.push(file);
else unsupported.push(file);
}
return { supported, unsupported };
}
export function formatUnsupportedDocumentWarning(files: File[]): string | null {
if (files.length === 0) return null;
return UNSUPPORTED_DOCUMENT_WARNING_MESSAGE;
}

View file

@ -6,14 +6,14 @@
import { supabase } from "@/lib/supabase";
import type {
AssistantEvent,
MikeChat,
MikeChatDetailOut,
MikeCitationAnnotation,
MikeDocument,
MikeFolder,
MikeMessage,
MikeProject,
MikeWorkflow,
Chat,
ChatDetailOut,
CitationAnnotation,
Document,
Folder,
Message,
Project,
Workflow,
TabularReview,
TabularReviewDetailOut,
} from "@/app/components/shared/types";
@ -26,11 +26,11 @@ interface ServerMessage {
content: string | AssistantEvent[] | null;
files?: { filename: string; document_id?: string }[] | null;
workflow?: { id: string; title: string } | null;
annotations?: MikeCitationAnnotation[] | null;
annotations?: CitationAnnotation[] | null;
created_at: string;
}
interface ServerChatDetailOut {
chat: MikeChat;
chat: Chat;
messages: ServerMessage[];
}
@ -77,16 +77,16 @@ async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
// Projects
// ---------------------------------------------------------------------------
export async function listProjects(): Promise<MikeProject[]> {
return apiRequest<MikeProject[]>("/projects");
export async function listProjects(): Promise<Project[]> {
return apiRequest<Project[]>("/projects");
}
export async function createProject(
name: string,
cm_number?: string,
shared_with?: string[],
): Promise<MikeProject> {
return apiRequest<MikeProject>("/projects", {
): Promise<Project> {
return apiRequest<Project>("/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, cm_number, shared_with }),
@ -104,6 +104,7 @@ export interface UserProfile {
creditsResetDate: string;
creditsRemaining: number;
tier: string;
titleModel: string;
tabularModel: string;
apiKeyStatus: ApiKeyStatus;
}
@ -115,6 +116,7 @@ export async function getUserProfile(): Promise<UserProfile> {
export async function updateUserProfile(payload: {
displayName?: string | null;
organisation?: string | null;
titleModel?: string;
tabularModel?: string;
}): Promise<UserProfile> {
return apiRequest<UserProfile>("/user/profile", {
@ -124,7 +126,12 @@ export async function updateUserProfile(payload: {
});
}
export type ApiKeyProvider = "claude" | "gemini" | "openai";
export type ApiKeyProvider =
| "claude"
| "gemini"
| "openai"
| "openrouter"
| "courtlistener";
export type ApiKeySource = "user" | "env" | null;
export type ApiKeyState = Record<
ApiKeyProvider,
@ -153,8 +160,8 @@ export async function saveApiKey(
});
}
export async function getProject(projectId: string): Promise<MikeProject> {
return apiRequest<MikeProject>(`/projects/${projectId}`);
export async function getProject(projectId: string): Promise<Project> {
return apiRequest<Project>(`/projects/${projectId}`);
}
export async function updateProject(
@ -164,8 +171,8 @@ export async function updateProject(
cm_number?: string;
shared_with?: string[];
},
): Promise<MikeProject> {
return apiRequest<MikeProject>(`/projects/${projectId}`, {
): Promise<Project> {
return apiRequest<Project>(`/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@ -203,8 +210,8 @@ export async function createProjectFolder(
projectId: string,
name: string,
parentFolderId?: string | null,
): Promise<MikeFolder> {
return apiRequest<MikeFolder>(`/projects/${projectId}/folders`, {
): Promise<Folder> {
return apiRequest<Folder>(`/projects/${projectId}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -218,8 +225,8 @@ export async function renameProjectFolder(
projectId: string,
folderId: string,
name: string,
): Promise<MikeFolder> {
return apiRequest<MikeFolder>(
): Promise<Folder> {
return apiRequest<Folder>(
`/projects/${projectId}/folders/${folderId}`,
{
method: "PATCH",
@ -242,8 +249,8 @@ export async function moveSubfolderToFolder(
projectId: string,
folderId: string,
parentFolderId: string | null,
): Promise<MikeFolder> {
return apiRequest<MikeFolder>(
): Promise<Folder> {
return apiRequest<Folder>(
`/projects/${projectId}/folders/${folderId}`,
{
method: "PATCH",
@ -257,8 +264,8 @@ export async function moveDocumentToFolder(
projectId: string,
documentId: string,
folderId: string | null,
): Promise<MikeDocument> {
return apiRequest<MikeDocument>(
): Promise<Document> {
return apiRequest<Document>(
`/projects/${projectId}/documents/${documentId}/folder`,
{
method: "PATCH",
@ -272,8 +279,8 @@ export async function renameProjectDocument(
projectId: string,
documentId: string,
filename: string,
): Promise<MikeDocument> {
return apiRequest<MikeDocument>(
): Promise<Document> {
return apiRequest<Document>(
`/projects/${projectId}/documents/${documentId}`,
{
method: "PATCH",
@ -286,24 +293,27 @@ export async function renameProjectDocument(
export async function addDocumentToProject(
projectId: string,
documentId: string,
): Promise<MikeDocument> {
return apiRequest<MikeDocument>(
): Promise<Document> {
return apiRequest<Document>(
`/projects/${projectId}/documents/${documentId}`,
{ method: "POST" },
);
}
export interface MikeDocumentVersion {
export interface DocumentVersion {
id: string;
version_number: number | null;
source: string;
created_at: string;
display_name: string | null;
filename: string | null;
file_type?: string | null;
size_bytes?: number | null;
page_count?: number | null;
}
export async function listDocumentVersions(documentId: string): Promise<{
current_version_id: string | null;
versions: MikeDocumentVersion[];
versions: DocumentVersion[];
}> {
return apiRequest(`/single-documents/${documentId}/versions`);
}
@ -311,12 +321,12 @@ export async function listDocumentVersions(documentId: string): Promise<{
export async function uploadDocumentVersion(
documentId: string,
file: File,
displayName?: string,
): Promise<MikeDocumentVersion> {
filename?: string,
): Promise<DocumentVersion> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
if (displayName) form.append("display_name", displayName);
if (filename) form.append("filename", filename);
const response = await fetch(
`${API_BASE}/single-documents/${documentId}/versions`,
{
@ -326,28 +336,58 @@ export async function uploadDocumentVersion(
},
);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<MikeDocumentVersion>;
return response.json() as Promise<DocumentVersion>;
}
export async function copyDocumentVersionFromDocument(
documentId: string,
sourceDocumentId: string,
filename?: string,
): Promise<DocumentVersion> {
return apiRequest<DocumentVersion>(
`/single-documents/${documentId}/versions/from-document`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_document_id: sourceDocumentId,
filename,
}),
},
);
}
export async function renameDocumentVersion(
documentId: string,
versionId: string,
displayName: string | null,
): Promise<MikeDocumentVersion> {
return apiRequest<MikeDocumentVersion>(
filename: string | null,
): Promise<DocumentVersion> {
return apiRequest<DocumentVersion>(
`/single-documents/${documentId}/versions/${versionId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name: displayName }),
body: JSON.stringify({ filename }),
},
);
}
export async function deleteDocumentVersion(
documentId: string,
versionId: string,
): Promise<{
deleted_version_id: string;
current_version_id: string | null;
}> {
return apiRequest(`/single-documents/${documentId}/versions/${versionId}`, {
method: "DELETE",
});
}
export async function uploadProjectDocument(
projectId: string,
file: File,
): Promise<MikeDocument> {
): Promise<Document> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
@ -360,12 +400,12 @@ export async function uploadProjectDocument(
},
);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<MikeDocument>;
return response.json() as Promise<Document>;
}
export async function uploadStandaloneDocument(
file: File,
): Promise<MikeDocument> {
): Promise<Document> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
@ -375,11 +415,11 @@ export async function uploadStandaloneDocument(
body: form,
});
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<MikeDocument>;
return response.json() as Promise<Document>;
}
export async function listStandaloneDocuments(): Promise<MikeDocument[]> {
return apiRequest<MikeDocument[]>("/single-documents");
export async function listStandaloneDocuments(): Promise<Document[]> {
return apiRequest<Document[]>("/single-documents");
}
export async function deleteDocument(documentId: string): Promise<void> {
@ -428,20 +468,20 @@ export async function createChat(payload?: {
});
}
export async function listChats(options?: { limit?: number }): Promise<MikeChat[]> {
export async function listChats(options?: { limit?: number }): Promise<Chat[]> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
const query = params.toString();
return apiRequest<MikeChat[]>(`/chat${query ? `?${query}` : ""}`);
return apiRequest<Chat[]>(`/chat${query ? `?${query}` : ""}`);
}
export async function listProjectChats(projectId: string): Promise<MikeChat[]> {
return apiRequest<MikeChat[]>(`/projects/${projectId}/chats`);
export async function listProjectChats(projectId: string): Promise<Chat[]> {
return apiRequest<Chat[]>(`/projects/${projectId}/chats`);
}
export async function getChat(chatId: string): Promise<MikeChatDetailOut> {
export async function getChat(chatId: string): Promise<ChatDetailOut> {
const raw = await apiRequest<ServerChatDetailOut>(`/chat/${chatId}`);
const messages: MikeMessage[] = raw.messages.map((m) => {
const messages: Message[] = raw.messages.map((m) => {
if (m.role === "user") {
return {
role: "user",
@ -490,6 +530,32 @@ export async function generateChatTitle(
});
}
export type CaseLawOpinion = {
opinionId: number | null;
apiUrl?: string | null;
type: string | null;
author: string | null;
url: string | null;
text?: string | null;
html?: string | null;
};
export async function getCourtlistenerOpinions(
clusterId: number,
): Promise<CaseLawOpinion[]> {
const result = await apiRequest<{ opinions: CaseLawOpinion[] }>(
"/case-law/case-opinions",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clusterId,
}),
},
);
return result.opinions;
}
export async function streamChat(payload: {
messages: {
role: string;
@ -627,7 +693,7 @@ export async function uploadReviewDocument(
documentIds?: string[];
columnsConfig?: { index: number; name: string; prompt: string }[];
},
): Promise<MikeDocument> {
): Promise<Document> {
const uploaded = options?.projectId
? await uploadProjectDocument(options.projectId, file)
: await uploadStandaloneDocument(file);
@ -789,16 +855,16 @@ export async function clearTabularCells(
// Workflows
// ---------------------------------------------------------------------------
type WorkflowType = MikeWorkflow["type"];
type WorkflowType = Workflow["type"];
export async function listWorkflows(
type: WorkflowType,
): Promise<MikeWorkflow[]> {
return apiRequest<MikeWorkflow[]>(`/workflows?type=${type}`);
): Promise<Workflow[]> {
return apiRequest<Workflow[]>(`/workflows?type=${type}`);
}
export async function getWorkflow(workflowId: string): Promise<MikeWorkflow> {
return apiRequest<MikeWorkflow>(`/workflows/${workflowId}`);
export async function getWorkflow(workflowId: string): Promise<Workflow> {
return apiRequest<Workflow>(`/workflows/${workflowId}`);
}
export async function createWorkflow(payload: {
@ -807,8 +873,8 @@ export async function createWorkflow(payload: {
prompt_md?: string;
columns_config?: { index: number; name: string; prompt: string }[];
practice?: string | null;
}): Promise<MikeWorkflow> {
return apiRequest<MikeWorkflow>("/workflows", {
}): Promise<Workflow> {
return apiRequest<Workflow>("/workflows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@ -823,8 +889,8 @@ export async function updateWorkflow(
columns_config?: { index: number; name: string; prompt: string }[];
practice?: string | null;
},
): Promise<MikeWorkflow> {
return apiRequest<MikeWorkflow>(`/workflows/${workflowId}`, {
): Promise<Workflow> {
return apiRequest<Workflow>(`/workflows/${workflowId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),

View file

@ -1,10 +1,10 @@
import { MODELS, type ModelOption } from "../components/assistant/ModelToggle";
import { SETTINGS_MODELS, type ModelOption } from "../components/assistant/ModelToggle";
import type { ApiKeyState } from "@/app/lib/mikeApi";
export type ModelProvider = "claude" | "gemini" | "openai";
export function getModelProvider(modelId: string): ModelProvider | null {
const model = MODELS.find((m) => m.id === modelId);
const model = SETTINGS_MODELS.find((m) => m.id === modelId);
if (!model) return null;
return modelGroupToProvider(model.group);
}

View file

@ -25,6 +25,7 @@ interface UserProfile {
creditsResetDate: string;
creditsRemaining: number;
tier: string;
titleModel: string;
tabularModel: string;
apiKeys: ApiKeyState;
}
@ -35,7 +36,7 @@ interface UserProfileContextType {
updateDisplayName: (name: string) => Promise<boolean>;
updateOrganisation: (organisation: string) => Promise<boolean>;
updateModelPreference: (
field: "tabularModel",
field: "titleModel" | "tabularModel",
value: string,
) => Promise<boolean>;
updateApiKey: (
@ -50,13 +51,21 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>(
undefined,
);
const API_KEY_PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"];
const API_KEY_PROVIDERS: ApiKeyProvider[] = [
"claude",
"gemini",
"openai",
"openrouter",
"courtlistener",
];
function emptyApiKeys(): ApiKeyState {
return {
claude: { configured: false, source: null },
gemini: { configured: false, source: null },
openai: { configured: false, source: null },
openrouter: { configured: false, source: null },
courtlistener: { configured: false, source: null },
};
}
@ -100,6 +109,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
creditsResetDate: futureResetDate.toISOString(),
creditsRemaining: 999999, // temporarily unlimited
tier: "Free",
titleModel: "gemini-3.1-flash-lite-preview",
tabularModel: "gemini-3-flash-preview",
apiKeys: emptyApiKeys(),
});
@ -154,12 +164,14 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
);
const updateModelPreference = useCallback(
async (field: "tabularModel", value: string): Promise<boolean> => {
async (
field: "titleModel" | "tabularModel",
value: string,
): Promise<boolean> => {
if (!user) return false;
if (field !== "tabularModel") return false;
try {
const updated = await updateUserProfile({
tabularModel: value,
[field]: value,
});
setProfile((prev) =>
prev ? { ...prev, ...toProfile(updated) } : null,

View file

@ -43,7 +43,6 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
return null;
}
console.log(`[Auth] User authenticated: ${user.email}`);
return {
email: user.email,
id: user.id
@ -53,4 +52,3 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
return null;
}
}

View file

@ -1,132 +0,0 @@
/**
* Cloudflare R2 storage utilities for Mike document management.
* R2 is S3-compatible uses @aws-sdk/client-s3.
*
* Required env vars:
* R2_ENDPOINT_URL https://<account-id>.r2.cloudflarestorage.com
* R2_ACCESS_KEY_ID R2 API token (Access Key ID)
* R2_SECRET_ACCESS_KEY R2 API token (Secret Access Key)
* R2_BUCKET_NAME bucket name (default: "mike")
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
function getClient(): S3Client {
return new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT_URL!,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
const BUCKET = process.env.R2_BUCKET_NAME ?? "mike";
export const storageEnabled = Boolean(
process.env.R2_ENDPOINT_URL &&
process.env.R2_ACCESS_KEY_ID &&
process.env.R2_SECRET_ACCESS_KEY,
);
// ---------------------------------------------------------------------------
// Upload
// ---------------------------------------------------------------------------
export async function uploadFile(
key: string,
content: ArrayBuffer,
contentType: string,
): Promise<void> {
const client = getClient();
await client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: Buffer.from(content),
ContentType: contentType,
}),
);
}
// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------
export async function downloadFile(key: string): Promise<ArrayBuffer | null> {
if (!storageEnabled) return null;
try {
const client = getClient();
const response = await client.send(
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
);
if (!response.Body) return null;
const bytes = await response.Body.transformToByteArray();
return bytes.buffer as ArrayBuffer;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
export async function deleteFile(key: string): Promise<void> {
if (!storageEnabled) return;
const client = getClient();
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}
// ---------------------------------------------------------------------------
// Signed URL (pre-signed for temporary direct access)
// ---------------------------------------------------------------------------
export async function getSignedUrl(
key: string,
expiresIn = 3600,
): Promise<string | null> {
if (!storageEnabled) return null;
try {
const client = getClient();
const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
return await awsGetSignedUrl(client, command, { expiresIn });
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Storage key helpers
// ---------------------------------------------------------------------------
export function storageKey(
userId: string,
docId: string,
filename: string,
): string {
return `documents/${userId}/${docId}/${filename}`;
}
export function pdfStorageKey(
userId: string,
docId: string,
stem: string,
): string {
return `documents/${userId}/${docId}/${stem}.pdf`;
}
export function generatedDocKey(
userId: string,
docId: string,
filename: string,
): string {
return `generated/${userId}/${docId}/${filename}`;
}