refactor: add table primitive and migrations by date; feat: add mcp connectors

This commit is contained in:
willchen96 2026-06-15 17:34:58 +08:00
parent 01dfcfe0d4
commit 9a1277ba99
99 changed files with 9344 additions and 2320 deletions

View file

@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import { accountGlassSectionClassName } from "./accountStyles";
export function AccountSection({
children,
className,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode;
}) {
return (
<div className={cn(accountGlassSectionClassName, className)} {...props}>
{children}
</div>
);
}

View file

@ -0,0 +1,86 @@
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
type AccountToggleSize = "sm" | "md";
const sizeClasses: Record<
AccountToggleSize,
{
track: string;
thumb: string;
translate: string;
}
> = {
sm: {
track: "h-4 w-7 p-0.5",
thumb: "h-3 w-3",
translate: "translate-x-3",
},
md: {
track: "h-5 w-9 p-0.5",
thumb: "h-4 w-4",
translate: "translate-x-4",
},
};
export function AccountToggle({
checked,
disabled,
loading,
onChange,
size = "sm",
label,
className,
}: {
checked: boolean;
disabled?: boolean;
loading?: boolean;
onChange: (checked: boolean) => void;
size?: AccountToggleSize;
label?: string;
className?: string;
}) {
const sizes = sizeClasses[size];
const button = (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled || loading}
onClick={() => onChange(!checked)}
className={cn(
"flex shrink-0 items-center rounded-full transition-colors",
checked ? "bg-emerald-600" : "bg-gray-200",
"disabled:cursor-not-allowed disabled:opacity-40",
sizes.track,
)}
>
<span
className={cn(
"flex items-center justify-center rounded-full bg-white shadow-sm transition-transform",
sizes.thumb,
checked ? sizes.translate : "translate-x-0",
)}
>
{loading && (
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400" />
)}
</span>
</button>
);
if (!label) return button;
return (
<label
className={cn(
"inline-flex shrink-0 items-center gap-1.5 text-xs font-medium",
checked ? "text-emerald-700" : "text-gray-500",
className,
)}
>
<span>{label}</span>
{button}
</label>
);
}

View file

@ -2,13 +2,13 @@ import { cn } from "@/lib/utils";
export const accountGlassInputClassName = cn(
"rounded-lg px-3 text-gray-900 placeholder:text-gray-400",
"border border-transparent bg-gray-100 shadow-none",
"border border-gray-200 bg-gray-50 shadow-none",
"focus-visible:border-gray-200 focus-visible:ring-2 focus-visible:ring-gray-300/45",
"disabled:cursor-not-allowed disabled:text-gray-700 disabled:opacity-100 disabled:placeholder:text-gray-600",
);
export const accountGlassSectionClassName =
"overflow-hidden rounded-xl bg-white";
"overflow-hidden rounded-xl border border-white/70 bg-white/55 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";
export const accountGlassButtonClassName = cn(
"rounded-lg border border-transparent bg-transparent px-3 text-gray-700 shadow-none transition-colors hover:bg-gray-100 hover:text-gray-950 active:bg-gray-200",

View file

@ -12,8 +12,8 @@ import { isMfaRequiredError } from "@/app/lib/mikeApi";
import {
accountGlassIconButtonClassName,
accountGlassInputClassName,
accountGlassSectionClassName,
} from "../accountStyles";
import { AccountSection } from "../AccountSection";
const MODEL_API_KEY_FIELDS = [
{
@ -61,7 +61,7 @@ export default function ApiKeysPage() {
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={accountGlassSectionClassName}>
<AccountSection>
{MODEL_API_KEY_FIELDS.map((field, index) => (
<div key={field.provider}>
<ApiKeyField
@ -87,9 +87,9 @@ export default function ApiKeysPage() {
)}
</div>
))}
</div>
</AccountSection>
<div className={`mt-8 ${accountGlassSectionClassName}`}>
<AccountSection className="mt-8">
{OTHER_API_KEY_FIELDS.map((field) => (
<ApiKeyField
key={field.provider}
@ -108,7 +108,7 @@ export default function ApiKeysPage() {
onRemove={() => updateApiKey(field.provider, null)}
/>
))}
</div>
</AccountSection>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { accountGlassSectionClassName } from "../accountStyles";
import { AccountSection } from "../AccountSection";
export default function FeaturesPage() {
const { profile, updateLegalResearchUs } = useUserProfile();
@ -52,7 +52,7 @@ export default function FeaturesPage() {
Legal Research
</h2>
</div>
<div className={accountGlassSectionClassName}>
<AccountSection>
<div className="px-4 py-5">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
@ -113,7 +113,7 @@ export default function FeaturesPage() {
</button>
</div>
</div>
</div>
</AccountSection>
</section>
</div>
);

View file

@ -23,6 +23,7 @@ const TABS: TabDef[] = [
{ id: "security", label: "Security", href: "/account/security" },
{ id: "models", label: "Model Preferences", href: "/account/models" },
{ id: "api-keys", label: "API Keys", href: "/account/api-keys" },
{ id: "connectors", label: "Connectors", href: "/account/connectors" },
];
export default function AccountLayout({

View file

@ -24,8 +24,8 @@ import {
} from "@/app/lib/modelAvailability";
import {
accountGlassInputClassName,
accountGlassSectionClassName,
} from "../accountStyles";
import { AccountSection } from "../AccountSection";
type ModelPreferenceField = "titleModel" | "tabularModel";
@ -79,7 +79,7 @@ export default function ModelPreferencesPage() {
Model Preferences
</h2>
</div>
<div className={accountGlassSectionClassName}>
<AccountSection>
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
Title generation model
@ -122,7 +122,7 @@ export default function ModelPreferencesPage() {
onChange={(id) => handleModelChange("tabularModel", id)}
/>
</div>
</div>
</AccountSection>
</div>
);
}

View file

@ -18,8 +18,8 @@ import {
accountGlassDangerOutlineButtonClassName,
accountGlassInputClassName,
accountGlassPrimaryButtonClassName,
accountGlassSectionClassName,
} from "./accountStyles";
import { AccountSection } from "./AccountSection";
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
@ -173,7 +173,7 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Profile
</h2>
<div className={`${accountGlassSectionClassName} p-4`}>
<AccountSection className="p-4">
<div className="divide-y divide-gray-200">
<div className="pb-4">
<label className="text-sm text-gray-600 block mb-2">
@ -249,7 +249,7 @@ export default function AccountPage() {
</div>
</div>
</div>
</div>
</AccountSection>
</section>
{/* Email */}
@ -257,7 +257,7 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Email
</h2>
<div className={`${accountGlassSectionClassName} p-4`}>
<AccountSection className="p-4">
<div className="space-y-2">
<Input
type="email"
@ -308,7 +308,7 @@ export default function AccountPage() {
</button>
</div>
</div>
</div>
</AccountSection>
</section>
{/* Plan */}
@ -316,13 +316,13 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Usage Plan
</h2>
<div className={`${accountGlassSectionClassName} p-4`}>
<AccountSection className="p-4">
<div>
<p className="text-base font-medium text-gray-500 capitalize">
{profile?.tier || "Free"}
</p>
</div>
</div>
</AccountSection>
</section>
{/* Actions */}
@ -345,9 +345,7 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-red-600">
Danger Zone
</h2>
<div
className={`${accountGlassSectionClassName} flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between`}
>
<AccountSection className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Delete account
@ -366,7 +364,7 @@ export default function AccountPage() {
<Trash2 className="h-4 w-4 shrink-0" />
Delete account
</Button>
</div>
</AccountSection>
</section>
<ConfirmPopup
open={deleteConfirm}

View file

@ -21,8 +21,8 @@ import {
import {
accountGlassDangerOutlineButtonClassName,
accountGlassPrimaryButtonClassName,
accountGlassSectionClassName,
} from "../accountStyles";
import { AccountSection } from "../AccountSection";
type DeleteDataAction = "chats" | "tabular-reviews" | "projects";
type ExportDataAction = "export-chats" | "export-tabular-reviews" | "export-account";
@ -221,7 +221,7 @@ export default function PrivacyDataPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Export data
</h2>
<div className={accountGlassSectionClassName}>
<AccountSection>
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
@ -294,14 +294,14 @@ export default function PrivacyDataPage() {
{isExportingAccount ? "Exporting..." : "Export"}
</Button>
</div>
</div>
</AccountSection>
</section>
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Delete data
</h2>
<div className={accountGlassSectionClassName}>
<AccountSection>
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
@ -368,7 +368,7 @@ export default function PrivacyDataPage() {
Delete
</Button>
</div>
</div>
</AccountSection>
</section>
<ConfirmPopup
open={!!pendingDeleteAction}

View file

@ -19,8 +19,9 @@ import {
} from "@/app/components/shared/MfaVerificationPopup";
import {
accountGlassPrimaryButtonClassName,
accountGlassSectionClassName,
} from "../accountStyles";
import { AccountSection } from "../AccountSection";
import { AccountToggle } from "../AccountToggle";
type MfaFactor = {
id: string;
@ -148,20 +149,18 @@ function VerificationCodeInput({
function MfaSettingsSkeleton() {
return (
<div className="px-4 py-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="space-y-1">
<div className="flex items-start justify-between gap-3">
<div className="h-4 w-36 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-72 max-w-full animate-pulse rounded bg-gray-100" />
<div className="h-3 w-14 shrink-0 animate-pulse rounded bg-gray-100" />
</div>
<div className="space-y-1.5 pt-1">
<div className="h-3 w-full max-w-md animate-pulse rounded bg-gray-100" />
<div className="h-3 w-3/4 max-w-sm animate-pulse rounded bg-gray-100" />
</div>
<div className="h-8 w-20 animate-pulse rounded-lg bg-gray-100" />
</div>
<div className="my-5 h-px bg-gray-100" />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-64 max-w-full animate-pulse rounded bg-gray-100" />
</div>
<div className="h-7 w-12 animate-pulse rounded-full bg-gray-100" />
<div className="mt-3 flex justify-end">
<div className="h-9 w-20 animate-pulse rounded-lg bg-gray-100" />
</div>
</div>
);
@ -469,7 +468,7 @@ export default function SecurityPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Multi-Factor Authentication
</h2>
<div className={accountGlassSectionClassName}>
<AccountSection>
{loading ? (
<MfaSettingsSkeleton />
) : (
@ -537,28 +536,15 @@ export default function SecurityPage() {
only before sensitive actions.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={loginMfaEnabled}
onClick={() =>
<AccountToggle
checked={loginMfaEnabled}
disabled={savingLoginPreference}
loading={savingLoginPreference}
size="md"
onChange={() =>
void handleLoginPreferenceToggle()
}
disabled={savingLoginPreference}
className={`flex h-7 w-12 shrink-0 items-center rounded-full px-1 transition-colors ${
loginMfaEnabled
? "bg-gray-950"
: "bg-gray-200"
} disabled:cursor-not-allowed disabled:opacity-45`}
>
<span
className={`h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
loginMfaEnabled
? "translate-x-5"
: "translate-x-0"
}`}
/>
</button>
/>
</div>
<div className="flex justify-end px-4 pb-4 pt-1">
<button
@ -587,7 +573,7 @@ export default function SecurityPage() {
</p>
</>
)}
</div>
</AccountSection>
</section>
<Modal
open={setupModalOpen}

View file

@ -124,7 +124,7 @@ export default function MikeLayout({
className="ml-auto flex min-w-0 flex-1 items-center justify-end"
/>
</div>
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
<main className="flex h-full w-full flex-1 flex-col overflow-y-auto md:overflow-hidden">
{children}
</main>
</div>

View file

@ -339,7 +339,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
setChatOwnerId(chat.user_id ?? null);
if (loaded.length > 0) setMessages(loaded);
})
.catch(() => router.replace(`/projects/${projectId}?tab=assistant`))
.catch(() => router.replace(`/projects/${projectId}/assistant`))
.finally(() => setChatLoaded(true));
}, [chatId]); // eslint-disable-line react-hooks/exhaustive-deps
@ -589,7 +589,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
setDeletingChat(true);
try {
await deleteChat(chatId);
router.push(`/projects/${projectId}?tab=assistant`);
router.push(`/projects/${projectId}/assistant`);
} finally {
setDeletingChat(false);
}
@ -783,14 +783,14 @@ export default function ProjectAssistantChatPage({ params }: Props) {
? {
label: project.name,
onClick: () =>
router.push(`/projects/${projectId}?tab=assistant`),
router.push(`/projects/${projectId}/assistant`),
title: "Back to project",
}
: {
loading: true,
skeletonClassName: "w-32",
onClick: () =>
router.push(`/projects/${projectId}?tab=assistant`),
router.push(`/projects/${projectId}/assistant`),
title: "Back to project",
},
chatLoaded

View file

@ -1,13 +1,168 @@
"use client";
import { use } from "react";
import { ProjectPage } from "@/app/components/projects/ProjectPage";
import { use, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { deleteChat, renameChat } from "@/app/lib/mikeApi";
import { ProjectAssistantTable } from "@/app/components/projects/ProjectAssistantTable";
import {
ProjectSectionToolbar,
useProjectWorkspace,
} from "@/app/components/projects/ProjectWorkspace";
import type { Chat } from "@/app/components/shared/types";
import { useAuth } from "@/contexts/AuthContext";
interface Props {
params: Promise<{ id: string }>;
}
export default function ProjectAssistantPage({ params }: Props) {
const { id } = use(params);
return <ProjectPage projectId={id} initialTab="assistant" />;
function SelectedChatActions({
selectedCount,
open,
onOpenChange,
onDelete,
}: {
selectedCount: number;
open: boolean;
onOpenChange: (open: boolean) => void;
onDelete: () => void;
}) {
if (selectedCount === 0) return null;
return (
<div className="relative">
<button
onClick={() => onOpenChange(!open)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 transition-colors hover:text-gray-900"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute right-0 top-full z-[120] mt-1 w-36 overflow-hidden rounded-lg border border-white/60 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_12px_32px_rgba(15,23,42,0.14)] backdrop-blur-xl">
<button
onClick={onDelete}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 transition-colors hover:bg-red-50"
>
Delete
</button>
</div>
)}
</div>
);
}
export default function ProjectAssistantPage({ params }: Props) {
use(params);
const workspace = useProjectWorkspace();
const router = useRouter();
const { user } = useAuth();
const {
ensureProjectChats,
projectChats,
projectId,
search,
setProjectChats,
setOwnerOnlyAction,
} = workspace;
const [selectedChatIds, setSelectedChatIds] = useState<string[]>([]);
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
const [renameChatValue, setRenameChatValue] = useState("");
const [actionsOpen, setActionsOpen] = useState(false);
const chats = useMemo(() => projectChats ?? [], [projectChats]);
const loading = projectChats === null;
useEffect(() => {
void ensureProjectChats();
}, [ensureProjectChats]);
const q = search.toLowerCase();
const filteredChats = q
? chats.filter((c) => (c.title ?? "").toLowerCase().includes(q))
: chats;
const allChatsSelected =
filteredChats.length > 0 &&
filteredChats.every((c) => selectedChatIds.includes(c.id));
const someChatsSelected =
!allChatsSelected &&
filteredChats.some((c) => selectedChatIds.includes(c.id));
async function submitChatRename(chatId: string) {
const trimmed = renameChatValue.trim();
setRenamingChatId(null);
if (!trimmed) return;
await renameChat(chatId, trimmed);
setProjectChats((prev) =>
(prev ?? []).map((chat) =>
chat.id === chatId ? { ...chat, title: trimmed } : chat,
),
);
}
async function handleDeleteChatRow(chat: Chat) {
if (user?.id && chat.user_id !== user.id) {
setOwnerOnlyAction("delete this chat");
return;
}
await deleteChat(chat.id);
setProjectChats((prev) => (prev ?? []).filter((c) => c.id !== chat.id));
}
const handleDeleteSelectedChats = useCallback(async () => {
const ids = [...selectedChatIds];
setActionsOpen(false);
const owned = ids.filter((id) => {
const chat = chats.find((c) => c.id === id);
return !chat || chat.user_id === user?.id;
});
const blocked = ids.length - owned.length;
setSelectedChatIds([]);
await Promise.all(owned.map((id) => deleteChat(id).catch(() => {})));
setProjectChats((prev) =>
(prev ?? []).filter((chat) => !owned.includes(chat.id)),
);
if (blocked > 0) {
setOwnerOnlyAction(
`delete ${blocked} of the selected chats - only the chat creator can delete a chat`,
);
}
}, [chats, selectedChatIds, setOwnerOnlyAction, setProjectChats, user?.id]);
return (
<>
<ProjectSectionToolbar
actions={
<SelectedChatActions
selectedCount={selectedChatIds.length}
open={actionsOpen}
onOpenChange={setActionsOpen}
onDelete={() => void handleDeleteSelectedChats()}
/>
}
/>
<ProjectAssistantTable
chats={chats}
filteredChats={filteredChats}
selectedChatIds={selectedChatIds}
allChatsSelected={allChatsSelected}
someChatsSelected={someChatsSelected}
renamingChatId={renamingChatId}
renameChatValue={renameChatValue}
currentUserId={user?.id}
loading={loading}
onCreateChat={() => void workspace.createChat()}
onOpenChat={(chatId) =>
router.push(
`/projects/${projectId}/assistant/chat/${chatId}`,
)
}
onDeleteChat={handleDeleteChatRow}
onOwnerOnlyAction={setOwnerOnlyAction}
submitChatRename={submitChatRename}
setSelectedChatIds={setSelectedChatIds}
setRenamingChatId={setRenamingChatId}
setRenameChatValue={setRenameChatValue}
/>
</>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { ProjectWorkspaceLayout } from "@/app/components/projects/ProjectWorkspace";
export default function ProjectLayout({
params,
children,
}: {
params: Promise<{ id: string }>;
children: ReactNode;
}) {
return (
<ProjectWorkspaceLayout params={params}>{children}</ProjectWorkspaceLayout>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { use } from "react";
import { ProjectPage } from "@/app/components/projects/ProjectPage";
import { ProjectDocumentsView } from "@/app/components/projects/ProjectDocumentsView";
interface Props {
params: Promise<{ id: string }>;
@ -9,5 +9,5 @@ interface Props {
export default function ProjectDetailPage({ params }: Props) {
const { id } = use(params);
return <ProjectPage projectId={id} />;
return <ProjectDocumentsView projectId={id} />;
}

View file

@ -1,13 +1,187 @@
"use client";
import { use } from "react";
import { ProjectPage } from "@/app/components/projects/ProjectPage";
import { use, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import {
deleteTabularReview,
updateTabularReview,
} from "@/app/lib/mikeApi";
import { ProjectReviewsTable } from "@/app/components/projects/ProjectReviewsTable";
import {
ProjectSectionToolbar,
useProjectWorkspace,
} from "@/app/components/projects/ProjectWorkspace";
import type { TabularReview } from "@/app/components/shared/types";
import { useAuth } from "@/contexts/AuthContext";
interface Props {
params: Promise<{ id: string }>;
}
export default function ProjectTabularReviewsPage({ params }: Props) {
const { id } = use(params);
return <ProjectPage projectId={id} initialTab="reviews" />;
function SelectedReviewActions({
selectedCount,
open,
onOpenChange,
onDelete,
}: {
selectedCount: number;
open: boolean;
onOpenChange: (open: boolean) => void;
onDelete: () => void;
}) {
if (selectedCount === 0) return null;
return (
<div className="relative">
<button
onClick={() => onOpenChange(!open)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 transition-colors hover:text-gray-900"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute right-0 top-full z-[120] mt-1 w-36 overflow-hidden rounded-lg border border-white/60 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_12px_32px_rgba(15,23,42,0.14)] backdrop-blur-xl">
<button
onClick={onDelete}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 transition-colors hover:bg-red-50"
>
Delete
</button>
</div>
)}
</div>
);
}
export default function ProjectTabularReviewsPage({ params }: Props) {
use(params);
const workspace = useProjectWorkspace();
const router = useRouter();
const { user } = useAuth();
const {
ensureProjectReviews,
project,
projectId,
projectReviews,
search,
setOwnerOnlyAction,
setProjectReviews,
} = workspace;
const [selectedReviewIds, setSelectedReviewIds] = useState<string[]>([]);
const [renamingReviewId, setRenamingReviewId] = useState<string | null>(
null,
);
const [renameReviewValue, setRenameReviewValue] = useState("");
const [actionsOpen, setActionsOpen] = useState(false);
const docs = project?.documents ?? [];
const reviews = useMemo(() => projectReviews ?? [], [projectReviews]);
const loading = projectReviews === null;
useEffect(() => {
void ensureProjectReviews();
}, [ensureProjectReviews]);
const q = search.toLowerCase();
const filteredReviews = q
? reviews.filter((r) => (r.title ?? "").toLowerCase().includes(q))
: reviews;
const allReviewsSelected =
filteredReviews.length > 0 &&
filteredReviews.every((r) => selectedReviewIds.includes(r.id));
const someReviewsSelected =
!allReviewsSelected &&
filteredReviews.some((r) => selectedReviewIds.includes(r.id));
async function submitReviewRename(reviewId: string) {
const trimmed = renameReviewValue.trim();
setRenamingReviewId(null);
if (!trimmed) return;
await updateTabularReview(reviewId, { title: trimmed });
setProjectReviews((prev) =>
(prev ?? []).map((review) =>
review.id === reviewId ? { ...review, title: trimmed } : review,
),
);
}
async function handleDeleteReviewRow(review: TabularReview) {
if (user?.id && review.user_id !== user.id) {
setOwnerOnlyAction("delete this tabular review");
return;
}
await deleteTabularReview(review.id);
setProjectReviews((prev) =>
(prev ?? []).filter((r) => r.id !== review.id),
);
}
const handleDeleteSelectedReviews = useCallback(async () => {
const ids = [...selectedReviewIds];
setActionsOpen(false);
const owned = ids.filter((id) => {
const review = reviews.find((r) => r.id === id);
return !review || review.user_id === user?.id;
});
const blocked = ids.length - owned.length;
setSelectedReviewIds([]);
await Promise.all(
owned.map((id) => deleteTabularReview(id).catch(() => {})),
);
setProjectReviews((prev) =>
(prev ?? []).filter((review) => !owned.includes(review.id)),
);
if (blocked > 0) {
setOwnerOnlyAction(
`delete ${blocked} of the selected reviews - only the review creator can delete a review`,
);
}
}, [
reviews,
selectedReviewIds,
setOwnerOnlyAction,
setProjectReviews,
user?.id,
]);
return (
<>
<ProjectSectionToolbar
actions={
<SelectedReviewActions
selectedCount={selectedReviewIds.length}
open={actionsOpen}
onOpenChange={setActionsOpen}
onDelete={() => void handleDeleteSelectedReviews()}
/>
}
/>
<ProjectReviewsTable
docs={docs}
reviews={reviews}
filteredReviews={filteredReviews}
selectedReviewIds={selectedReviewIds}
allReviewsSelected={allReviewsSelected}
someReviewsSelected={someReviewsSelected}
renamingReviewId={renamingReviewId}
renameReviewValue={renameReviewValue}
creatingReview={workspace.creatingReview}
currentUserId={user?.id}
loading={loading}
onCreateReview={workspace.openNewReview}
onOpenReview={(reviewId) =>
router.push(
`/projects/${projectId}/tabular-reviews/${reviewId}`,
)
}
onDeleteReview={handleDeleteReviewRow}
onOwnerOnlyAction={setOwnerOnlyAction}
submitReviewRename={submitReviewRename}
setSelectedReviewIds={setSelectedReviewIds}
setRenamingReviewId={setRenamingReviewId}
setRenameReviewValue={setRenameReviewValue}
/>
</>
);
}

View file

@ -2,8 +2,11 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown, Check, Table2 } from "lucide-react";
import { RowActions } from "@/app/components/shared/RowActions";
import { ChevronDown, Table2 } from "lucide-react";
import {
RowActionMenuItems,
RowActions,
} from "@/app/components/shared/RowActions";
import {
deleteTabularReview,
listTabularReviews,
@ -12,17 +15,34 @@ import {
updateTabularReview,
} from "@/app/lib/mikeApi";
import type { TabularReview, Project } from "@/app/components/shared/types";
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
import { TableToolbar } from "@/app/components/shared/TableToolbar";
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";
import {
GLASS_DROPDOWN,
HeaderFilterDropdown,
} from "@/app/components/shared/HeaderFilterDropdown";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
TableCell,
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
type Tab = "all" | "in-project" | "standalone";
type ReviewScope = "all" | "in-project" | "standalone";
const NAME_COL_W = "w-[332px] shrink-0";
const TABS: { id: Tab; label: string }[] = [
const REVIEW_SCOPES: { id: ReviewScope; label: string }[] = [
{ id: "all", label: "All" },
{ id: "in-project", label: "In Project" },
{ id: "standalone", label: "Standalone" },
@ -42,20 +62,17 @@ export default function TabularReviewsPage() {
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newTROpen, setNewTROpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>("all");
const [activeScope, setActiveScope] = useState<ReviewScope>("all");
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [projectFilter, setProjectFilter] = useState<string | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const filterRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { user } = useAuth();
const stickyCellBg = "bg-[#fafbfc]";
useEffect(() => {
Promise.all([
@ -71,15 +88,7 @@ export default function TabularReviewsPage() {
useEffect(() => {
setSelectedIds([]);
}, [activeTab, projectFilter]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (filterRef.current && !filterRef.current.contains(e.target as Node)) setFilterOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
}, [activeScope, projectFilter]);
useEffect(() => {
function handleClick(e: MouseEvent) {
@ -97,8 +106,8 @@ export default function TabularReviewsPage() {
const q = search.toLowerCase();
const filtered = reviews
.filter((r) => {
if (activeTab === "in-project") return !!r.project_id;
if (activeTab === "standalone") return !r.project_id;
if (activeScope === "in-project") return !!r.project_id;
if (activeScope === "standalone") return !r.project_id;
return true;
})
.filter((r) => !projectFilter || r.project_id === projectFilter)
@ -121,8 +130,6 @@ export default function TabularReviewsPage() {
);
}
const selectedProject = projects.find((p) => p.id === projectFilter);
const handleNewReview = async (
title: string,
projectId?: string,
@ -189,84 +196,43 @@ export default function TabularReviewsPage() {
}
const projectFilterButton = (
<div className="relative" ref={filterRef}>
<button
onClick={() => setFilterOpen((o) => !o)}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
projectFilter
? "text-gray-700 hover:text-gray-900"
: "text-gray-500 hover:text-gray-700"
}`}
>
{selectedProject ? selectedProject.name : "Filter by project"}
<ChevronDown className="h-3 w-3" />
</button>
{filterOpen && (
<div className="absolute right-0 top-full mt-1.5 z-20 w-52 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden">
<button
onClick={() => {
setProjectFilter(null);
setFilterOpen(false);
}}
className="flex items-center justify-between w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
All Projects
{!projectFilter && (
<Check className="h-3.5 w-3.5 text-gray-400" />
)}
</button>
{projects.length > 0 && (
<div className="border-t border-gray-100" />
)}
{projects.map((p) => (
<button
key={p.id}
onClick={() => {
setProjectFilter(p.id);
setFilterOpen(false);
}}
className="flex items-center justify-between w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<span className="truncate pr-2">{p.name}</span>
{projectFilter === p.id && (
<Check className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
</button>
))}
</div>
)}
</div>
<HeaderFilterDropdown
label="Filter by project"
value={projectFilter}
allLabel="All Projects"
options={projects.map((project) => ({
value: project.id,
label: project.name,
}))}
onChange={setProjectFilter}
/>
);
const toolbarActions = (
<>
{selectedIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<button
onClick={handleDeleteSelected}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
</div>
)}
{projectFilterButton}
</>
);
const toolbarActions =
selectedIds.length > 0 ? (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className={`absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden ${GLASS_DROPDOWN}`}>
<button
onClick={handleDeleteSelected}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 transition-colors hover:bg-red-500/10"
>
Delete
</button>
</div>
)}
</div>
) : undefined;
return (
<div className="flex-1 overflow-y-auto">
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{/* Page header */}
<PageHeader
loading={loading}
@ -290,20 +256,19 @@ export default function TabularReviewsPage() {
</h1>
</PageHeader>
<ToolbarTabs
tabs={TABS}
active={activeTab}
onChange={setActiveTab}
<TableToolbar
items={REVIEW_SCOPES}
active={activeScope}
onChange={setActiveScope}
actions={toolbarActions}
/>
{/* Table */}
<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] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
<TableScrollArea>
<TableHeaderRow>
<TableStickyCell header>
{loading ? (
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<SkeletonDot />
) : (
<input
type="checkbox"
@ -312,48 +277,58 @@ export default function TabularReviewsPage() {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
</div>
<div className="ml-auto w-24 shrink-0">Columns</div>
<div className="w-24 shrink-0">Documents</div>
<div className="w-40 shrink-0">Project</div>
<div className="w-32 shrink-0">Created</div>
<div className="w-8 shrink-0" />
</div>
</TableStickyCell>
<TableHeaderCell className="ml-auto w-24">
Columns
</TableHeaderCell>
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
<TableHeaderCell className="w-40">
<div className="flex items-center gap-1">
<span>Project</span>
{projectFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
{loading ? (
<div>
<TableBody>
{[1, 2, 3].map((i) => (
<div
<TableRow
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
interactive={false}
>
<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">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-40 shrink-0">
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
<TableStickyCell
hover={false}
bgClassName="bg-transparent"
>
<SkeletonDot />
<SkeletonLine className="h-3.5 w-48" />
</TableStickyCell>
<TableCell className="ml-auto w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-40">
<SkeletonLine className="w-24" />
</TableCell>
<TableCell className="w-32">
<SkeletonLine className="w-20" />
</TableCell>
<TableCell className="w-8" />
</TableRow>
))}
</div>
</TableBody>
) : filtered.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
{activeTab === "all" && !projectFilter ? (
<TableEmptyState>
{activeScope === "all" && !projectFilter ? (
<>
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
@ -376,19 +351,60 @@ export default function TabularReviewsPage() {
No reviews found
</p>
)}
</div>
</TableEmptyState>
) : (
<div>
<TableBody>
{filtered.map((review) => {
const project = projects.find(
(p) => p.id === review.project_id,
);
const rowBg = selectedIds.includes(review.id)
? "bg-gray-50"
: stickyCellBg;
: TABLE_STICKY_CELL_BG;
return (
<div
<TableRow
key={review.id}
rightClickDropdown={(close) => (
<RowActionMenuItems
onClose={close}
onRename={() => {
if (
user?.id &&
review.user_id !== user.id
) {
setOwnerOnlyAction(
"rename this tabular review",
);
return;
}
setRenameValue(
review.title ??
"Untitled Review",
);
setRenamingId(review.id);
}}
onDelete={async () => {
if (
user?.id &&
review.user_id !== user.id
) {
setOwnerOnlyAction(
"delete this tabular review",
);
return;
}
await deleteTabularReview(
review.id,
);
setReviews((prev) =>
prev.filter(
(r) =>
r.id !== review.id,
),
);
}}
/>
)}
onClick={() => {
if (renamingId === review.id) return;
router.push(
@ -397,65 +413,33 @@ 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-100 cursor-pointer transition-colors"
>
<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
type="checkbox"
checked={selectedIds.includes(
review.id,
)}
onChange={() =>
toggleOne(review.id)
}
onClick={(e) =>
e.stopPropagation()
}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{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">
<TablePrimaryCell
bgClassName={rowBg}
selected={selectedIds.includes(
review.id,
)}
onSelectionChange={() =>
toggleOne(review.id)
}
label={
review.title ?? "Untitled Review"
}
editing={renamingId === review.id}
editValue={renameValue}
onEditValueChange={setRenameValue}
onEditCommit={() =>
handleRenameSubmit(review.id)
}
onEditCancel={() => setRenamingId(null)}
/>
<TableCell className="ml-auto w-24">
{review.columns_config?.length ?? 0}
</div>
<div className="w-24 shrink-0 text-sm text-gray-500 truncate">
</TableCell>
<TableCell className="w-24">
{review.document_count ?? 0}
</div>
<div className="w-40 shrink-0 text-sm text-gray-500 truncate pr-2">
</TableCell>
<TableCell className="w-40 pr-2">
{project ? (
project.name
) : (
@ -463,8 +447,8 @@ export default function TabularReviewsPage() {
</span>
)}
</div>
<div className="w-32 shrink-0 text-sm text-gray-500 truncate">
</TableCell>
<TableCell className="w-32">
{review.created_at ? (
formatDate(review.created_at)
) : (
@ -472,7 +456,7 @@ export default function TabularReviewsPage() {
</span>
)}
</div>
</TableCell>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
@ -516,13 +500,12 @@ export default function TabularReviewsPage() {
}}
/>
</div>
</div>
</TableRow>
);
})}
</div>
</TableBody>
)}
</div>
</div>
</TableScrollArea>
<AddNewTRModal
open={newTROpen}

View file

@ -49,6 +49,7 @@ function toolCallLabel(name: string): string {
if (name === "courtlistener_read_case") return "Reading case...";
if (name === "courtlistener_verify_citations")
return "Verifying citations...";
if (name.startsWith("mcp_")) return "Using connector...";
return name ? `Running ${name}...` : "Working...";
}
@ -1933,6 +1934,41 @@ export function AssistantMessage({
</div>
);
}
if (event.type === "mcp_tool_call") {
const isError = event.status === "error";
const label = event.connector_name
? `${event.connector_name}: ${event.tool_name}`
: toolCallLabel(event.openai_tool_name);
return (
<div
key={globalIdx}
className="flex items-start text-sm font-serif text-gray-500 relative"
>
{showConnector && (
<div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" />
)}
<div
className={
event.isStreaming
? "mt-[7px] h-1.5 w-1.5 shrink-0 animate-spin rounded-full border border-gray-400 border-t-transparent"
: isError
? "mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-red-500"
: "mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400"
}
/>
<div className="ml-2 min-w-0">
<span className="font-medium">
{event.isStreaming ? "Using connector..." : label}
</span>
{isError && event.error && (
<p className="mt-0.5 text-xs text-red-600">
{event.error}
</p>
)}
</div>
</div>
);
}
if (event.type === "doc_read") {
const ann = annotations.find(
(a) => a.kind !== "case" && a.filename === event.filename,

View file

@ -1,166 +0,0 @@
"use client";
import { type Dispatch, type SetStateAction } from "react";
import { MessageSquare } from "lucide-react";
import { RowActions } from "@/app/components/shared/RowActions";
import type { Chat } from "@/app/components/shared/types";
import { formatDate, NAME_COL_W } from "./ProjectPageParts";
export function ProjectAssistantTab({
chats,
filteredChats,
selectedChatIds,
allChatsSelected,
someChatsSelected,
renamingChatId,
renameChatValue,
currentUserId,
onCreateChat,
onOpenChat,
onDeleteChat,
onOwnerOnlyAction,
submitChatRename,
setSelectedChatIds,
setRenamingChatId,
setRenameChatValue,
}: {
chats: Chat[];
filteredChats: Chat[];
selectedChatIds: string[];
allChatsSelected: boolean;
someChatsSelected: boolean;
renamingChatId: string | null;
renameChatValue: string;
currentUserId?: string | null;
onCreateChat: () => void;
onOpenChat: (chatId: string) => 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-[#fafbfc]";
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] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
<input
type="checkbox"
checked={allChatsSelected}
ref={(el) => {
if (el) el.indeterminate = someChatsSelected;
}}
onChange={() => {
if (allChatsSelected) setSelectedChatIds([]);
else setSelectedChatIds(filteredChats.map((c) => c.id));
}}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
<span>Chats</span>
</div>
<div className="ml-auto w-32 shrink-0 text-left">Created</div>
<div className="w-8 shrink-0" />
</div>
{chats.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
<MessageSquare className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Assistant
</p>
<p className="mt-1 text-xs text-gray-400 max-w-xs">
Ask questions and get answers grounded in the documents
in this project.
</p>
<button
onClick={onCreateChat}
className="mt-4 inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 transition-colors shadow-md"
>
+ Create New
</button>
</div>
) : (
<div>
{filteredChats.map((chat) => (
<div
key={chat.id}
onClick={() => {
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-100 cursor-pointer transition-colors"
>
<div
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`}
>
<div className="flex min-w-0 items-center gap-4">
<input
type="checkbox"
checked={selectedChatIds.includes(chat.id)}
onChange={() =>
setSelectedChatIds((prev) =>
prev.includes(chat.id)
? prev.filter((x) => x !== chat.id)
: [...prev, chat.id],
)
}
onClick={(e) => e.stopPropagation()}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{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)}
</div>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={() => {
if (
currentUserId &&
chat.user_id !== currentUserId
) {
onOwnerOnlyAction("rename this chat");
return;
}
setRenameChatValue(
chat.title ?? "Untitled Chat",
);
setRenamingChatId(chat.id);
}}
onDelete={() => onDeleteChat(chat)}
/>
</div>
</div>
))}
</div>
)}
</>
);
}

View file

@ -0,0 +1,235 @@
"use client";
import { type Dispatch, type SetStateAction } from "react";
import { MessageSquare } from "lucide-react";
import {
RowActionMenuItems,
RowActions,
} from "@/app/components/shared/RowActions";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
TableCell,
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
import type { Chat } from "@/app/components/shared/types";
import { formatDate } from "./ProjectPageParts";
function creatorLabel(chat: Chat, currentUserId?: string | null) {
if (currentUserId && chat.user_id === currentUserId) return "Me";
return chat.creator_display_name?.trim() || "Shared";
}
export function ProjectAssistantTable({
chats,
filteredChats,
selectedChatIds,
allChatsSelected,
someChatsSelected,
renamingChatId,
renameChatValue,
currentUserId,
onCreateChat,
onOpenChat,
onDeleteChat,
onOwnerOnlyAction,
submitChatRename,
setSelectedChatIds,
setRenamingChatId,
setRenameChatValue,
loading = false,
}: {
chats: Chat[];
filteredChats: Chat[];
selectedChatIds: string[];
allChatsSelected: boolean;
someChatsSelected: boolean;
renamingChatId: string | null;
renameChatValue: string;
currentUserId?: string | null;
onCreateChat: () => void;
onOpenChat: (chatId: string) => 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>>;
loading?: boolean;
}) {
return (
<TableScrollArea>
<TableHeaderRow className="pr-8 md:pr-8">
<TableStickyCell header>
{loading ? (
<SkeletonDot />
) : (
<input
type="checkbox"
checked={allChatsSelected}
ref={(el) => {
if (el) el.indeterminate = someChatsSelected;
}}
onChange={() => {
if (allChatsSelected) setSelectedChatIds([]);
else
setSelectedChatIds(
filteredChats.map((c) => c.id),
);
}}
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Chats</span>
</TableStickyCell>
<TableHeaderCell className="ml-auto w-32">Creator</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
{loading ? (
<ProjectAssistantLoadingRows />
) : chats.length === 0 ? (
<TableEmptyState>
<MessageSquare className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Assistant
</p>
<p className="mt-1 text-xs text-gray-400 max-w-xs">
Ask questions and get answers grounded in the documents
in this project.
</p>
<button
onClick={onCreateChat}
className="mt-4 inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 transition-colors shadow-md"
>
+ Create New
</button>
</TableEmptyState>
) : (
<TableBody>
{filteredChats.map((chat) => (
<TableRow
key={chat.id}
rightClickDropdown={(close) => (
<RowActionMenuItems
onClose={close}
onRename={() => {
if (
currentUserId &&
chat.user_id !== currentUserId
) {
onOwnerOnlyAction("rename this chat");
return;
}
setRenameChatValue(
chat.title ?? "Untitled Chat",
);
setRenamingChatId(chat.id);
}}
onDelete={() => onDeleteChat(chat)}
/>
)}
onClick={() => {
if (renamingChatId === chat.id) return;
onOpenChat(chat.id);
}}
className="pr-8 md:pr-8"
>
<TablePrimaryCell
bgClassName={
selectedChatIds.includes(chat.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG
}
selected={selectedChatIds.includes(chat.id)}
onSelectionChange={() =>
setSelectedChatIds((prev) =>
prev.includes(chat.id)
? prev.filter((x) => x !== chat.id)
: [...prev, chat.id],
)
}
label={chat.title ?? "Untitled Chat"}
editing={renamingChatId === chat.id}
editValue={renameChatValue}
onEditValueChange={setRenameChatValue}
onEditCommit={() =>
void submitChatRename(chat.id)
}
onEditCancel={() => setRenamingChatId(null)}
/>
<TableCell className="ml-auto w-32">
{creatorLabel(chat, currentUserId)}
</TableCell>
<TableCell className="w-32">
{formatDate(chat.created_at)}
</TableCell>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={() => {
if (
currentUserId &&
chat.user_id !== currentUserId
) {
onOwnerOnlyAction("rename this chat");
return;
}
setRenameChatValue(
chat.title ?? "Untitled Chat",
);
setRenamingChatId(chat.id);
}}
onDelete={() => onDeleteChat(chat)}
/>
</div>
</TableRow>
))}
</TableBody>
)}
</TableScrollArea>
);
}
function ProjectAssistantLoadingRows() {
const titleWidths = ["w-36", "w-40", "w-44", "w-48", "w-52"];
return (
<TableBody>
{[1, 2, 3, 4, 5].map((i) => (
<TableRow
key={i}
interactive={false}
className="pr-8 md:pr-8"
>
<TableStickyCell hover={false}>
<div className="flex min-w-0 items-center gap-4">
<SkeletonDot />
<SkeletonLine
className={`h-3.5 ${titleWidths[i - 1]}`}
/>
</div>
</TableStickyCell>
<TableCell className="ml-auto w-32">
<SkeletonLine className="w-16" />
</TableCell>
<TableCell className="w-32">
<SkeletonLine className="w-16" />
</TableCell>
<TableCell className="w-8" />
</TableRow>
))}
</TableBody>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import { type DragEvent, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
Upload,
Loader2,
@ -13,17 +12,8 @@ import {
FolderPlus,
} from "lucide-react";
import {
getProject,
deleteProject,
deleteDocument,
createTabularReview,
updateProject,
listProjectChats,
deleteChat,
renameChat,
listTabularReviews,
deleteTabularReview,
updateTabularReview,
getProject,
getDocumentUrl,
downloadDocumentsZip,
createProjectFolder,
@ -39,18 +29,12 @@ import {
deleteDocumentVersion,
uploadProjectDocument,
renameDocumentVersion,
getProjectPeople,
type DocumentVersion,
} from "@/app/lib/mikeApi";
import type {
Document,
Folder as ProjectFolder,
Project,
Chat,
TabularReview,
ColumnConfig,
} from "@/app/components/shared/types";
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
import {
closeRowActionMenus,
RowActionMenuItems,
@ -60,14 +44,9 @@ import {
AddDocumentsModal,
invalidateDirectoryCache,
} from "@/app/components/shared/AddDocumentsModal";
import { PeopleModal } from "@/app/components/shared/PeopleModal";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
import { WarningPopup } from "@/app/components/shared/WarningPopup";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import {
formatUnsupportedDocumentWarning,
partitionSupportedDocumentFiles,
@ -78,19 +57,14 @@ import {
DocVersionHistory,
formatBytes,
formatDate,
ProjectPageHeader,
treeNameCellStyle,
type ProjectContextMenu,
type ProjectTab,
} from "./ProjectPageParts";
import { DocumentSidePanel } from "./DocumentSidePanel";
import { ProjectDetailsModal } from "./ProjectDetailsModal";
import { ProjectAssistantTab } from "./ProjectAssistantTab";
import { ProjectReviewsTab } from "./ProjectReviewsTab";
import { ProjectSectionToolbar, useProjectWorkspace } from "./ProjectWorkspace";
interface Props {
projectId: string;
initialTab?: ProjectTab;
}
function apiErrorDetail(error: unknown): string | null {
@ -112,102 +86,10 @@ function apiErrorDetail(error: unknown): string | null {
}
function ProjectTableLoading({
tab,
stickyCellBg,
}: {
tab: ProjectTab;
stickyCellBg: string;
}) {
if (tab === "assistant") {
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Chats</span>
</div>
<div className="ml-auto w-32 shrink-0 text-left">
Created
</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${44 + i * 7}px` }}
/>
</div>
</div>
<div className="ml-auto w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</>
);
}
if (tab === "reviews") {
return (
<>
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<span>Name</span>
</div>
<div className="ml-auto w-24 shrink-0 text-left">
Columns
</div>
<div className="w-24 shrink-0 text-left">Documents</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-8 shrink-0" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div
className="h-3.5 rounded bg-gray-100 animate-pulse"
style={{ width: `${180 + i * 18}px` }}
/>
</div>
</div>
<div className="ml-auto w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
))}
</>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
@ -262,38 +144,28 @@ function ProjectTableLoading({
);
}
export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [project, setProject] = useState<Project | null>(null);
const [folders, setFolders] = useState<ProjectFolder[]>([]);
const [chats, setChats] = useState<Chat[]>([]);
const [projectReviews, setProjectReviews] = useState<TabularReview[]>([]);
const [loading, setLoading] = useState(true);
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const tab: ProjectTab =
tabParam === "assistant" || tabParam === "reviews"
? tabParam
: initialTab;
export function ProjectDocumentsView({ projectId }: Props) {
const workspace = useProjectWorkspace();
const project = workspace.project;
const setProject = workspace.setProject;
const folders = workspace.folders;
const setFolders = workspace.setFolders;
const loading = workspace.projectLoading;
const prefetchProjectSections = workspace.prefetchProjectSections;
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [projectDetailsOpen, setProjectDetailsOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const setOwnerOnlyAction = workspace.setOwnerOnlyAction;
const { user } = useAuth();
const { profile } = useUserProfile();
const stickyCellBg = "bg-[#fafbfc]";
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
const [viewingDocVersion, setViewingDocVersion] = useState<{
id: string;
label: string;
} | null>(null);
const [creatingChat, setCreatingChat] = useState(false);
const [creatingReview, setCreatingReview] = useState(false);
const [newTRModalOpen, setNewTRModalOpen] = useState(false);
// Per-tab selection
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [selectedChatIds, setSelectedChatIds] = useState<string[]>([]);
const [selectedReviewIds, setSelectedReviewIds] = useState<string[]>([]);
useEffect(() => {
if (!loading) prefetchProjectSections();
}, [loading, prefetchProjectSections]);
// Version-history expansion (per-doc). versionsByDocId caches fetched
// versions so toggling closed + open again doesn't refetch. loadingIds
@ -512,13 +384,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}
}
// Inline rename for chats and reviews
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
const [renameChatValue, setRenameChatValue] = useState("");
const [renamingReviewId, setRenamingReviewId] = useState<string | null>(
null,
);
const [renameReviewValue, setRenameReviewValue] = useState("");
const [renamingDocumentId, setRenamingDocumentId] = useState<string | null>(
null,
);
@ -590,51 +455,21 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const [pendingDeleteFolderStatus, setPendingDeleteFolderStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
useState(false);
const [deleteProjectStatus, setDeleteProjectStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
// Actions dropdown
const [actionsOpen, setActionsOpen] = useState(false);
const actionsRef = useRef<HTMLDivElement>(null);
const [search, setSearch] = useState("");
const router = useRouter();
const { saveChat } = useChatHistoryContext();
function handleTabChange(newTab: ProjectTab) {
const base = `/projects/${projectId}`;
const url = newTab === "documents" ? base : `${base}?tab=${newTab}`;
router.push(url);
}
const search = workspace.search;
useEffect(() => {
Promise.all([
getProject(projectId),
listProjectChats(projectId).catch(() => [] as Chat[]),
listTabularReviews(projectId).catch(() => []),
])
.then(([proj, projectChats, projectReviews]) => {
setProject(proj);
const loadedFolders = proj.folders ?? [];
setFolders(loadedFolders);
setExpandedFolderIds(new Set(loadedFolders.map((f) => f.id)));
setChats(projectChats);
setProjectReviews(projectReviews);
})
.finally(() => setLoading(false));
}, [projectId]);
if (loading) return;
setExpandedFolderIds(new Set(folders.map((f) => f.id)));
}, [loading, folders]);
// Reset selection and close dropdowns when tab changes
useEffect(() => {
setSelectedDocIds([]);
setSelectedChatIds([]);
setSelectedReviewIds([]);
setActionsOpen(false);
setContextMenu(null);
}, [tab]);
}, [projectId]);
useEffect(() => {
function handleClick(e: MouseEvent) {
@ -1098,126 +933,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}
}
async function handleNewChat() {
setCreatingChat(true);
try {
const id = await saveChat(projectId);
if (id) router.push(`/projects/${projectId}/assistant/chat/${id}`);
} finally {
setCreatingChat(false);
}
}
function handleNewReview() {
const docs =
project?.documents?.filter((d) => d.status === "ready") || [];
if (docs.length === 0) return;
setNewTRModalOpen(true);
}
async function handleCreateReview(
title: string,
_projectId?: string,
documentIds?: string[],
columnsConfig?: ColumnConfig[] | null,
) {
setCreatingReview(true);
try {
const docs =
project?.documents?.filter((d) => d.status === "ready") || [];
const review = await createTabularReview({
title: title || undefined,
document_ids: documentIds ?? docs.map((d) => d.id),
columns_config: columnsConfig ?? [],
project_id: projectId,
});
router.push(`/projects/${projectId}/tabular-reviews/${review.id}`);
} finally {
setCreatingReview(false);
}
}
async function handleProjectDetailsSave(values: {
name: string;
cmNumber: string;
}) {
if (project && project.is_owner === false) {
setOwnerOnlyAction("edit project details");
return;
}
const name = values.name.trim();
const cmNumber = values.cmNumber.trim();
if (!name) return;
const updated = await updateProject(projectId, {
name,
cm_number: cmNumber,
});
setProject((prev) =>
prev
? {
...prev,
name: updated.name,
cm_number: updated.cm_number,
updated_at: updated.updated_at,
}
: prev,
);
}
function requestProjectDelete() {
if (project?.is_owner === false) {
setOwnerOnlyAction("delete this project");
return;
}
setDeleteProjectStatus("idle");
setDeleteProjectConfirmOpen(true);
}
async function confirmProjectDelete() {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectStatus("deleting");
try {
await deleteProject(projectId);
setDeleteProjectStatus("deleted");
setTimeout(() => {
router.push("/projects");
}, 250);
} catch (err) {
setDeleteProjectStatus("idle");
console.error("Failed to delete project", err);
}
}
async function submitChatRename(chatId: string) {
const trimmed = renameChatValue.trim();
setRenamingChatId(null);
if (!trimmed) return;
const chat = chats.find((c) => c.id === chatId);
if (chat && user?.id && chat.user_id !== user.id) {
setOwnerOnlyAction("rename this chat");
return;
}
setChats((prev) =>
prev.map((c) => (c.id === chatId ? { ...c, title: trimmed } : c)),
);
await renameChat(chatId, trimmed);
}
async function submitReviewRename(reviewId: string) {
const trimmed = renameReviewValue.trim();
setRenamingReviewId(null);
if (!trimmed) return;
const review = projectReviews.find((r) => r.id === reviewId);
if (review && user?.id && review.user_id !== user.id) {
setOwnerOnlyAction("rename this tabular review");
return;
}
setProjectReviews((prev) =>
prev.map((r) => (r.id === reviewId ? { ...r, title: trimmed } : r)),
);
await updateTabularReview(reviewId, { title: trimmed });
}
async function downloadDoc(docId: string) {
const { url, filename } = await getDocumentUrl(docId);
const a = document.createElement("a");
@ -1316,62 +1031,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}
}
async function handleDeleteSelectedChats() {
const ids = [...selectedChatIds];
setActionsOpen(false);
const owned = ids.filter((id) => {
const c = chats.find((cc) => cc.id === id);
return !c || !user?.id || c.user_id === user.id;
});
const blocked = ids.length - owned.length;
setSelectedChatIds([]);
await Promise.all(owned.map((id) => deleteChat(id).catch(() => {})));
setChats((prev) => prev.filter((c) => !owned.includes(c.id)));
if (blocked > 0) {
setOwnerOnlyAction(
`delete ${blocked} of the selected chats — only the chat creator can delete a chat`,
);
}
}
async function handleDeleteSelectedReviews() {
const ids = [...selectedReviewIds];
setActionsOpen(false);
const owned = ids.filter((id) => {
const r = projectReviews.find((rr) => rr.id === id);
return !r || !user?.id || r.user_id === user.id;
});
const blocked = ids.length - owned.length;
setSelectedReviewIds([]);
await Promise.all(
owned.map((id) => deleteTabularReview(id).catch(() => {})),
);
setProjectReviews((prev) => prev.filter((r) => !owned.includes(r.id)));
if (blocked > 0) {
setOwnerOnlyAction(
`delete ${blocked} of the selected reviews — only the review creator can delete a review`,
);
}
}
async function handleDeleteChatRow(chat: Chat) {
if (user?.id && chat.user_id !== user.id) {
setOwnerOnlyAction("delete this chat");
return;
}
await deleteChat(chat.id);
setChats((prev) => prev.filter((c) => c.id !== chat.id));
}
async function handleDeleteReviewRow(review: TabularReview) {
if (user?.id && review.user_id !== user.id) {
setOwnerOnlyAction("delete this tabular review");
return;
}
await deleteTabularReview(review.id);
setProjectReviews((prev) => prev.filter((r) => r.id !== review.id));
}
// ── Drag & drop ───────────────────────────────────────────────────────────
function wouldCreateCycle(movingId: string, targetId: string): boolean {
@ -2239,14 +1898,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const filteredDocs = q
? docs.filter((d) => d.filename.toLowerCase().includes(q))
: docs;
const filteredChats = q
? chats.filter((c) => (c.title ?? "").toLowerCase().includes(q))
: chats;
const filteredReviews = q
? projectReviews.filter((r) =>
(r.title ?? "").toLowerCase().includes(q),
)
: projectReviews;
const allDocsSelected =
filteredDocs.length > 0 &&
@ -2254,35 +1905,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const someDocsSelected =
!allDocsSelected &&
filteredDocs.some((d) => selectedDocIds.includes(d.id));
const allChatsSelected =
filteredChats.length > 0 &&
filteredChats.every((c) => selectedChatIds.includes(c.id));
const someChatsSelected =
!allChatsSelected &&
filteredChats.some((c) => selectedChatIds.includes(c.id));
const allReviewsSelected =
filteredReviews.length > 0 &&
filteredReviews.every((r) => selectedReviewIds.includes(r.id));
const someReviewsSelected =
!allReviewsSelected &&
filteredReviews.some((r) => selectedReviewIds.includes(r.id));
const currentSelectionCount =
tab === "documents"
? selectedDocIds.length
: tab === "assistant"
? selectedChatIds.length
: selectedReviewIds.length;
const handleDeleteSelected =
tab === "documents"
? handleDeleteSelectedDocs
: tab === "assistant"
? handleDeleteSelectedChats
: handleDeleteSelectedReviews;
const actionsDropdown =
currentSelectionCount > 0 ? (
selectedDocIds.length > 0 ? (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
@ -2293,29 +1918,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-[120] overflow-hidden">
{tab === "documents" && (
<button
onClick={handleDownloadSelectedDocs}
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
Download
</button>
{selectedDocIds.some(
(id) =>
docs.find((d) => d.id === id)?.folder_id !=
null,
) && (
<button
onClick={handleDownloadSelectedDocs}
onClick={handleRemoveSelectedFromFolder}
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
Download
Remove from subfolder
</button>
)}
{tab === "documents" &&
selectedDocIds.some(
(id) =>
docs.find((d) => d.id === id)?.folder_id !=
null,
) && (
<button
onClick={handleRemoveSelectedFromFolder}
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
Remove from subfolder
</button>
)}
<button
onClick={handleDeleteSelected}
onClick={handleDeleteSelectedDocs}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
@ -2328,32 +1950,29 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const toolbarActions = (
<div className="flex items-center gap-5">
{actionsDropdown}
{tab === "documents" && (
<>
<button
onClick={() => {
if (loading) return;
setCreatingFolderIn(null);
setNewFolderName("");
}}
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<FolderPlus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Subfolder</span>
</button>
<button
onClick={() => setAddDocsOpen(true)}
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Documents</span>
</button>
</>
)}
<button
onClick={() => {
if (loading) return;
setCreatingFolderIn(null);
setNewFolderName("");
}}
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<FolderPlus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Subfolder</span>
</button>
<button
onClick={() => setAddDocsOpen(true)}
disabled={loading}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Documents</span>
</button>
</div>
);
const pendingVersionDropMessage = pendingVersionDrop ? (
<div className="space-y-2">
<p>
@ -2432,7 +2051,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
) : undefined;
return (
<div className="relative flex-1 overflow-y-auto flex flex-col h-full">
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<input
ref={versionUploadInputRef}
type="file"
@ -2512,46 +2131,13 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}}
onConfirm={() => void confirmDeletePendingFolder()}
/>
<ProjectPageHeader
project={project}
search={search}
creatingChat={creatingChat}
creatingReview={creatingReview}
docsCount={docs.length}
isOwner={project?.is_owner !== false}
onBackToProjects={() => router.push("/projects")}
onOwnerOnly={setOwnerOnlyAction}
onOpenDetails={() => setProjectDetailsOpen(true)}
onDeleteProject={requestProjectDelete}
onSearchChange={setSearch}
onOpenPeople={() => setPeopleModalOpen(true)}
onNewChat={handleNewChat}
onNewReview={handleNewReview}
/>
<ToolbarTabs
tabs={[
{ id: "documents", label: "Documents" },
{ id: "assistant", label: "Assistant Chats" },
{ id: "reviews", label: "Tabular Reviews" },
]}
active={tab}
onChange={handleTabChange}
actions={<>{toolbarActions}</>}
/>
{/* Table content */}
<div className="w-full flex-1 min-h-0 overflow-x-auto">
<ProjectSectionToolbar actions={toolbarActions} />
<div className="w-full flex-1 min-h-0 overflow-auto">
<div className="min-w-max flex min-h-full flex-col">
{loading ? (
<ProjectTableLoading
tab={tab}
stickyCellBg={stickyCellBg}
/>
<ProjectTableLoading stickyCellBg={stickyCellBg} />
) : (
<>
{/* Tab: Documents */}
{tab === "documents" && (
<div className="flex-1 flex flex-col min-h-0">
{/* Table header */}
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
@ -3293,62 +2879,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
{/* end blue ring wrapper */}
</div>
)}
{/* Tab: Assistant */}
{tab === "assistant" && (
<ProjectAssistantTab
chats={chats}
filteredChats={filteredChats}
selectedChatIds={selectedChatIds}
allChatsSelected={allChatsSelected}
someChatsSelected={someChatsSelected}
renamingChatId={renamingChatId}
renameChatValue={renameChatValue}
currentUserId={user?.id}
onCreateChat={handleNewChat}
onOpenChat={(chatId) =>
router.push(
`/projects/${projectId}/assistant/chat/${chatId}`,
)
}
onDeleteChat={handleDeleteChatRow}
onOwnerOnlyAction={setOwnerOnlyAction}
submitChatRename={submitChatRename}
setSelectedChatIds={setSelectedChatIds}
setRenamingChatId={setRenamingChatId}
setRenameChatValue={setRenameChatValue}
/>
)}
{/* Tab: Reviews */}
{tab === "reviews" && (
<ProjectReviewsTab
docs={docs}
reviews={projectReviews}
filteredReviews={filteredReviews}
selectedReviewIds={selectedReviewIds}
allReviewsSelected={allReviewsSelected}
someReviewsSelected={someReviewsSelected}
renamingReviewId={renamingReviewId}
renameReviewValue={renameReviewValue}
creatingReview={creatingReview}
currentUserId={user?.id}
onCreateReview={handleNewReview}
onOpenReview={(reviewId) =>
router.push(
`/projects/${projectId}/tabular-reviews/${reviewId}`,
)
}
onDeleteReview={handleDeleteReviewRow}
onOwnerOnlyAction={setOwnerOnlyAction}
submitReviewRename={submitReviewRename}
setSelectedReviewIds={setSelectedReviewIds}
setRenamingReviewId={setRenamingReviewId}
setRenameReviewValue={setRenameReviewValue}
/>
)}
</>
)}
</div>
</div>
@ -3409,96 +2939,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}}
/>
<AddNewTRModal
open={newTRModalOpen}
onClose={() => setNewTRModalOpen(false)}
onAdd={handleCreateReview}
projectDocs={project?.documents?.filter(
(d) => d.status === "ready",
)}
projectName={project?.name}
projectCmNumber={project?.cm_number}
/>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}
onClose={() => setOwnerOnlyAction(null)}
/>
<ProjectDetailsModal
open={projectDetailsOpen}
project={project}
canEdit={project?.is_owner !== false}
currentUserDisplayName={profile?.displayName ?? null}
currentUserEmail={user?.email ?? null}
fetchPeople={getProjectPeople}
onClose={() => setProjectDetailsOpen(false)}
onSave={handleProjectDetailsSave}
onShareProject={() => {
setProjectDetailsOpen(false);
setPeopleModalOpen(true);
}}
/>
<ConfirmPopup
open={deleteProjectConfirmOpen}
title="Delete project?"
message="This will permanently delete the project and its related documents, chats, and tabular reviews."
confirmLabel="Delete"
confirmStatus={
deleteProjectStatus === "deleting"
? "loading"
: deleteProjectStatus === "deleted"
? "complete"
: "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectConfirmOpen(false);
setDeleteProjectStatus("idle");
}}
onConfirm={() => void confirmProjectDelete()}
/>
{project && (
<PeopleModal
open={peopleModalOpen}
onClose={() => setPeopleModalOpen(false)}
resource={project}
fetchPeople={getProjectPeople}
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Projects",
project.name +
(project.cm_number
? ` (${project.cm_number})`
: ""),
"People",
]}
// Only owners may modify the member list. Without this prop
// PeopleModal renders read-only — non-owners can still see
// who has access but the add/remove controls are hidden.
onSharedWithChange={
project.is_owner === false
? undefined
: async (next) => {
const updated = await updateProject(projectId, {
shared_with: next,
});
setProject((prev) =>
prev
? {
...prev,
shared_with: updated.shared_with,
}
: prev,
);
}
}
/>
)}
</div>
);
}

View file

@ -18,8 +18,9 @@ import type { Project } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { RowActions } from "@/app/components/shared/RowActions";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
import { TABLE_PRIMARY_CELL_WIDTH_CLASS } from "@/app/components/shared/TablePrimitive";
export type ProjectTab = "documents" | "assistant" | "reviews";
export type ProjectWorkspaceSection = "documents" | "assistant" | "reviews";
export type ProjectContextMenu = {
x: number;
@ -29,7 +30,7 @@ export type ProjectContextMenu = {
showFolderActions: boolean;
};
export const NAME_COL_W = "w-[332px] shrink-0";
export const NAME_COL_W = TABLE_PRIMARY_CELL_WIDTH_CLASS;
export const DOC_NAME_COL_W =
"w-[292px] sm:w-[332px] md:w-[392px] lg:w-[452px] xl:w-[532px] 2xl:w-[592px] shrink-0";
@ -422,8 +423,6 @@ export function ProjectPageHeader({
}),
},
]}
align="start"
actionGap="lg"
actionGroups={[
[
{
@ -465,12 +464,10 @@ export function ProjectPageHeader({
},
],
{
gap: "xs",
actions: [
{
onClick: onNewChat,
disabled: creatingChat,
compact: true,
icon: creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
@ -485,7 +482,6 @@ export function ProjectPageHeader({
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
compact: true,
icon: creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (

View file

@ -1,191 +0,0 @@
"use client";
import { type Dispatch, type SetStateAction } from "react";
import { Table2 } from "lucide-react";
import { RowActions } from "@/app/components/shared/RowActions";
import type { Document, TabularReview } from "@/app/components/shared/types";
import { formatDate, NAME_COL_W } from "./ProjectPageParts";
export function ProjectReviewsTab({
docs,
reviews,
filteredReviews,
selectedReviewIds,
allReviewsSelected,
someReviewsSelected,
renamingReviewId,
renameReviewValue,
creatingReview,
currentUserId,
onCreateReview,
onOpenReview,
onDeleteReview,
onOwnerOnlyAction,
submitReviewRename,
setSelectedReviewIds,
setRenamingReviewId,
setRenameReviewValue,
}: {
docs: Document[];
reviews: TabularReview[];
filteredReviews: TabularReview[];
selectedReviewIds: string[];
allReviewsSelected: boolean;
someReviewsSelected: boolean;
renamingReviewId: string | null;
renameReviewValue: string;
creatingReview: boolean;
currentUserId?: string | null;
onCreateReview: () => void;
onOpenReview: (reviewId: string) => void;
onDeleteReview: (review: TabularReview) => Promise<void> | void;
onOwnerOnlyAction: (action: string) => void;
submitReviewRename: (reviewId: string) => Promise<void> | void;
setSelectedReviewIds: Dispatch<SetStateAction<string[]>>;
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
setRenameReviewValue: Dispatch<SetStateAction<string>>;
}) {
const stickyCellBg = "bg-[#fafbfc]";
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] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
<input
type="checkbox"
checked={allReviewsSelected}
ref={(el) => {
if (el) el.indeterminate = someReviewsSelected;
}}
onChange={() => {
if (allReviewsSelected) setSelectedReviewIds([]);
else
setSelectedReviewIds(
filteredReviews.map((r) => r.id),
);
}}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
<span>Name</span>
</div>
<div className="ml-auto w-24 shrink-0 text-left">Columns</div>
<div className="w-24 shrink-0 text-left">Documents</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-8 shrink-0" />
</div>
{reviews.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Tabular Reviews
</p>
<p className="mt-1 text-xs text-gray-400 max-w-xs">
Extract data from project documents into tables using AI.
</p>
<button
onClick={onCreateReview}
disabled={creatingReview || docs.length === 0}
className="mt-4 inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 transition-colors shadow-md disabled:opacity-40"
>
+ Create New
</button>
</div>
) : (
<div>
{filteredReviews.map((review) => (
<div
key={review.id}
onClick={() => {
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-100 cursor-pointer transition-colors"
>
<div
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`}
>
<div className="flex min-w-0 items-center gap-4">
<input
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="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{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}
</div>
<div className="w-24 shrink-0 text-sm text-gray-500 truncate">
{review.document_count ?? 0}
</div>
<div className="w-32 shrink-0 text-sm text-gray-500 truncate">
{review.created_at ? (
formatDate(review.created_at)
) : (
<span className="text-gray-300"></span>
)}
</div>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={() => {
if (
currentUserId &&
review.user_id !== currentUserId
) {
onOwnerOnlyAction(
"rename this tabular review",
);
return;
}
setRenameReviewValue(
review.title ?? "Untitled Review",
);
setRenamingReviewId(review.id);
}}
onDelete={() => onDeleteReview(review)}
/>
</div>
</div>
))}
</div>
)}
</>
);
}

View file

@ -0,0 +1,251 @@
"use client";
import { type Dispatch, type SetStateAction } from "react";
import { Table2 } from "lucide-react";
import {
RowActionMenuItems,
RowActions,
} from "@/app/components/shared/RowActions";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
TableCell,
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
import type { Document, TabularReview } from "@/app/components/shared/types";
import { formatDate } from "./ProjectPageParts";
export function ProjectReviewsTable({
docs,
reviews,
filteredReviews,
selectedReviewIds,
allReviewsSelected,
someReviewsSelected,
renamingReviewId,
renameReviewValue,
creatingReview,
currentUserId,
onCreateReview,
onOpenReview,
onDeleteReview,
onOwnerOnlyAction,
submitReviewRename,
setSelectedReviewIds,
setRenamingReviewId,
setRenameReviewValue,
loading = false,
}: {
docs: Document[];
reviews: TabularReview[];
filteredReviews: TabularReview[];
selectedReviewIds: string[];
allReviewsSelected: boolean;
someReviewsSelected: boolean;
renamingReviewId: string | null;
renameReviewValue: string;
creatingReview: boolean;
currentUserId?: string | null;
onCreateReview: () => void;
onOpenReview: (reviewId: string) => void;
onDeleteReview: (review: TabularReview) => Promise<void> | void;
onOwnerOnlyAction: (action: string) => void;
submitReviewRename: (reviewId: string) => Promise<void> | void;
setSelectedReviewIds: Dispatch<SetStateAction<string[]>>;
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
setRenameReviewValue: Dispatch<SetStateAction<string>>;
loading?: boolean;
}) {
return (
<TableScrollArea>
<TableHeaderRow className="pr-8 md:pr-8">
<TableStickyCell header>
{loading ? (
<SkeletonDot />
) : (
<input
type="checkbox"
checked={allReviewsSelected}
ref={(el) => {
if (el) el.indeterminate = someReviewsSelected;
}}
onChange={() => {
if (allReviewsSelected) setSelectedReviewIds([]);
else
setSelectedReviewIds(
filteredReviews.map((r) => r.id),
);
}}
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
</TableStickyCell>
<TableHeaderCell className="ml-auto w-24">Columns</TableHeaderCell>
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
{loading ? (
<ProjectReviewsLoadingRows />
) : reviews.length === 0 ? (
<TableEmptyState>
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Tabular Reviews
</p>
<p className="mt-1 text-xs text-gray-400 max-w-xs">
Extract data from project documents into tables using AI.
</p>
<button
onClick={onCreateReview}
disabled={creatingReview || docs.length === 0}
className="mt-4 inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 transition-colors shadow-md disabled:opacity-40"
>
+ Create New
</button>
</TableEmptyState>
) : (
<TableBody>
{filteredReviews.map((review) => (
<TableRow
key={review.id}
rightClickDropdown={(close) => (
<RowActionMenuItems
onClose={close}
onRename={() => {
if (
currentUserId &&
review.user_id !== currentUserId
) {
onOwnerOnlyAction(
"rename this tabular review",
);
return;
}
setRenameReviewValue(
review.title ?? "Untitled Review",
);
setRenamingReviewId(review.id);
}}
onDelete={() => onDeleteReview(review)}
/>
)}
onClick={() => {
if (renamingReviewId === review.id) return;
onOpenReview(review.id);
}}
className="pr-8 md:pr-8"
>
<TablePrimaryCell
bgClassName={
selectedReviewIds.includes(review.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG
}
selected={selectedReviewIds.includes(review.id)}
onSelectionChange={() =>
setSelectedReviewIds((prev) =>
prev.includes(review.id)
? prev.filter(
(x) => x !== review.id,
)
: [...prev, review.id],
)
}
label={review.title ?? "Untitled Review"}
editing={renamingReviewId === review.id}
editValue={renameReviewValue}
onEditValueChange={setRenameReviewValue}
onEditCommit={() =>
void submitReviewRename(review.id)
}
onEditCancel={() => setRenamingReviewId(null)}
/>
<TableCell className="ml-auto w-24">
{review.columns_config?.length ?? 0}
</TableCell>
<TableCell className="w-24">
{review.document_count ?? 0}
</TableCell>
<TableCell className="w-32">
{review.created_at ? (
formatDate(review.created_at)
) : (
<span className="text-gray-300"></span>
)}
</TableCell>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={() => {
if (
currentUserId &&
review.user_id !== currentUserId
) {
onOwnerOnlyAction(
"rename this tabular review",
);
return;
}
setRenameReviewValue(
review.title ?? "Untitled Review",
);
setRenamingReviewId(review.id);
}}
onDelete={() => onDeleteReview(review)}
/>
</div>
</TableRow>
))}
</TableBody>
)}
</TableScrollArea>
);
}
function ProjectReviewsLoadingRows() {
const titleWidths = ["w-36", "w-40", "w-44", "w-48", "w-52"];
return (
<TableBody>
{[1, 2, 3, 4, 5].map((i) => (
<TableRow
key={i}
interactive={false}
className="pr-8 md:pr-8"
>
<TableStickyCell hover={false}>
<div className="flex min-w-0 items-center gap-4">
<SkeletonDot />
<SkeletonLine
className={`h-3.5 ${titleWidths[i - 1]}`}
/>
</div>
</TableStickyCell>
<TableCell className="ml-auto w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-32">
<SkeletonLine className="w-20" />
</TableCell>
<TableCell className="w-8" />
</TableRow>
))}
</TableBody>
);
}

View file

@ -0,0 +1,568 @@
"use client";
import {
createContext,
type ReactNode,
use,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import {
createTabularReview,
deleteProject,
getProject,
getProjectPeople,
listProjectChats,
listTabularReviews,
updateProject,
} from "@/app/lib/mikeApi";
import type {
Chat,
ColumnConfig,
Folder as ProjectFolder,
Project,
TabularReview,
} from "@/app/components/shared/types";
import { TableToolbar } from "@/app/components/shared/TableToolbar";
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { PeopleModal } from "@/app/components/shared/PeopleModal";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { ProjectDetailsModal } from "./ProjectDetailsModal";
import {
ProjectPageHeader,
type ProjectWorkspaceSection,
} from "./ProjectPageParts";
type ProjectWorkspaceValue = {
projectId: string;
project: Project | null;
setProject: React.Dispatch<React.SetStateAction<Project | null>>;
folders: ProjectFolder[];
setFolders: React.Dispatch<React.SetStateAction<ProjectFolder[]>>;
projectLoading: boolean;
activeSection: ProjectWorkspaceSection;
search: string;
setSearch: (search: string) => void;
projectChats: Chat[] | null;
setProjectChats: React.Dispatch<React.SetStateAction<Chat[] | null>>;
projectChatsLoading: boolean;
ensureProjectChats: () => Promise<Chat[]>;
projectReviews: TabularReview[] | null;
setProjectReviews: React.Dispatch<
React.SetStateAction<TabularReview[] | null>
>;
projectReviewsLoading: boolean;
ensureProjectReviews: () => Promise<TabularReview[]>;
prefetchProjectSections: () => void;
creatingChat: boolean;
creatingReview: boolean;
createChat: () => Promise<void>;
openNewReview: () => void;
setOwnerOnlyAction: React.Dispatch<React.SetStateAction<string | null>>;
};
const ProjectWorkspaceContext =
createContext<ProjectWorkspaceValue | null>(null);
export function useProjectWorkspace() {
const value = useContext(ProjectWorkspaceContext);
if (!value) {
throw new Error(
"useProjectWorkspace must be used inside ProjectWorkspaceProvider",
);
}
return value;
}
export function useProjectWorkspaceOptional() {
return useContext(ProjectWorkspaceContext);
}
function activeSectionFromSegments(
segments: string[],
): ProjectWorkspaceSection {
if (segments[0] === "assistant") return "assistant";
if (segments[0] === "tabular-reviews") return "reviews";
return "documents";
}
function shouldShowWorkspaceShell(segments: string[]) {
if (segments.length === 0) return true;
if (segments.length !== 1) return false;
return segments[0] === "assistant" || segments[0] === "tabular-reviews";
}
export function ProjectWorkspaceProvider({
projectId,
children,
}: {
projectId: string;
children: ReactNode;
}) {
const [project, setProject] = useState<Project | null>(null);
const [folders, setFolders] = useState<ProjectFolder[]>([]);
const [projectLoading, setProjectLoading] = useState(true);
const [searchBySection, setSearchBySection] = useState<
Record<ProjectWorkspaceSection, string>
>({ documents: "", assistant: "", reviews: "" });
const [projectChats, setProjectChats] = useState<Chat[] | null>(null);
const [projectReviews, setProjectReviews] = useState<
TabularReview[] | null
>(null);
const [projectChatsLoading, setProjectChatsLoading] = useState(false);
const [projectReviewsLoading, setProjectReviewsLoading] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [projectDetailsOpen, setProjectDetailsOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
useState(false);
const [deleteProjectStatus, setDeleteProjectStatus] = useState<
"idle" | "deleting" | "deleted"
>("idle");
const [newTRModalOpen, setNewTRModalOpen] = useState(false);
const [creatingChat, setCreatingChat] = useState(false);
const [creatingReview, setCreatingReview] = useState(false);
const segments = useSelectedLayoutSegments();
const activeSection = activeSectionFromSegments(segments);
const showShell = shouldShowWorkspaceShell(segments);
const router = useRouter();
const { user } = useAuth();
const { profile } = useUserProfile();
const { saveChat } = useChatHistoryContext();
const projectChatsPromiseRef = useRef<Promise<Chat[]> | null>(null);
const projectReviewsPromiseRef = useRef<Promise<TabularReview[]> | null>(
null,
);
useEffect(() => {
setProjectChats(null);
setProjectReviews(null);
setProjectChatsLoading(false);
setProjectReviewsLoading(false);
projectChatsPromiseRef.current = null;
projectReviewsPromiseRef.current = null;
}, [projectId]);
useEffect(() => {
if (!showShell) {
setProjectLoading(false);
return;
}
let cancelled = false;
setProjectLoading(true);
getProject(projectId)
.then((loaded) => {
if (cancelled) return;
setProject(loaded);
setFolders(loaded.folders ?? []);
})
.catch((error) => {
console.error("[project workspace] failed to load project", error);
if (!cancelled) {
setProject(null);
setFolders([]);
}
})
.finally(() => {
if (!cancelled) setProjectLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId, showShell]);
const search = searchBySection[activeSection];
const setSearch = useCallback(
(value: string) =>
setSearchBySection((prev) => ({
...prev,
[activeSection]: value,
})),
[activeSection],
);
const ensureProjectChats = useCallback(() => {
if (projectChats) return Promise.resolve(projectChats);
if (projectChatsPromiseRef.current) return projectChatsPromiseRef.current;
setProjectChatsLoading(true);
const promise = listProjectChats(projectId)
.then((loaded) => {
setProjectChats(loaded);
return loaded;
})
.catch((error) => {
console.error("[project assistant] failed to load", error);
setProjectChats([]);
return [];
})
.finally(() => {
projectChatsPromiseRef.current = null;
setProjectChatsLoading(false);
});
projectChatsPromiseRef.current = promise;
return promise;
}, [projectChats, projectId]);
const ensureProjectReviews = useCallback(() => {
if (projectReviews) return Promise.resolve(projectReviews);
if (projectReviewsPromiseRef.current)
return projectReviewsPromiseRef.current;
setProjectReviewsLoading(true);
const promise = listTabularReviews(projectId)
.then((loaded) => {
setProjectReviews(loaded);
return loaded;
})
.catch((error) => {
console.error("[project reviews] failed to load", error);
setProjectReviews([]);
return [];
})
.finally(() => {
projectReviewsPromiseRef.current = null;
setProjectReviewsLoading(false);
});
projectReviewsPromiseRef.current = promise;
return promise;
}, [projectId, projectReviews]);
const prefetchProjectSections = useCallback(() => {
void ensureProjectChats();
void ensureProjectReviews();
}, [ensureProjectChats, ensureProjectReviews]);
const createChat = useCallback(async () => {
setCreatingChat(true);
try {
const id = await saveChat(projectId);
if (id) {
const now = new Date().toISOString();
setProjectChats((prev) =>
prev
? [
{
id,
project_id: projectId,
user_id: user?.id ?? "",
creator_display_name:
profile?.displayName ?? null,
title: null,
created_at: now,
},
...prev,
]
: prev,
);
router.push(`/projects/${projectId}/assistant/chat/${id}`);
}
} finally {
setCreatingChat(false);
}
}, [profile?.displayName, projectId, router, saveChat, user?.id]);
const openNewReview = useCallback(() => {
const readyDocs =
project?.documents?.filter((d) => d.status === "ready") ?? [];
if (readyDocs.length === 0) return;
setNewTRModalOpen(true);
}, [project?.documents]);
async function handleCreateReview(
title: string,
_projectId?: string,
documentIds?: string[],
columnsConfig?: ColumnConfig[] | null,
) {
setCreatingReview(true);
try {
const readyDocs =
project?.documents?.filter((d) => d.status === "ready") ?? [];
const review = await createTabularReview({
title: title || undefined,
document_ids: documentIds ?? readyDocs.map((d) => d.id),
columns_config: columnsConfig ?? [],
project_id: projectId,
});
setProjectReviews((prev) => (prev ? [review, ...prev] : prev));
router.push(`/projects/${projectId}/tabular-reviews/${review.id}`);
} finally {
setCreatingReview(false);
}
}
async function handleProjectDetailsSave(values: {
name: string;
cmNumber: string;
}) {
if (project && project.is_owner === false) {
setOwnerOnlyAction("edit project details");
return;
}
const name = values.name.trim();
const cmNumber = values.cmNumber.trim();
if (!name) return;
const updated = await updateProject(projectId, {
name,
cm_number: cmNumber,
});
setProject((prev) =>
prev
? {
...prev,
name: updated.name,
cm_number: updated.cm_number,
}
: updated,
);
}
function requestProjectDelete() {
if (project && project.is_owner === false) {
setOwnerOnlyAction("delete this project");
return;
}
setDeleteProjectStatus("idle");
setDeleteProjectConfirmOpen(true);
}
async function confirmProjectDelete() {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectStatus("deleting");
try {
await deleteProject(projectId);
setDeleteProjectStatus("deleted");
window.setTimeout(() => router.push("/projects"), 500);
} catch (error) {
console.error("deleteProject failed", error);
setDeleteProjectStatus("idle");
}
}
const value = useMemo<ProjectWorkspaceValue>(
() => ({
projectId,
project,
setProject,
folders,
setFolders,
projectLoading,
activeSection,
search,
setSearch,
projectChats,
setProjectChats,
projectChatsLoading,
ensureProjectChats,
projectReviews,
setProjectReviews,
projectReviewsLoading,
ensureProjectReviews,
prefetchProjectSections,
creatingChat,
creatingReview,
createChat,
openNewReview,
setOwnerOnlyAction,
}),
[
projectId,
project,
folders,
projectLoading,
activeSection,
search,
setSearch,
projectChats,
projectChatsLoading,
ensureProjectChats,
projectReviews,
projectReviewsLoading,
ensureProjectReviews,
prefetchProjectSections,
creatingChat,
creatingReview,
createChat,
openNewReview,
],
);
if (!showShell) {
return (
<ProjectWorkspaceContext.Provider value={value}>
{children}
</ProjectWorkspaceContext.Provider>
);
}
return (
<ProjectWorkspaceContext.Provider value={value}>
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<ProjectPageHeader
project={project}
search={search}
creatingChat={creatingChat}
creatingReview={creatingReview}
docsCount={project?.documents?.length ?? 0}
isOwner={project?.is_owner !== false}
onBackToProjects={() => router.push("/projects")}
onOwnerOnly={setOwnerOnlyAction}
onOpenDetails={() => setProjectDetailsOpen(true)}
onDeleteProject={requestProjectDelete}
onSearchChange={setSearch}
onOpenPeople={() => setPeopleModalOpen(true)}
onNewChat={() => void createChat()}
onNewReview={openNewReview}
/>
{children}
<AddNewTRModal
open={newTRModalOpen}
onClose={() => setNewTRModalOpen(false)}
onAdd={handleCreateReview}
projectDocs={project?.documents?.filter(
(d) => d.status === "ready",
)}
projectName={project?.name}
projectCmNumber={project?.cm_number}
/>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}
onClose={() => setOwnerOnlyAction(null)}
/>
<ProjectDetailsModal
open={projectDetailsOpen}
project={project}
canEdit={project?.is_owner !== false}
currentUserDisplayName={profile?.displayName ?? null}
currentUserEmail={user?.email ?? null}
fetchPeople={getProjectPeople}
onClose={() => setProjectDetailsOpen(false)}
onSave={handleProjectDetailsSave}
onShareProject={() => {
setProjectDetailsOpen(false);
setPeopleModalOpen(true);
}}
/>
<ConfirmPopup
open={deleteProjectConfirmOpen}
title="Delete project?"
message="This will permanently delete the project and its related documents, chats, and tabular reviews."
confirmLabel="Delete"
confirmStatus={
deleteProjectStatus === "deleting"
? "loading"
: deleteProjectStatus === "deleted"
? "complete"
: "idle"
}
cancelLabel="Cancel"
onCancel={() => {
if (deleteProjectStatus === "deleting") return;
setDeleteProjectConfirmOpen(false);
setDeleteProjectStatus("idle");
}}
onConfirm={() => void confirmProjectDelete()}
/>
{project && (
<PeopleModal
open={peopleModalOpen}
onClose={() => setPeopleModalOpen(false)}
resource={project}
fetchPeople={getProjectPeople}
currentUserEmail={user?.email ?? null}
breadcrumb={[
"Projects",
project.name +
(project.cm_number
? ` (${project.cm_number})`
: ""),
"People",
]}
onSharedWithChange={
project.is_owner === false
? undefined
: async (next) => {
const updated = await updateProject(
projectId,
{ shared_with: next },
);
setProject((prev) =>
prev
? {
...prev,
shared_with:
updated.shared_with,
}
: prev,
);
}
}
/>
)}
</div>
</ProjectWorkspaceContext.Provider>
);
}
export function ProjectSectionToolbar({
actions,
}: {
actions?: ReactNode;
}) {
const { activeSection, projectId } = useProjectWorkspace();
const router = useRouter();
return (
<TableToolbar
items={[
{ id: "documents", label: "Documents" },
{ id: "assistant", label: "Assistant Chats" },
{ id: "reviews", label: "Tabular Reviews" },
]}
active={activeSection}
onChange={(next) => {
const href =
next === "documents"
? `/projects/${projectId}`
: next === "assistant"
? `/projects/${projectId}/assistant`
: `/projects/${projectId}/tabular-reviews`;
router.push(href);
}}
actions={actions}
/>
);
}
export function ProjectWorkspaceLayout({
params,
children,
}: {
params: Promise<{ id: string }>;
children: ReactNode;
}) {
const { id } = use(params);
return (
<ProjectWorkspaceProvider projectId={id}>
{children}
</ProjectWorkspaceProvider>
);
}

View file

@ -8,9 +8,27 @@ import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
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 { TableToolbar } from "@/app/components/shared/TableToolbar";
import {
RowActionMenuItems,
RowActions,
} from "@/app/components/shared/RowActions";
import { PageHeader } from "@/app/components/shared/PageHeader";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
TableCell,
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
@ -20,16 +38,23 @@ function formatDate(iso: string) {
});
}
type Tab = "all" | "mine" | "shared-with-me";
function getProjectOwnerLabel(project: Project, currentUserId?: string | null) {
if (project.is_owner ?? project.user_id === currentUserId) return "Me";
return (
project.owner_display_name?.trim() ||
project.owner_email?.trim() ||
"Shared"
);
}
const NAME_COL_W = "w-[332px] shrink-0";
type ProjectFilter = "all" | "mine" | "shared-with-me";
export function ProjectsOverview() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>("all");
const [activeFilter, setActiveFilter] = useState<ProjectFilter>("all");
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [cmEditingId, setCmEditingId] = useState<string | null>(null);
@ -41,7 +66,6 @@ export function ProjectsOverview() {
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { user, isAuthenticated, authLoading } = useAuth();
const stickyCellBg = "bg-[#fafbfc]";
useEffect(() => {
if (authLoading) {
@ -80,7 +104,7 @@ export function ProjectsOverview() {
useEffect(() => {
setSelectedIds([]);
}, [activeTab]);
}, [activeFilter]);
useEffect(() => {
function handleClick(e: MouseEvent) {
@ -96,9 +120,9 @@ export function ProjectsOverview() {
const q = search.toLowerCase();
const filtered = (
activeTab === "all"
activeFilter === "all"
? projects
: activeTab === "mine"
: activeFilter === "mine"
? projects.filter((p) => p.is_owner ?? p.user_id === user?.id)
: projects.filter((p) => !(p.is_owner ?? p.user_id === user?.id))
).filter(
@ -128,7 +152,7 @@ export function ProjectsOverview() {
);
}
const tabs: { id: Tab; label: string }[] = [
const filters: { id: ProjectFilter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "mine", label: "Mine" },
{ id: "shared-with-me", label: "Shared with me" },
@ -160,7 +184,7 @@ export function ProjectsOverview() {
setActionsOpen(false);
// Only the project owner can delete; the per-row delete is hidden
// for shared projects but the bulk action can still pick them up
// if a user toggled them across tabs. Filter and warn.
// if a user toggled them across filters. Filter and warn.
const owned = ids.filter((id) => {
const p = projects.find((pp) => pp.id === id);
return !p || (p.is_owner ?? p.user_id === user?.id);
@ -203,7 +227,7 @@ export function ProjectsOverview() {
);
return (
<div className="flex-1 overflow-y-auto">
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{/* Page header */}
<PageHeader
loading={loading}
@ -226,21 +250,20 @@ export function ProjectsOverview() {
</h1>
</PageHeader>
<ToolbarTabs
tabs={tabs}
active={activeTab}
onChange={setActiveTab}
<TableToolbar
items={filters}
active={activeFilter}
onChange={setActiveFilter}
actions={toolbarActions}
/>
{/* Table */}
<div className="w-full overflow-x-auto">
<div className="min-w-max">
<TableScrollArea>
{/* 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] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
<TableHeaderRow>
<TableStickyCell header>
{loading ? (
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<SkeletonDot />
) : (
<input
type="checkbox"
@ -249,53 +272,60 @@ export function ProjectsOverview() {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
className={TABLE_CHECKBOX_CLASS}
/>
)}
<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>
<div className="w-24 shrink-0 text-left">Chats</div>
<div className="w-36 shrink-0 text-left">
</TableStickyCell>
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
<TableHeaderCell className="w-32">Owner</TableHeaderCell>
<TableHeaderCell className="w-24">Files</TableHeaderCell>
<TableHeaderCell className="w-24">Chats</TableHeaderCell>
<TableHeaderCell className="w-36">
Tabular Reviews
</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-8 shrink-0" />
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
{loading ? (
<div>
<TableBody>
{[1, 2, 3].map((i) => (
<div
<TableRow
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
interactive={false}
>
<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">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-24 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-36 shrink-0">
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-32 shrink-0">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
<TableStickyCell
hover={false}
bgClassName="bg-transparent"
>
<SkeletonDot />
<SkeletonLine className="h-3.5 w-48" />
</TableStickyCell>
<TableCell className="ml-auto w-32">
<SkeletonLine className="w-20" />
</TableCell>
<TableCell className="w-32">
<SkeletonLine className="w-16" />
</TableCell>
<TableCell className="w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-36">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-32">
<SkeletonLine className="w-20" />
</TableCell>
<TableCell className="w-8" />
</TableRow>
))}
</div>
</TableBody>
) : loadError ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
<TableEmptyState>
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Projects
@ -303,10 +333,10 @@ export function ProjectsOverview() {
<p className="mt-1 text-xs text-red-500 max-w-xs">
{loadError}
</p>
</div>
</TableEmptyState>
) : filtered.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
{activeTab === "all" || activeTab === "mine" ? (
<TableEmptyState>
{activeFilter === "all" || activeFilter === "mine" ? (
<>
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
@ -326,68 +356,80 @@ export function ProjectsOverview() {
</>
) : (
<p className="text-sm text-gray-400">
No {activeTab} projects
No {activeFilter} projects
</p>
)}
</div>
</TableEmptyState>
) : (
<div>
<TableBody>
{filtered.map((project) => {
const rowBg = selectedIds.includes(project.id)
? "bg-gray-50"
: stickyCellBg;
: TABLE_STICKY_CELL_BG;
return (
<div
<TableRow
key={project.id}
rightClickDropdown={
(project.is_owner ??
project.user_id === user?.id)
? (close) => (
<RowActionMenuItems
onClose={close}
onRename={() => {
setRenameValue(
project.name,
);
setRenamingId(project.id);
}}
onUpdateCmNumber={() => {
setCmValue(
project.cm_number ??
"",
);
setCmEditingId(
project.id,
);
}}
onDelete={async () => {
await deleteProject(
project.id,
);
setProjects((prev) =>
prev.filter(
(p) =>
p.id !==
project.id,
),
);
}}
/>
)
: undefined
}
onClick={() => {
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-100 cursor-pointer transition-colors"
>
{/* Project Name */}
<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
type="checkbox"
checked={selectedIds.includes(
project.id,
)}
onChange={() => toggleOne(project.id)}
onClick={(e) => e.stopPropagation()}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
{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>
<TablePrimaryCell
bgClassName={rowBg}
selected={selectedIds.includes(project.id)}
onSelectionChange={() =>
toggleOne(project.id)
}
label={project.name}
editing={renamingId === project.id}
editValue={renameValue}
onEditValueChange={setRenameValue}
onEditCommit={() =>
handleRenameSubmit(project.id)
}
onEditCancel={() => setRenamingId(null)}
/>
<div
className="ml-auto w-32 shrink-0 text-sm text-gray-500 truncate"
<TableCell
className="ml-auto w-32"
onClick={(e) => e.stopPropagation()}
>
{cmEditingId === project.id ? (
@ -416,19 +458,22 @@ export function ProjectsOverview() {
</span>
))
)}
</div>
<div className="w-24 shrink-0 text-sm text-gray-500 truncate">
</TableCell>
<TableCell className="w-32">
{getProjectOwnerLabel(project, user?.id)}
</TableCell>
<TableCell className="w-24">
{project.document_count ?? 0}
</div>
<div className="w-24 shrink-0 text-sm text-gray-500 truncate">
</TableCell>
<TableCell className="w-24">
{project.chat_count ?? 0}
</div>
<div className="w-36 shrink-0 text-sm text-gray-500 truncate">
</TableCell>
<TableCell className="w-36">
{project.review_count ?? 0}
</div>
<div className="w-32 shrink-0 text-sm text-gray-500 truncate">
</TableCell>
<TableCell className="w-32">
{formatDate(project.created_at)}
</div>
</TableCell>
<div
className="w-8 shrink-0 flex justify-end"
@ -459,13 +504,12 @@ export function ProjectsOverview() {
/>
)}
</div>
</div>
</TableRow>
);
})}
</div>
</TableBody>
)}
</div>
</div>
</TableScrollArea>
<NewProjectModal
open={modalOpen}

View file

@ -280,7 +280,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
: "text-gray-700 hover:bg-gray-100",
)}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-500" />
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
<span className="min-w-0 flex-1 truncate">
{project.name}
</span>

View file

@ -0,0 +1,119 @@
"use client";
import { useEffect, useRef, useState, type ComponentType } from "react";
import { Check, ChevronDown } from "lucide-react";
export const GLASS_DROPDOWN =
"rounded-2xl border border-white/70 bg-white/70 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)] backdrop-blur-2xl";
export const GLASS_MENU_ITEM = "transition-colors hover:bg-white/65";
export type HeaderFilterOption<T extends string> = {
value: T;
label: string;
icon?: ComponentType<{ className?: string }>;
className?: string;
};
export function HeaderFilterDropdown<T extends string>({
label,
value,
allLabel,
options,
onChange,
widthClassName = "w-52",
}: {
label: string;
value: T | null;
allLabel: string;
options: HeaderFilterOption<T>[];
onChange: (value: T | null) => void;
widthClassName?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const selected = options.find((option) => option.value === value);
useEffect(() => {
if (!open) return;
function handleClick(event: MouseEvent) {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((next) => !next)}
aria-label={label}
title={selected?.label ?? label}
className={`flex h-5 w-5 items-center justify-center rounded-full transition-colors ${
value
? "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
: "text-gray-400 hover:bg-gray-100 hover:text-gray-700"
}`}
>
<ChevronDown
className={`h-3 w-3 transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</button>
{open && (
<div
className={`absolute right-0 top-full mt-1.5 z-[100] overflow-hidden ${widthClassName} ${GLASS_DROPDOWN}`}
>
<button
onClick={() => {
onChange(null);
setOpen(false);
}}
className={`flex w-full items-center justify-between px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
>
{allLabel}
{!value && (
<Check className="h-3.5 w-3.5 text-gray-400" />
)}
</button>
{options.length > 0 && (
<div className="border-t border-white/60" />
)}
{options.map((option) => {
const Icon = option.icon;
return (
<button
key={option.value}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
className={`flex w-full items-center justify-between px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
>
<span
className={`truncate pr-2 ${
Icon
? "inline-flex items-center gap-1.5 font-medium"
: ""
} ${option.className ?? ""}`}
>
{Icon && (
<Icon className="h-3.5 w-3.5 shrink-0" />
)}
{option.label}
</span>
{value === option.value && (
<Check className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -22,6 +22,7 @@ interface ModalProps {
breadcrumbs?: ReactNode[];
title?: ReactNode;
icon?: ReactNode;
headerAction?: ReactNode;
size?: ModalSize;
className?: string;
footerInfo?: ReactNode;
@ -45,6 +46,7 @@ export function Modal({
breadcrumbs,
title,
icon,
headerAction,
size = "lg",
className,
footerInfo,
@ -77,7 +79,7 @@ export function Modal({
>
<div
className={cn(
"w-full rounded-2xl flex h-[600px] flex-col",
"w-full rounded-3xl flex h-[600px] flex-col",
sizeClassName[size],
"border border-white/70 bg-white/94 shadow-[0_12px_36px_rgba(15,23,42,0.1)] backdrop-blur-2xl",
className,
@ -87,25 +89,31 @@ export function Modal({
{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}
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<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>
</span>
))}
))}
</div>
{headerAction}
</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 className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{icon}
<h2 className="truncate text-base font-medium text-gray-900">
{title}
</h2>
</div>
{headerAction}
</div>
)}
<button
@ -186,9 +194,10 @@ function ModalActionButton({
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
variant === "secondary" && "text-gray-600 hover:text-gray-950",
fallbackVariant === "secondary" &&
variant === "secondary" &&
"rounded-full border border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.16),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 hover:text-white active:scale-[0.98] disabled:active:scale-100",
variant === "danger" &&
"rounded-full border border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 active:scale-[0.98] disabled:active:scale-100",
"px-1 text-red-600 hover:text-red-700 active:scale-[0.98] disabled:active:scale-100",
)}
{...props}
>

View file

@ -32,7 +32,6 @@ type PageHeaderButtonAction = {
title?: string;
variant?: "default" | "danger";
iconOnly?: boolean;
compact?: boolean;
tooltip?: ReactNode;
};
@ -72,41 +71,28 @@ export type PageHeaderAction =
| PageHeaderCustomAction
| ReactNode;
type PageHeaderActionGap = "xs" | "sm" | "md" | "lg";
type PageHeaderActionGroup =
| PageHeaderAction[]
| {
actions: PageHeaderAction[];
gap?: PageHeaderActionGap;
};
interface PageHeaderProps {
children?: ReactNode;
actions?: PageHeaderAction[];
actionGroups?: PageHeaderActionGroup[];
align?: "center" | "start";
shrink?: boolean;
className?: string;
actionGap?: PageHeaderActionGap;
breadcrumbs?: PageHeaderBreadcrumb[];
loading?: boolean;
}
const actionGapClassName = {
xs: "gap-1",
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,
loading = false,
}: PageHeaderProps) {
@ -121,19 +107,16 @@ export function PageHeader({
const actionItems = actions?.filter(Boolean) ?? [];
const groupedActionItems = (
actionGroups
?.map((group) => normalizeActionGroup(group, actionGap))
?.map(normalizeActionGroup)
.filter((group) => group.actions.length > 0) ??
(actionItems.length > 0
? [{ actions: actionItems, gap: actionGap }]
: [])
(actionItems.length > 0 ? [{ actions: actionItems }] : [])
);
const hasActions = groupedActionItems.length > 0;
return (
<div
className={cn(
"flex justify-between",
align === "start" ? "items-start" : "items-center",
"flex items-center justify-between",
"px-4 md:px-10",
"min-h-[76px] pb-4 pt-5.5",
shrink && "shrink-0",
@ -170,7 +153,6 @@ function PageHeaderActionGroups({
}: {
groupedActionItems: {
actions: PageHeaderAction[];
gap: PageHeaderActionGap;
}[];
actionsDisabled: boolean;
}) {
@ -180,8 +162,7 @@ function PageHeaderActionGroups({
<div
key={groupIndex}
className={cn(
"flex shrink-0 items-center",
actionGapClassName[group.gap],
"flex shrink-0 items-center gap-2",
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl",
)}
>
@ -199,19 +180,14 @@ function PageHeaderActionGroups({
);
}
function normalizeActionGroup(
group: PageHeaderActionGroup,
fallbackGap: PageHeaderActionGap,
) {
function normalizeActionGroup(group: PageHeaderActionGroup) {
if (Array.isArray(group)) {
return {
actions: group.filter(Boolean),
gap: fallbackGap,
};
}
return {
actions: group.actions.filter(Boolean),
gap: group.gap ?? fallbackGap,
};
}
@ -299,7 +275,6 @@ function PageHeaderButtonActionControl({
aria-label={action.title}
variant={action.variant}
iconOnly={iconOnly}
compact={action.compact}
>
{action.icon}
{action.label}
@ -430,13 +405,11 @@ type PageHeaderActionButtonProps = Omit<
> & {
variant?: "default" | "danger";
iconOnly?: boolean;
compact?: boolean;
};
type PageHeaderActionControlClassNameOptions = {
variant?: "default" | "danger";
iconOnly?: boolean;
compact?: boolean;
disabled?: boolean;
className?: string;
};
@ -444,13 +417,14 @@ type PageHeaderActionControlClassNameOptions = {
function pageHeaderActionControlClassName({
variant = "default",
iconOnly = false,
compact = 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" : compact ? "gap-1.5 px-2" : "gap-1.5 px-3",
iconOnly
? "w-7"
: "w-7 gap-1.5 px-0 sm:w-auto sm:px-3",
disabled ? "cursor-default" : "cursor-pointer",
"hover:bg-gray-100 active:bg-gray-100",
variant === "danger"
@ -464,7 +438,6 @@ function PageHeaderActionButton({
children,
variant = "default",
iconOnly = false,
compact = false,
disabled,
...props
}: PageHeaderActionButtonProps) {
@ -474,7 +447,6 @@ function PageHeaderActionButton({
className={pageHeaderActionControlClassName({
variant,
iconOnly,
compact,
disabled,
})}
{...props}

View file

@ -13,8 +13,12 @@ import {
Trash2,
Upload,
} from "lucide-react";
import {
GLASS_DROPDOWN,
GLASS_MENU_ITEM,
} from "@/app/components/shared/HeaderFilterDropdown";
const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
export const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
export function closeRowActionMenus() {
document.dispatchEvent(new Event(CLOSE_ROW_ACTIONS_EVENT));
@ -61,7 +65,7 @@ export function RowActionMenuItems({
{onNewSubfolder && (
<button
onClick={() => { onClose(); onNewSubfolder(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
>
<FolderPlus className="h-3.5 w-3.5 shrink-0" />
{newSubfolderLabel}
@ -70,7 +74,7 @@ export function RowActionMenuItems({
{onRename && (
<button
onClick={() => { onClose(); onRename(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
>
<Pencil className="h-3.5 w-3.5" />
{renameLabel}
@ -79,7 +83,7 @@ export function RowActionMenuItems({
{onUpdateCmNumber && (
<button
onClick={() => { onClose(); onUpdateCmNumber(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
>
<Hash className="h-3.5 w-3.5" />
Edit CM No.
@ -88,7 +92,7 @@ export function RowActionMenuItems({
{onDownload && (
<button
onClick={() => { onClose(); onDownload(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
>
<Download className="h-3.5 w-3.5" />
Download
@ -97,7 +101,7 @@ export function RowActionMenuItems({
{onShowAllVersions && (
<button
onClick={() => { onClose(); onShowAllVersions(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
>
<History className="h-3.5 w-3.5 shrink-0" />
Show all versions
@ -106,7 +110,7 @@ export function RowActionMenuItems({
{onUploadNewVersion && (
<button
onClick={() => { onClose(); onUploadNewVersion(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
>
<Upload className="h-3.5 w-3.5 shrink-0" />
Upload new version
@ -115,7 +119,7 @@ export function RowActionMenuItems({
{onRemoveFromFolder && (
<button
onClick={() => { onClose(); onRemoveFromFolder(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
>
<FolderMinus className="h-3.5 w-3.5 shrink-0" />
Remove from subfolder
@ -124,7 +128,7 @@ export function RowActionMenuItems({
{onUnhide && (
<button
onClick={() => { onClose(); onUnhide(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
>
<Eye className="h-3.5 w-3.5" />
Unhide
@ -133,7 +137,7 @@ export function RowActionMenuItems({
{onHide && (
<button
onClick={() => { onClose(); onHide(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
>
<EyeOff className="h-3.5 w-3.5" />
Hide
@ -150,7 +154,7 @@ export function RowActionMenuItems({
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
deleteDisabled
? "cursor-not-allowed opacity-40 hover:bg-transparent"
: "hover:bg-red-50"
: "hover:bg-red-500/10"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
@ -217,7 +221,7 @@ export function RowActions(props: Props) {
{open && (
<div
style={{ position: "fixed", top: coords.top, right: coords.right }}
className="z-[120] w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
className={`z-[120] w-48 overflow-hidden ${GLASS_DROPDOWN}`}
onClick={(e) => e.stopPropagation()}
>
<RowActionMenuItems

View file

@ -0,0 +1,306 @@
"use client";
import {
useEffect,
useState,
type HTMLAttributes,
type MouseEvent,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import {
CLOSE_ROW_ACTIONS_EVENT,
closeRowActionMenus,
} from "@/app/components/shared/RowActions";
import { GLASS_DROPDOWN } from "@/app/components/shared/HeaderFilterDropdown";
export const TABLE_STICKY_CELL_BG = "bg-[#fafbfc]";
export const TABLE_PRIMARY_CELL_WIDTH_CLASS =
"w-[248px] sm:w-[292px] md:w-[332px] shrink-0";
export const TABLE_CHECKBOX_CLASS =
"h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black";
type DivProps = HTMLAttributes<HTMLDivElement>;
export function SkeletonLine({ className }: { className?: string }) {
return (
<div
className={cn("h-3 rounded bg-gray-100 animate-pulse", className)}
/>
);
}
export function SkeletonDot({ className }: { className?: string }) {
return (
<div
className={cn(
"h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse",
className,
)}
/>
);
}
export function TableScrollArea({
children,
className,
innerClassName,
}: DivProps & { innerClassName?: string }) {
return (
<div className={cn("w-full min-h-0 flex-1 overflow-auto", className)}>
<div
className={cn("flex min-h-full min-w-max flex-col", innerClassName)}
>
{children}
</div>
</div>
);
}
export function TableHeaderRow({ children, className, ...props }: DivProps) {
return (
<div
className={cn(
"flex h-8 items-center border-b border-gray-200 pr-3 text-xs font-medium text-gray-500 select-none md:pr-10",
className,
)}
{...props}
>
{children}
</div>
);
}
export function TableRow({
children,
className,
interactive = true,
onContextMenu,
rightClickDropdown,
...props
}: DivProps & {
interactive?: boolean;
rightClickDropdown?: ReactNode | ((close: () => void) => ReactNode);
}) {
const [menuCoords, setMenuCoords] = useState<{
top: number;
left: number;
} | null>(null);
useEffect(() => {
if (!menuCoords) return;
function handleClick() {
setMenuCoords(null);
}
function handleCloseRowActions() {
setMenuCoords(null);
}
document.addEventListener("click", handleClick);
document.addEventListener(CLOSE_ROW_ACTIONS_EVENT, handleCloseRowActions);
return () => {
document.removeEventListener("click", handleClick);
document.removeEventListener(
CLOSE_ROW_ACTIONS_EVENT,
handleCloseRowActions,
);
};
}, [menuCoords]);
function closeRightClickDropdown() {
setMenuCoords(null);
}
function handleContextMenu(e: MouseEvent<HTMLDivElement>) {
onContextMenu?.(e);
if (!rightClickDropdown || e.defaultPrevented) return;
e.preventDefault();
e.stopPropagation();
closeRowActionMenus();
const menuWidth = 192;
setMenuCoords({
top: e.clientY,
left: Math.min(e.clientX, window.innerWidth - menuWidth - 8),
});
}
return (
<>
<div
className={cn(
"group flex h-10 items-center border-b border-gray-50 pr-3 transition-colors md:pr-10",
interactive && "cursor-pointer hover:bg-gray-100",
className,
)}
onContextMenu={handleContextMenu}
{...props}
>
{children}
</div>
{menuCoords && rightClickDropdown && (
<div
style={{
position: "fixed",
top: menuCoords.top,
left: menuCoords.left,
}}
className={`z-[120] w-48 overflow-hidden ${GLASS_DROPDOWN}`}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
{typeof rightClickDropdown === "function"
? rightClickDropdown(closeRightClickDropdown)
: rightClickDropdown}
</div>
)}
</>
);
}
export function TableStickyCell({
children,
className,
widthClassName = TABLE_PRIMARY_CELL_WIDTH_CLASS,
bgClassName = TABLE_STICKY_CELL_BG,
header = false,
hover = true,
}: DivProps & {
widthClassName?: string;
bgClassName?: string;
header?: boolean;
hover?: boolean;
}) {
return (
<div
className={cn(
"sticky left-0 z-[60] flex gap-4 pl-4 pr-2 text-left",
widthClassName,
bgClassName,
header
? "items-center self-stretch"
: "py-2 transition-colors",
!header && hover && "group-hover:bg-gray-100",
className,
)}
>
{children}
</div>
);
}
export function TablePrimaryCell({
children,
className,
widthClassName = TABLE_PRIMARY_CELL_WIDTH_CLASS,
bgClassName,
selected,
onSelectionChange,
checkboxTitle,
label,
editing = false,
editValue,
onEditValueChange,
onEditCommit,
onEditCancel,
}: DivProps & {
widthClassName?: string;
bgClassName?: string;
selected: boolean;
onSelectionChange: () => void;
checkboxTitle?: string;
label?: ReactNode;
editing?: boolean;
editValue?: string;
onEditValueChange?: (value: string) => void;
onEditCommit?: () => void;
onEditCancel?: () => void;
}) {
const content =
label !== undefined ? (
editing ? (
<input
autoFocus
value={editValue ?? ""}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onEditCommit?.();
if (e.key === "Escape") onEditCancel?.();
}}
onBlur={onEditCommit}
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">
{label}
</span>
)
) : (
children
);
return (
<TableStickyCell
widthClassName={widthClassName}
bgClassName={bgClassName}
className={className}
>
<div className="flex min-w-0 items-center gap-4">
<input
type="checkbox"
checked={selected}
onChange={onSelectionChange}
onClick={(e) => e.stopPropagation()}
className={TABLE_CHECKBOX_CLASS}
title={checkboxTitle}
/>
{content}
</div>
</TableStickyCell>
);
}
export function TableHeaderCell({ children, className, ...props }: DivProps) {
return (
<div className={cn("shrink-0 text-left", className)} {...props}>
{children}
</div>
);
}
export function TableCell({ children, className, ...props }: DivProps) {
return (
<div
className={cn("shrink-0 truncate text-sm text-gray-500", className)}
{...props}
>
{children}
</div>
);
}
export function TableBody({ children, className, ...props }: DivProps) {
return (
<div className={cn("flex-1", className)} {...props}>
{children}
</div>
);
}
export function TableEmptyState({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"mx-auto flex w-full max-w-xs flex-1 flex-col items-start justify-center py-24",
className,
)}
>
{children}
</div>
);
}

View file

@ -0,0 +1,56 @@
import React from "react";
interface ToolbarItem<T extends string> {
id: T;
label: string;
}
interface Props<T extends string> {
items: ToolbarItem<T>[];
active: T;
onChange: (id: T) => void;
/** Optional content rendered on the right side of the toolbar */
actions?: React.ReactNode;
}
export function TableToolbar<T extends string>({
items,
active,
onChange,
actions,
}: Props<T>) {
const hasItems = items.length > 0;
return (
<div className="flex items-center h-10 px-4 border-b border-gray-200 md:px-10">
{hasItems && (
<div className="flex-1 flex items-center gap-5">
{items.map((item) => (
<button
key={item.id}
onClick={() => onChange(item.id)}
className={`text-xs transition-colors ${
active === item.id
? "font-medium text-gray-700"
: "font-normal text-gray-500 hover:text-gray-700"
}`}
>
{item.label}
</button>
))}
</div>
)}
{actions && (
<div
className={
hasItems
? "flex items-center gap-2"
: "flex flex-1 items-center gap-2"
}
>
{actions}
</div>
)}
</div>
);
}

View file

@ -1,44 +0,0 @@
import React from "react";
interface Tab<T extends string> {
id: T;
label: string;
}
interface Props<T extends string> {
tabs: Tab<T>[];
active: T;
onChange: (id: T) => void;
/** Optional content rendered on the right side of the toolbar */
actions?: React.ReactNode;
}
export function ToolbarTabs<T extends string>({
tabs,
active,
onChange,
actions,
}: Props<T>) {
return (
<div className="flex items-center h-10 px-4 border-b border-gray-200 md:px-10">
<div className="flex-1 flex items-center gap-5">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`text-xs transition-colors ${
active === tab.id
? "font-medium text-gray-700"
: "font-normal text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
</button>
))}
</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
);
}

View file

@ -14,6 +14,8 @@ export interface Project {
id: string;
user_id: string;
is_owner?: boolean;
owner_display_name?: string | null;
owner_email?: string | null;
name: string;
cm_number: string | null;
shared_with: string[];
@ -61,6 +63,7 @@ export interface Chat {
id: string;
project_id: string | null;
user_id: string;
creator_display_name?: string | null;
title: string | null;
created_at: string;
}
@ -92,6 +95,16 @@ export type AssistantEvent =
name: string;
isStreaming?: boolean;
}
| {
type: "mcp_tool_call";
connector_id: string;
connector_name: string;
tool_name: string;
openai_tool_name: string;
status: "ok" | "error";
error?: string;
isStreaming?: boolean;
}
| { type: "thinking"; isStreaming?: boolean }
| {
type: "doc_read";

View file

@ -129,7 +129,7 @@ export function TREditColumnMenu({
{open && (
<div
className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-xl border border-gray-100 bg-white p-3 shadow-lg"
className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-2xl border border-white/70 bg-white p-3 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)] backdrop-blur-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
@ -293,7 +293,7 @@ export function TREditColumnMenu({
!name.trim() ||
!prompt.trim()
}
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 disabled:opacity-40"
className="rounded-full border border-gray-700/40 bg-gray-950/88 px-3 py-1 text-xs font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-colors hover:bg-gray-900/90 disabled:opacity-40"
>
{saving ? "Saving…" : "Save"}
</button>

View file

@ -9,6 +9,11 @@ import type {
} from "../shared/types";
import { TabularCell as TabularCellComponent } from "./TabularCell";
import { TREditColumnMenu } from "./TREditColumnMenu";
import {
TABLE_CHECKBOX_CLASS,
SkeletonDot,
SkeletonLine,
} from "../shared/TablePrimitive";
const SKELETON_COLS = 4;
const SKELETON_ROWS = 5;
@ -72,6 +77,8 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const totalContentWidth =
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
const skeletonContentWidth =
DOC_COL_W_PX + SKELETON_COLS * DATA_COL_W_PX + 32;
useImperativeHandle(ref, () => ({
scrollToCell(colIdx: number, rowIdx: number) {
@ -130,41 +137,48 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
if (loading) {
return (
<div className="flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex border-b border-gray-200">
<div
className={`flex h-8 ${stickyCellBg}`}
style={{ minWidth: skeletonContentWidth }}
>
<div
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`}
className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`}
>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<SkeletonDot />
<span>Document</span>
</div>
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
<div
key={i}
className={`${COL_W} border-r border-gray-200 p-2`}
className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`}
>
<div className="h-4 w-28 rounded bg-gray-100 animate-pulse" />
<SkeletonLine className="h-4 w-28" />
</div>
))}
<div className="flex-1" />
<div className="flex-1 border-b border-gray-200 min-w-8" />
</div>
{/* Rows */}
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
<div
key={row}
className={`flex border-b border-gray-50 ${row % 2 === 0 ? "" : "bg-gray-50/50"}`}
className={`flex h-10 ${row % 2 === 0 ? stickyCellBg : "bg-gray-50"}`}
style={{ minWidth: skeletonContentWidth }}
>
<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 className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2`}>
<SkeletonDot />
<SkeletonLine className="h-4 w-32" />
</div>
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
<div key={col} className={`${COL_W} p-2`}>
<div className="h-4 rounded bg-gray-100 animate-pulse" />
<div
key={col}
className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`}
>
<SkeletonLine className="h-4" />
</div>
))}
<div className="flex-1" />
<div className="flex-1 border-b border-gray-200 min-w-8" />
</div>
))}
</div>
@ -239,7 +253,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
className={TABLE_CHECKBOX_CLASS}
/>
<span>Document</span>
</div>
@ -278,7 +292,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{uploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="flex"
className="flex h-10"
style={{ minWidth: totalContentWidth }}
>
<div
@ -299,7 +313,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
key={col.index}
className={`${COL_W} border-b border-r border-gray-200 p-2`}
>
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse" />
<SkeletonLine className="h-4 w-20" />
</div>
))}
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
@ -324,7 +338,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
type="checkbox"
checked={selectedDocIds.includes(doc.id)}
onChange={() => toggleDoc(doc.id)}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
className={TABLE_CHECKBOX_CLASS}
/>
<span
className="line-clamp-1"

View file

@ -7,6 +7,7 @@ import { AlertCircle, Expand } from "lucide-react";
import type { ColumnConfig, TabularCell as TCell } from "../shared/types";
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
import { getPillClass } from "./pillUtils";
import { SkeletonLine } from "../shared/TablePrimitive";
interface Props {
cell: TCell;
@ -22,6 +23,14 @@ const FLAG_STYLES = {
red: "bg-red-500",
} as const;
function TabularCellSkeleton() {
return (
<div className="flex h-10 items-center px-2">
<SkeletonLine className="h-3.5 w-full" />
</div>
);
}
// Replace citations and pills with inline-code tokens so ReactMarkdown passes
// them through its `code` component, where we render the final UI.
function preprocessCellMarkdown(text: string): {
@ -171,11 +180,7 @@ export function TabularCell({
}, [inlineExpanded]);
if (cell.status === "generating") {
return (
<div className="h-10 px-2 flex items-center">
<div className="h-4 w-full rounded bg-gray-100 animate-pulse" />
</div>
);
return <TabularCellSkeleton />;
}
if (cell.status === "error") {

View file

@ -59,6 +59,7 @@ import { TRChatPanel } from "./TRChatPanel";
import { exportTabularReviewToExcel } from "./exportToExcel";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { PageHeader } from "../shared/PageHeader";
import { TableToolbar } from "../shared/TableToolbar";
interface Props {
reviewId: string;
@ -523,8 +524,7 @@ export function TRView({ reviewId, projectId }: Props) {
}
}
async function handleClearResults() {
const docIds = [...selectedDocIds];
async function clearResultsForDocuments(docIds: string[]) {
if (docIds.length === 0) return;
setCells((prev) =>
prev.map((c) =>
@ -538,6 +538,14 @@ export function TRView({ reviewId, projectId }: Props) {
await clearTabularCells(reviewId, docIds);
}
async function handleClearResults() {
await clearResultsForDocuments([...selectedDocIds]);
}
async function handleClearAllResults() {
await clearResultsForDocuments(documents.map((document) => document.id));
}
async function handleTitleCommit(newTitle: string) {
if (!newTitle || newTitle === review?.title) return;
if (review?.is_owner === false) {
@ -580,7 +588,7 @@ export function TRView({ reviewId, projectId }: Props) {
setTimeout(() => {
router.push(
projectId
? `/projects/${projectId}?tab=reviews`
? `/projects/${projectId}/tabular-reviews`
: "/tabular-reviews",
);
}, 250);
@ -641,7 +649,6 @@ export function TRView({ reviewId, projectId }: Props) {
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<PageHeader
align="start"
shrink
className="gap-4"
breadcrumbs={[
@ -657,7 +664,7 @@ export function TRView({ reviewId, projectId }: Props) {
skeletonClassName: "w-32",
onClick: () =>
router.push(
`/projects/${projectId}?tab=reviews`,
`/projects/${projectId}/tabular-reviews`,
),
title: "Back to project",
}
@ -665,7 +672,7 @@ export function TRView({ reviewId, projectId }: Props) {
label: project?.name ?? "",
onClick: () =>
router.push(
`/projects/${projectId}?tab=reviews`,
`/projects/${projectId}/tabular-reviews`,
),
title: "Back to project",
},
@ -718,6 +725,29 @@ export function TRView({ reviewId, projectId }: Props) {
icon: WandSparkles,
onSelect: requestWorkflow,
},
{
label: "Export",
icon: Download,
onSelect: () =>
exportTabularReviewToExcel({
reviewTitle:
review?.title ||
"Tabular Review",
columns,
documents,
cells,
}),
disabled:
columns.length === 0 ||
documents.length === 0,
},
{
label: "Clear results",
icon: X,
onSelect: handleClearAllResults,
disabled:
documents.length === 0,
},
{
label: "Delete",
icon: Trash2,
@ -729,135 +759,130 @@ export function TRView({ reviewId, projectId }: Props) {
),
},
],
[
{
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>
),
},
],
{
actions: [
{
onClick: () => {
if (!chatOpen) setSidebarOpen(false);
if (chatOpen) setSelectedChatId(null);
setChatOpen((v) => !v);
},
disabled:
loading ||
columns.length === 0 ||
documents.length === 0,
title: chatOpen
? "Close assistant"
: "Open assistant",
icon: chatOpen ? (
<X className="h-4 w-4" />
) : (
<MessageSquare className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
Assistant
</span>
),
},
{
onClick: handleGenerate,
disabled:
generating ||
columns.length === 0 ||
documents.length === 0 ||
savingColumnsConfig,
icon: generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
{generating ? "Running…" : "Run"}
</span>
),
},
],
},
]}
/>
{/* Toolbar */}
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-4">
<button
onClick={() => {
if (!chatOpen) setSidebarOpen(false);
if (chatOpen) setSelectedChatId(null);
setChatOpen((v) => !v);
}}
disabled={loading || columns.length === 0 || documents.length === 0}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
loading || columns.length === 0 || documents.length === 0
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
{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 ? (
<>
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
</>
) : null}
{!loading && selectedDocIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<button
onClick={handleClearResults}
className="w-full px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-gray-50 transition-colors"
>
Clear results
</button>
<button
onClick={handleDeleteDocuments}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
</div>
)}
{!loading && (
<>
<button
onClick={() => setAddDocsOpen(true)}
disabled={savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Upload className="h-3.5 w-3.5" />
Add Documents
</button>
<button
onClick={() => setAddColOpen(true)}
disabled={savingColumn || savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
savingColumn || savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Plus className="h-3.5 w-3.5" />
Add Columns
</button>
</>
)}
</div>
</div>
<TableToolbar
items={[]}
active="table"
onChange={() => undefined}
actions={
<div className="ml-auto flex items-center gap-5">
{loading ? (
<>
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
</>
) : null}
{!loading && selectedDocIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
onClick={() =>
setActionsOpen((v) => !v)
}
className="flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<button
onClick={handleClearResults}
className="w-full px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-gray-50 transition-colors"
>
Clear results
</button>
<button
onClick={handleDeleteDocuments}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
</div>
)}
{!loading && (
<>
<button
onClick={() => setAddDocsOpen(true)}
disabled={savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Upload className="h-3.5 w-3.5" />
Add Documents
</button>
<button
onClick={() => setAddColOpen(true)}
disabled={
savingColumn || savingColumnsConfig
}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
savingColumn || savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Plus className="h-3.5 w-3.5" />
Add Columns
</button>
</>
)}
</div>
}
/>
{/* Table area */}
<div className="flex flex-1 overflow-hidden">

View file

@ -266,7 +266,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{/* Page header */}
<PageHeader
shrink
actionGap="md"
breadcrumbs={[
{
label: "Workflows",

View file

@ -8,7 +8,6 @@ import {
MessageSquare,
User,
ChevronDown,
Check,
} from "lucide-react";
import {
listWorkflows,
@ -21,18 +20,36 @@ import type { Workflow } from "../shared/types";
import { BUILT_IN_WORKFLOWS, BUILT_IN_IDS } from "./builtinWorkflows";
import { DisplayWorkflowModal } from "./DisplayWorkflowModal";
import { NewWorkflowModal } from "./NewWorkflowModal";
import { ToolbarTabs } from "../shared/ToolbarTabs";
import { RowActions } from "../shared/RowActions";
import { TableToolbar } from "../shared/TableToolbar";
import { RowActionMenuItems, RowActions } from "../shared/RowActions";
import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/contexts/AuthContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { workflowDetailPath } from "./workflowRoutes";
import {
GLASS_DROPDOWN,
GLASS_MENU_ITEM,
HeaderFilterDropdown,
} from "../shared/HeaderFilterDropdown";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
TableCell,
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
TableStickyCell,
} from "../shared/TablePrimitive";
type Tab = "all" | "builtin" | "custom" | "hidden";
type WorkflowScope = "all" | "builtin" | "custom" | "hidden";
const NAME_COL_W = "w-[332px] shrink-0";
const TABS: { id: Tab; label: string }[] = [
const WORKFLOW_SCOPES: { id: WorkflowScope; label: string }[] = [
{ id: "all", label: "All" },
{ id: "builtin", label: "Built-in" },
{ id: "custom", label: "Custom" },
@ -42,25 +59,20 @@ const TABS: { id: Tab; label: string }[] = [
export function WorkflowList() {
const router = useRouter();
const { user } = useAuth();
const stickyCellBg = "bg-[#fafbfc]";
const [custom, setCustom] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Workflow | null>(null);
const [activeTab, setActiveTab] = useState<Tab>("all");
const [activeScope, setActiveScope] = useState<WorkflowScope>("all");
const [newModalOpen, setNewModalOpen] = useState(false);
const [hiddenBuiltinIds, setHiddenBuiltinIds] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [practiceFilter, setPracticeFilter] = useState<string | null>(null);
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false);
const [typeFilter, setTypeFilter] = useState<Workflow["type"] | null>(
null,
);
const [typeFilterOpen, setTypeFilterOpen] = useState(false);
const [search, setSearch] = useState("");
const actionsRef = useRef<HTMLDivElement>(null);
const practiceFilterRef = useRef<HTMLDivElement>(null);
const typeFilterRef = useRef<HTMLDivElement>(null);
useEffect(() => {
Promise.all([
@ -79,7 +91,7 @@ export function WorkflowList() {
useEffect(() => {
setSelectedIds([]);
setActionsOpen(false);
}, [activeTab, practiceFilter, typeFilter]);
}, [activeScope, practiceFilter, typeFilter]);
useEffect(() => {
function handleClick(e: MouseEvent) {
@ -94,25 +106,6 @@ export function WorkflowList() {
return () => document.removeEventListener("mousedown", handleClick);
}, [actionsOpen]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
practiceFilterRef.current &&
!practiceFilterRef.current.contains(e.target as Node)
) {
setPracticeFilterOpen(false);
}
if (
typeFilterRef.current &&
!typeFilterRef.current.contains(e.target as Node)
) {
setTypeFilterOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const hiddenBuiltins = BUILT_IN_WORKFLOWS.filter((wf) =>
hiddenBuiltinIds.includes(wf.id),
);
@ -120,19 +113,21 @@ export function WorkflowList() {
(wf) => !hiddenBuiltinIds.includes(wf.id),
);
const all = [...visibleBuiltins, ...custom];
const byTab =
activeTab === "builtin"
const byScope =
activeScope === "builtin"
? visibleBuiltins
: activeTab === "custom"
: activeScope === "custom"
? custom
: activeTab === "hidden"
: activeScope === "hidden"
? hiddenBuiltins
: all;
const practices = Array.from(
new Set(byTab.map((wf) => wf.practice).filter((p): p is string => !!p)),
new Set(
byScope.map((wf) => wf.practice).filter((p): p is string => !!p),
),
).sort();
const q = search.toLowerCase();
const filtered = byTab
const filtered = byScope
.filter((wf) => !practiceFilter || wf.practice === practiceFilter)
.filter((wf) => !typeFilter || wf.type === typeFilter)
.filter((wf) => !q || wf.title.toLowerCase().includes(q));
@ -209,156 +204,71 @@ export function WorkflowList() {
};
const typeFilterButton = (
<div className="relative" ref={typeFilterRef}>
<button
onClick={() => setTypeFilterOpen((o) => !o)}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
typeFilter
? "text-gray-700 hover:text-gray-900"
: "text-gray-500 hover:text-gray-700"
}`}
>
{typeFilter
? typeFilter === "tabular"
? "Tabular"
: "Assistant"
: "Filter by type"}
<ChevronDown className="h-3 w-3" />
</button>
{typeFilterOpen && (
<div className="absolute right-0 top-full mt-1.5 z-20 w-40 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden">
<button
onClick={() => {
setTypeFilter(null);
setTypeFilterOpen(false);
}}
className="flex items-center justify-between w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
All Types
{!typeFilter && (
<Check className="h-3.5 w-3.5 text-gray-400" />
)}
</button>
<div className="border-t border-gray-100" />
{(["assistant", "tabular"] as const).map((t) => {
const { label, Icon, className } = getTypeMeta(t);
return (
<button
key={t}
onClick={() => {
setTypeFilter(t);
setTypeFilterOpen(false);
}}
className="flex items-center justify-between w-full px-3 py-2 text-xs hover:bg-gray-50 transition-colors"
>
<span
className={`inline-flex items-center gap-1.5 font-medium ${className}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</span>
{typeFilter === t && (
<Check className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
</button>
);
})}
</div>
)}
</div>
<HeaderFilterDropdown
label="Filter by type"
value={typeFilter}
allLabel="All Types"
widthClassName="w-40"
options={(["assistant", "tabular"] as const).map((type) => {
const { label, Icon, className } = getTypeMeta(type);
return {
value: type,
label,
icon: Icon,
className,
};
})}
onChange={setTypeFilter}
/>
);
const practiceFilterButton = (
<div className="relative" ref={practiceFilterRef}>
<button
onClick={() => setPracticeFilterOpen((o) => !o)}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
practiceFilter
? "text-gray-700 hover:text-gray-900"
: "text-gray-500 hover:text-gray-700"
}`}
>
{practiceFilter ?? "Filter by practice"}
<ChevronDown className="h-3 w-3" />
</button>
{practiceFilterOpen && (
<div className="absolute right-0 top-full mt-1.5 z-20 w-52 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden">
<button
onClick={() => {
setPracticeFilter(null);
setPracticeFilterOpen(false);
}}
className="flex items-center justify-between w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
All Practices
{!practiceFilter && (
<Check className="h-3.5 w-3.5 text-gray-400" />
)}
</button>
{practices.length > 0 && (
<div className="border-t border-gray-100" />
)}
{practices.map((p) => (
<button
key={p}
onClick={() => {
setPracticeFilter(p);
setPracticeFilterOpen(false);
}}
className="flex items-center justify-between w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<span className="truncate pr-2">{p}</span>
{practiceFilter === p && (
<Check className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
</button>
))}
</div>
)}
</div>
<HeaderFilterDropdown
label="Filter by practice"
value={practiceFilter}
allLabel="All Practices"
options={practices.map((practice) => ({
value: practice,
label: practice,
}))}
onChange={setPracticeFilter}
/>
);
const toolbarActions = (
<>
{selectedIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
{activeTab === "hidden" ? (
<button
onClick={handleBulkUnhide}
className="w-full px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-gray-50 transition-colors"
>
Unhide
</button>
) : (
<button
onClick={handleBulkRemove}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
)}
</div>
)}
</div>
)}
<div className="flex items-center gap-5">
{typeFilterButton}
{practiceFilterButton}
const toolbarActions =
selectedIds.length > 0 ? (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className={`absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden ${GLASS_DROPDOWN}`}>
{activeScope === "hidden" ? (
<button
onClick={handleBulkUnhide}
className={`w-full px-3 py-1.5 text-left text-xs text-gray-700 ${GLASS_MENU_ITEM}`}
>
Unhide
</button>
) : (
<button
onClick={handleBulkRemove}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 transition-colors hover:bg-red-500/10"
>
Delete
</button>
)}
</div>
)}
</div>
</>
);
) : undefined;
return (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{/* Page header */}
<PageHeader
shrink
@ -382,21 +292,20 @@ export function WorkflowList() {
</h1>
</PageHeader>
<ToolbarTabs
tabs={TABS}
active={activeTab}
onChange={setActiveTab}
<TableToolbar
items={WORKFLOW_SCOPES}
active={activeScope}
onChange={setActiveScope}
actions={toolbarActions}
/>
{/* Table */}
<div className="flex-1 overflow-auto">
<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] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
<TableScrollArea>
{/* Column headers */}
<TableHeaderRow>
<TableStickyCell header>
{loading ? (
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<SkeletonDot />
) : (
<input
type="checkbox"
@ -405,46 +314,58 @@ export function WorkflowList() {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
</div>
<div className="ml-auto w-28 shrink-0">Type</div>
<div className="w-40 shrink-0">Practice</div>
<div className="w-28 shrink-0">Source</div>
<div className="w-8 shrink-0" />
</div>
</TableStickyCell>
<TableHeaderCell className="ml-auto w-28">
<div className="flex items-center gap-1">
<span>Type</span>
{typeFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-40">
<div className="flex items-center gap-1">
<span>Practice</span>
{practiceFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-28">Source</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
{loading && activeTab !== "builtin" ? (
<div>
{loading && activeScope !== "builtin" ? (
<TableBody>
{[1, 2, 3].map((i) => (
<div
<TableRow
key={i}
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
interactive={false}
>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}>
<TableStickyCell
hover={false}
>
<div className="flex items-center gap-4">
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
<SkeletonDot />
<SkeletonLine className="h-3.5 w-48" />
</div>
</div>
<div className="ml-auto w-28 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-40 shrink-0">
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-28 shrink-0">
<div className="h-3 w-14 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
</TableStickyCell>
<TableCell className="ml-auto w-28">
<SkeletonLine className="w-16" />
</TableCell>
<TableCell className="w-40">
<SkeletonLine className="w-24" />
</TableCell>
<TableCell className="w-28">
<SkeletonLine className="w-14" />
</TableCell>
<TableCell className="w-8" />
</TableRow>
))}
</div>
</TableBody>
) : filtered.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
{activeTab === "custom" ? (
<TableEmptyState>
{activeScope === "custom" ? (
<>
<Library className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
@ -462,14 +383,14 @@ export function WorkflowList() {
+ Create New
</button>
</>
) : activeTab === "hidden" ? (
) : activeScope === "hidden" ? (
<>
<Library className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Hidden Workflows
</p>
<p className="mt-1 text-xs text-gray-400 text-left">
Built-in workflows you've hidden will
Built-in workflows you&apos;ve hidden will
appear here. You can unhide them at any
time.
</p>
@ -486,33 +407,68 @@ export function WorkflowList() {
</p>
</>
)}
</div>
</TableEmptyState>
) : (
filtered.map((wf) => {
<TableBody>
{filtered.map((wf) => {
const rowBg = selectedIds.includes(wf.id)
? "bg-gray-50"
: stickyCellBg;
: TABLE_STICKY_CELL_BG;
return (
<div
<TableRow
key={wf.id}
rightClickDropdown={
wf.is_system
? activeScope === "hidden"
? (close) => (
<RowActionMenuItems
onClose={close}
onUnhide={() =>
handleUnhideWorkflow(
wf.id,
)
}
/>
)
: (close) => (
<RowActionMenuItems
onClose={close}
onHide={() =>
handleHideWorkflow(
wf.id,
)
}
/>
)
: wf.is_owner === false
? undefined
: (close) => (
<RowActionMenuItems
onClose={close}
onDelete={async () => {
await deleteWorkflow(
wf.id,
);
setCustom((prev) =>
prev.filter(
(w) =>
w.id !==
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-100 cursor-pointer transition-colors"
>
<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">
<TablePrimaryCell
bgClassName={rowBg}
selected={selectedIds.includes(wf.id)}
onSelectionChange={() => toggleOne(wf.id)}
label={wf.title}
/>
<TableCell className="ml-auto w-28">
{(() => {
const { label, Icon, className } =
getTypeMeta(wf.type);
@ -525,8 +481,8 @@ export function WorkflowList() {
</span>
);
})()}
</div>
<div className="w-40 shrink-0">
</TableCell>
<TableCell className="w-40">
{wf.practice ? (
<span className="text-xs font-medium text-gray-600">
{wf.practice}
@ -536,8 +492,8 @@ export function WorkflowList() {
</span>
)}
</div>
<div className="w-28 shrink-0">
</TableCell>
<TableCell className="w-28">
{wf.is_system ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600">
<MikeIcon size={14} />
@ -556,13 +512,13 @@ export function WorkflowList() {
</span>
</span>
)}
</div>
</TableCell>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
{wf.is_system ? (
activeTab === "hidden" ? (
activeScope === "hidden" ? (
<RowActions
onUnhide={() =>
handleUnhideWorkflow(wf.id)
@ -588,12 +544,12 @@ export function WorkflowList() {
/>
)}
</div>
</div>
</TableRow>
);
})
})}
</TableBody>
)}
</div>
</div>
</TableScrollArea>
<DisplayWorkflowModal
workflows={all}

View file

@ -3,7 +3,6 @@
import { useEffect, useRef, useState } from "react";
import {
ChevronDown,
ChevronLeft,
MessageSquare,
Search,
Table2,
@ -16,6 +15,7 @@ import { formatIcon, formatLabel } from "../tabular/columnFormat";
import { TAG_COLORS } from "../tabular/pillUtils";
type WorkflowPreviewMode = "auto" | "prompt" | "columns";
type MobilePickerPane = "list" | "details";
interface WorkflowPickerContentProps {
workflows: Workflow[];
@ -47,6 +47,9 @@ export function WorkflowPickerContent({
allowClearPreview = true,
}: WorkflowPickerContentProps) {
const selectedRowRef = useRef<HTMLButtonElement>(null);
const [mobilePane, setMobilePane] = useState<MobilePickerPane>(
selected ? "details" : "list",
);
useEffect(() => {
if (selectedRowRef.current) {
@ -54,6 +57,10 @@ export function WorkflowPickerContent({
}
}, [selected?.id]);
useEffect(() => {
setMobilePane(selected ? "details" : "list");
}, [selected?.id]);
const normalizedSearch = search.trim().toLowerCase();
const filteredWorkflows = normalizedSearch
? workflows.filter((workflow) =>
@ -74,13 +81,23 @@ export function WorkflowPickerContent({
: workflowType === "all"
? "No workflows found"
: `No ${workflowType} workflows found`);
const handleSelectWorkflow = (workflow: Workflow | null) => {
onSelect(workflow);
setMobilePane(workflow ? "details" : "list");
};
const handleClearPreview = () => {
onSelect(null);
setMobilePane("list");
};
return (
<div className="flex min-h-0 flex-1 flex-row gap-3 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden md:flex-row">
<div
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
className={`min-h-0 flex-1 flex-col overflow-hidden ${
selected ? "md:w-80 md:flex-none md:shrink-0" : ""
} ${mobilePane === "details" && selected ? "hidden md:flex" : "flex"}`}
>
<div className="shrink-0 px-2 pb-2 pt-3">
<div className="shrink-0 pb-2 pt-3">
<div className="flex h-9 items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<input
@ -104,80 +121,90 @@ export function WorkflowPickerContent({
</div>
</div>
{loading ? (
<div className="space-y-1">
{[60, 45, 75, 50, 65, 40, 55].map((width, index) => (
<div
key={index}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
>
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${width}%` }}
/>
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-gray-100" />
</div>
))}
</div>
) : filteredWorkflows.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-400">
{resolvedEmptyMessage}
</p>
) : (
<div className="space-y-1 overflow-y-auto">
{filteredWorkflows.map((workflow) => {
const disabled = disabledWorkflow?.(workflow) ?? false;
const isSelected = selected?.id === workflow.id;
const TypeIcon =
workflow.type === "tabular"
? Table2
: MessageSquare;
return (
<button
key={workflow.id}
ref={isSelected ? selectedRowRef : null}
type="button"
disabled={disabled}
onClick={() =>
onSelect(isSelected ? null : workflow)
}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-colors ${
isSelected
? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50"
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
>
<span
className={`flex-1 truncate ${
isSelected
? "font-medium text-gray-900"
: "text-gray-700"
}`}
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white">
{loading ? (
<div>
{[60, 45, 75, 50, 65, 40, 55].map(
(width, index) => (
<div
key={index}
className="flex items-center justify-between gap-3 px-3 py-2.5"
>
{workflow.title}
</span>
{showTypeIcon ? (
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<span className="shrink-0 text-xs text-gray-400">
{workflow.is_system
? "Built-in"
: "Custom"}
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${width}%` }}
/>
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-gray-100" />
</div>
),
)}
</div>
) : filteredWorkflows.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-400">
{resolvedEmptyMessage}
</p>
) : (
<div>
{filteredWorkflows.map((workflow) => {
const disabled =
disabledWorkflow?.(workflow) ?? false;
const isSelected = selected?.id === workflow.id;
const TypeIcon =
workflow.type === "tabular"
? Table2
: MessageSquare;
return (
<button
key={workflow.id}
ref={isSelected ? selectedRowRef : null}
type="button"
disabled={disabled}
onClick={() =>
handleSelectWorkflow(
isSelected ? null : workflow,
)
}
className={`flex w-full items-center gap-3 px-3 py-2 text-left text-xs transition-colors ${
isSelected
? "bg-gray-50 text-gray-900"
: "hover:bg-gray-50"
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
>
<span
className={`flex-1 truncate ${
isSelected
? "font-medium text-gray-900"
: "text-gray-700"
}`}
>
{workflow.title}
</span>
)}
</button>
);
})}
</div>
)}
{showTypeIcon ? (
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<span className="shrink-0 text-xs text-gray-400">
{workflow.is_system
? "Built-in"
: "Custom"}
</span>
)}
</button>
);
})}
</div>
)}
</div>
</div>
{selected && (
<WorkflowPreview
workflow={selected}
mode={previewMode}
onClear={() => onSelect(null)}
onClear={handleClearPreview}
allowClear={allowClearPreview}
className={
mobilePane === "details" ? "flex" : "hidden md:flex"
}
/>
)}
</div>
@ -189,11 +216,13 @@ function WorkflowPreview({
mode,
onClear,
allowClear,
className = "flex",
}: {
workflow: Workflow;
mode: WorkflowPreviewMode;
onClear: () => void;
allowClear: boolean;
className?: string;
}) {
const resolvedMode =
mode === "auto"
@ -202,40 +231,53 @@ function WorkflowPreview({
: "prompt"
: mode;
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between pb-2 pt-3">
<p className="text-sm font-medium text-gray-700">
Workflow Details
</p>
{allowClear ? (
<button
type="button"
onClick={onClear}
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
) : null}
<div
className={`${className} min-h-0 flex-1 flex-col overflow-hidden pt-3`}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-gray-200 bg-white">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-3">
<p className="truncate text-sm font-medium text-gray-700">
{workflow.title}
</p>
{allowClear ? (
<button
type="button"
onClick={onClear}
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{resolvedMode === "columns" ? (
<WorkflowColumnPreview
columns={workflow.columns_config ?? []}
/>
) : (
<WorkflowPromptPreview
content={workflow.prompt_md ?? "_No prompt defined._"}
/>
)}
</div>
{resolvedMode === "columns" ? (
<WorkflowColumnPreview columns={workflow.columns_config ?? []} />
) : (
<WorkflowPromptPreview
content={workflow.prompt_md ?? "_No prompt defined._"}
/>
)}
</div>
);
}
function WorkflowPromptPreview({ content }: { content: string }) {
const previewContent = stripLeadingMarkdownHeading(content);
return (
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
<WorkflowPromptMarkdown content={content} />
<div className="flex-1 overflow-y-auto bg-gray-50 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
<WorkflowPromptMarkdown content={previewContent} />
</div>
);
}
function stripLeadingMarkdownHeading(content: string) {
const stripped = content.replace(/^\s{0,3}#{1,6}\s+[^\n]+(?:\n+|$)/, "");
return stripped.trimStart() || content;
}
function WorkflowPromptMarkdown({ content }: { content: string }) {
return (
<ReactMarkdown
@ -287,7 +329,7 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
return (
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50">
<div className="flex-1 overflow-y-auto bg-gray-50">
{sortedColumns.length === 0 ? (
<p className="px-4 py-6 text-center text-xs text-gray-400">
No columns defined

View file

@ -623,6 +623,50 @@ export function useAssistantChat({
continue;
}
if (data.type === "mcp_tool_start") {
pushEvent({
type: "mcp_tool_call",
connector_id: "",
connector_name: "",
tool_name: (data.name as string) ?? "",
openai_tool_name: (data.name as string) ?? "",
status: "ok",
isStreaming: true,
});
continue;
}
if (data.type === "mcp_tool_result") {
const openaiToolName = (data.name as string) ?? "";
updateMatchingEvent(
(e) =>
e.type === "mcp_tool_call" &&
e.openai_tool_name === openaiToolName &&
!!e.isStreaming,
() => ({
type: "mcp_tool_call",
connector_id: "",
connector_name:
typeof data.connector_name === "string"
? (data.connector_name as string)
: "",
tool_name:
typeof data.tool_name === "string"
? (data.tool_name as string)
: openaiToolName,
openai_tool_name: openaiToolName,
status: data.status === "error" ? "error" : "ok",
error:
typeof data.error === "string"
? (data.error as string)
: undefined,
isStreaming: false,
}),
);
pushThinkingPlaceholder();
continue;
}
if (data.type === "courtlistener_search_case_law_start") {
pushEvent({
type: "courtlistener_search_case_law",

View file

@ -288,6 +288,120 @@ export async function saveApiKey(
});
}
export interface McpToolSummary {
id: string;
toolName: string;
openaiToolName: string;
title: string | null;
description: string | null;
enabled: boolean;
readOnly: boolean;
destructive: boolean;
requiresConfirmation: boolean;
lastSeenAt: string;
}
export interface McpConnectorSummary {
id: string;
name: string;
transport: "streamable_http";
serverUrl: string;
authType: "none" | "bearer" | "oauth";
enabled: boolean;
hasAuthConfig: boolean;
customHeaderKeys: string[];
oauthConnected: boolean;
toolPolicy: Record<string, unknown>;
tools: McpToolSummary[];
toolCount: number;
createdAt: string;
updatedAt: string;
}
export async function listMcpConnectors(): Promise<McpConnectorSummary[]> {
return apiRequest<McpConnectorSummary[]>("/user/mcp-connectors");
}
export async function getMcpConnector(
connectorId: string,
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}`,
);
}
export async function createMcpConnector(payload: {
name: string;
serverUrl: string;
bearerToken?: string | null;
headers?: Record<string, string>;
}): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>("/user/mcp-connectors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function updateMcpConnector(
connectorId: string,
payload: {
name?: string;
serverUrl?: string;
enabled?: boolean;
bearerToken?: string | null;
headers?: Record<string, string>;
},
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
}
export async function deleteMcpConnector(connectorId: string): Promise<void> {
return apiRequest<void>(`/user/mcp-connectors/${connectorId}`, {
method: "DELETE",
});
}
export async function refreshMcpConnectorTools(
connectorId: string,
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}/refresh-tools`,
{ method: "POST" },
);
}
export async function startMcpConnectorOAuth(
connectorId: string,
): Promise<{ authorizationUrl: string | null; alreadyAuthorized: boolean }> {
return apiRequest<{ authorizationUrl: string | null; alreadyAuthorized: boolean }>(
`/user/mcp-connectors/${connectorId}/oauth/start`,
{ method: "POST" },
);
}
export async function setMcpToolEnabled(
connectorId: string,
toolId: string,
enabled: boolean,
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}/tools/${toolId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
},
);
}
export async function getProject(projectId: string): Promise<Project> {
return apiRequest<Project>(`/projects/${projectId}`);
}

View file

@ -13,6 +13,12 @@ const authGlassCardClassName =
"rounded-2xl border border-white/70 bg-white/72 p-8 shadow-[0_4px_14px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-8px_18px_rgba(255,255,255,0.12)] backdrop-blur-2xl";
const authInputClassName =
"rounded-lg border border-transparent bg-gray-100 px-3 shadow-none focus-visible:border-gray-200 focus-visible:ring-2 focus-visible:ring-gray-300/45";
const authToggleClassName =
"flex gap-1 rounded-full bg-gray-200 p-1 text-xs font-medium";
const authToggleActiveClassName =
"inline-flex h-6 items-center rounded-full border border-white/80 bg-white/86 px-3 text-gray-900 shadow-[0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-3px_7px_rgba(229,231,235,0.32)] backdrop-blur-xl";
const authToggleInactiveClassName =
"inline-flex h-6 items-center rounded-full border border-transparent px-3 text-gray-500 transition-colors hover:bg-white/38 hover:text-gray-900";
export default function LoginPage() {
const router = useRouter();
@ -58,16 +64,16 @@ export default function LoginPage() {
{/* Login Form */}
<div className={`${authGlassCardClassName} mb-4`}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-left text-2xl font-serif">
<h2 className="text-left text-2xl font-medium font-serif text-gray-950">
Log In
</h2>
<div className="bg-gray-200/70 p-1 rounded-lg flex text-xs font-medium shadow-[inset_0_1px_0_rgba(255,255,255,0.65),inset_0_-3px_8px_rgba(148,163,184,0.16)] backdrop-blur-xl">
<span className="text-gray-700 px-3 py-1 bg-white/85 rounded-md shadow-[0_1px_4px_rgba(15,23,42,0.06)]">
<div className={authToggleClassName}>
<span className={authToggleActiveClassName}>
Log in
</span>
<Link
href="/signup"
className="px-3 py-1 text-gray-500 hover:text-gray-900"
className={authToggleInactiveClassName}
>
Sign up
</Link>
@ -125,12 +131,6 @@ export default function LoginPage() {
</Button>
</form>
</div>
<p className="text-center text-xs text-gray-500 leading-relaxed px-2">
Mike hosted on MikeOSS.com is currently a demo service.
Please do not upload, submit, or store sensitive,
confidential, privileged, client, or personally
identifiable documents.
</p>
</div>
</div>
);

View file

@ -15,6 +15,12 @@ const authGlassCardClassName =
"rounded-2xl border border-white/70 bg-white/72 p-8 shadow-[0_4px_14px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-8px_18px_rgba(255,255,255,0.12)] backdrop-blur-2xl";
const authInputClassName =
"rounded-lg border border-transparent bg-gray-100 px-3 shadow-none focus-visible:border-gray-200 focus-visible:ring-2 focus-visible:ring-gray-300/45";
const authToggleClassName =
"flex gap-1 rounded-full bg-gray-200 p-1 text-xs font-medium";
const authToggleActiveClassName =
"inline-flex h-6 items-center rounded-full border border-white/80 bg-white/86 px-3 text-gray-900 shadow-[0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-3px_7px_rgba(229,231,235,0.32)] backdrop-blur-xl";
const authToggleInactiveClassName =
"inline-flex h-6 items-center rounded-full border border-transparent px-3 text-gray-500 transition-colors hover:bg-white/38 hover:text-gray-900";
export default function SignupPage() {
const router = useRouter();
@ -107,7 +113,7 @@ export default function SignupPage() {
<div className="mx-auto w-12 h-12 bg-green-50 rounded-full flex items-center justify-center mb-6">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-3">
<h2 className="text-2xl font-bold text-gray-950 mb-3">
Account created!
</h2>
<p className="text-gray-600 leading-relaxed">
@ -128,17 +134,17 @@ export default function SignupPage() {
<div className="w-full max-w-md">
<div className={`${authGlassCardClassName} mb-4`}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-left text-2xl font-serif">
<h2 className="text-left text-2xl font-medium font-serif text-gray-950">
Create Account
</h2>
<div className="bg-gray-200/70 p-1 rounded-lg flex text-xs font-medium shadow-[inset_0_1px_0_rgba(255,255,255,0.65),inset_0_-3px_8px_rgba(148,163,184,0.16)] backdrop-blur-xl">
<div className={authToggleClassName}>
<Link
href="/login"
className="px-3 py-1 text-gray-500 hover:text-gray-900"
className={authToggleInactiveClassName}
>
Log in
</Link>
<span className="px-3 py-1 bg-white/85 rounded-md shadow-[0_1px_4px_rgba(15,23,42,0.06)] text-gray-900">
<span className={authToggleActiveClassName}>
Sign up
</span>
</div>
@ -280,12 +286,6 @@ export default function SignupPage() {
</Link>
</div>
</div>
<p className="text-center text-xs text-gray-500 leading-relaxed px-2">
Mike hosted on MikeOSS.com is currently a demo service.
Please do not upload, submit, or store sensitive,
confidential, privileged, client, or personally identifiable
documents.
</p>
</div>
</div>
);