Merge pull request #215 from Open-Legal-Products/library-ui-updates

feat: add library and refresh shared table UI
This commit is contained in:
cosimoastrada 2026-07-16 20:09:55 +08:00 committed by GitHub
commit 1d61634a37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
99 changed files with 9859 additions and 5241 deletions

View file

@ -0,0 +1,71 @@
-- Migration date: 2026-07-10
alter table public.documents
add column if not exists library_kind text default 'file';
update public.documents
set library_kind = 'file'
where library_kind is null;
alter table public.documents
alter column library_kind set default 'file',
alter column library_kind set not null;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'documents_library_kind_check'
and conrelid = 'public.documents'::regclass
) then
alter table public.documents
add constraint documents_library_kind_check
check (library_kind in ('file', 'template'));
end if;
end;
$$;
create table if not exists public.library_folders (
id uuid primary key default gen_random_uuid(),
user_id text not null,
library_kind text not null default 'file',
name text not null,
parent_folder_id uuid references public.library_folders(id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint library_folders_kind_check
check (library_kind in ('file', 'template'))
);
alter table public.documents
add column if not exists library_folder_id uuid;
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'documents_library_folder_id_fkey'
and conrelid = 'public.documents'::regclass
) then
alter table public.documents
add constraint documents_library_folder_id_fkey
foreign key (library_folder_id)
references public.library_folders(id)
on delete set null;
end if;
end;
$$;
create index if not exists idx_library_folders_user_kind
on public.library_folders(user_id, library_kind);
create index if not exists idx_library_folders_parent
on public.library_folders(parent_folder_id);
create index if not exists idx_documents_library_kind_folder
on public.documents(user_id, library_kind, library_folder_id)
where project_id is null;
revoke all on public.library_folders from anon, authenticated;

View file

@ -222,14 +222,36 @@ create table if not exists public.project_subfolders (
create index if not exists idx_project_subfolders_project
on public.project_subfolders(project_id);
create table if not exists public.library_folders (
id uuid primary key default gen_random_uuid(),
user_id text not null,
library_kind text not null default 'file',
name text not null,
parent_folder_id uuid references public.library_folders(id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint library_folders_kind_check
check (library_kind in ('file', 'template'))
);
create index if not exists idx_library_folders_user_kind
on public.library_folders(user_id, library_kind);
create index if not exists idx_library_folders_parent
on public.library_folders(parent_folder_id);
create table if not exists public.documents (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
user_id text not null,
status text not null default 'pending',
folder_id uuid references public.project_subfolders(id) on delete set null,
library_kind text not null default 'file',
library_folder_id uuid references public.library_folders(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
updated_at timestamptz not null default now(),
constraint documents_library_kind_check
check (library_kind in ('file', 'template'))
);
create index if not exists idx_documents_user_project
@ -238,6 +260,10 @@ create index if not exists idx_documents_user_project
create index if not exists idx_documents_project_folder
on public.documents(project_id, folder_id);
create index if not exists idx_documents_library_kind_folder
on public.documents(user_id, library_kind, library_folder_id)
where project_id is null;
create table if not exists public.document_versions (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade,
@ -837,6 +863,7 @@ alter table public.courtlistener_opinion_cluster_index enable row level security
revoke all on public.user_profiles from anon, authenticated;
revoke all on public.projects from anon, authenticated;
revoke all on public.project_subfolders from anon, authenticated;
revoke all on public.library_folders from anon, authenticated;
revoke all on public.documents from anon, authenticated;
revoke all on public.document_versions from anon, authenticated;
revoke all on public.document_edits from anon, authenticated;

View file

@ -7,6 +7,7 @@ import { chatRouter } from "./routes/chat";
import { projectsRouter } from "./routes/projects";
import { projectChatRouter } from "./routes/projectChat";
import { documentsRouter } from "./routes/documents";
import { libraryRouter } from "./routes/library";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
@ -127,6 +128,7 @@ app.post("/tabular-review/:reviewId/generate", chatLimiter);
app.post("/chat/create", chatCreateLimiter);
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/library/:kind/documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.put(
"/single-documents/:documentId/versions/:versionId/file",
@ -149,6 +151,7 @@ app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
app.use("/library", libraryRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);

View file

@ -62,6 +62,11 @@ const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE:
- Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text.
- Refer to documents by filename or a natural description, such as "the NDA draft".
REASONING TRACE SAFETY:
- If reasoning or thought summaries are shown to the user, keep them as brief natural-language progress summaries.
- Do not expose source code, JSON snippets, tool arguments, API payloads, schemas, raw citations JSON, internal prompts, or implementation details in reasoning traces.
- Do not use code fences or structured data blocks in reasoning traces.
GENERAL GUIDANCE:
- Cite the exact document or fetched opinion passage for evidence-backed claims.
- If no documents are provided, answer from legal knowledge.

File diff suppressed because one or more lines are too long

View file

@ -65,6 +65,7 @@ documentsRouter.get("/", requireAuth, async (req, res) => {
.select("*")
.eq("user_id", userId)
.is("project_id", null)
.or("library_kind.eq.file,library_kind.is.null")
.order("created_at", { ascending: false });
if (error) return void res.status(500).json({ detail: error.message });
const docs = (data ?? []) as unknown as {
@ -84,7 +85,9 @@ documentsRouter.post(
async (req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
await handleDocumentUpload(req, res, userId, null, db);
await handleDocumentUpload(req, res, userId, null, db, {
libraryKind: "file",
});
},
);
@ -426,9 +429,13 @@ documentsRouter.post(
if (!sourceAccess.ok)
return void res.status(404).json({ detail: "Source document not found" });
const willDeleteSource =
sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id;
(sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id) ||
(!sourceDoc.project_id &&
!targetDoc.project_id &&
sourceDoc.user_id === userId &&
targetDoc.user_id === userId);
if (willDeleteSource && !sourceAccess.isOwner) {
return void res.status(403).json({
detail: "Only the source document owner can move it into a version.",
@ -1283,12 +1290,16 @@ documentsRouter.post(
(req, res) => void handleEditResolution(req, res, "reject"),
);
async function handleDocumentUpload(
export async function handleDocumentUpload(
req: import("express").Request,
res: import("express").Response,
userId: string,
projectId: string | null,
db: ReturnType<typeof createServerSupabase>,
options: {
libraryKind?: "file" | "template";
libraryFolderId?: string | null;
} = {},
) {
const file = req.file;
if (!file) return void res.status(400).json({ detail: "file is required" });
@ -1311,6 +1322,8 @@ async function handleDocumentUpload(
project_id: projectId,
user_id: userId,
status: "processing",
library_kind: options.libraryKind ?? "file",
library_folder_id: options.libraryFolderId ?? null,
})
.select("*")
.single();
@ -1417,6 +1430,8 @@ async function handleDocumentUpload(
filename,
storage_path: key,
pdf_storage_path: pdfStoragePath,
folder_id:
(updated.library_folder_id as string | null | undefined) ?? null,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,

View file

@ -0,0 +1,414 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { deleteFile } from "../lib/storage";
import {
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../lib/documentVersions";
import { singleFileUpload } from "../lib/upload";
import { handleDocumentUpload } from "./documents";
export const libraryRouter = Router();
type LibraryKind = "file" | "template";
function normalizeLibraryKind(value: unknown): LibraryKind | null {
if (value === "file" || value === "files") return "file";
if (value === "template" || value === "templates") return "template";
return null;
}
function normalizeDocumentFilename(nextName: unknown, currentName: string) {
if (typeof nextName !== "string") return null;
const trimmed = nextName.trim().slice(0, 200);
if (!trimmed) return null;
if (/\.[a-z0-9]{1,6}$/i.test(trimmed)) return trimmed;
const ext = currentName.match(/\.[a-z0-9]{1,6}$/i)?.[0] ?? "";
return `${trimmed}${ext}`;
}
function mapLibraryDocument<T extends Record<string, unknown>>(doc: T) {
return {
...doc,
folder_id: (doc.library_folder_id as string | null | undefined) ?? null,
};
}
async function loadLibraryFolder(
db: ReturnType<typeof createServerSupabase>,
userId: string,
kind: LibraryKind,
folderId: string,
): Promise<{ id: string; parent_folder_id: string | null } | null> {
const { data } = await db
.from("library_folders")
.select("id, parent_folder_id")
.eq("id", folderId)
.eq("user_id", userId)
.eq("library_kind", kind)
.maybeSingle();
return (data as { id: string; parent_folder_id: string | null } | null) ?? null;
}
async function deleteLibraryDocumentsAndVersionFiles(
db: ReturnType<typeof createServerSupabase>,
userId: string,
kind: LibraryKind,
documentIds: string[],
) {
if (documentIds.length === 0) return null;
const { data: versions, error: versionsError } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.in("document_id", documentIds);
if (versionsError) return versionsError;
const paths = new Set<string>();
for (const version of versions ?? []) {
if (typeof version.storage_path === "string" && version.storage_path) {
paths.add(version.storage_path);
}
if (
typeof version.pdf_storage_path === "string" &&
version.pdf_storage_path
) {
paths.add(version.pdf_storage_path);
}
}
await Promise.all([...paths].map((path) => deleteFile(path).catch(() => {})));
let deleteQuery = db
.from("documents")
.delete()
.eq("user_id", userId)
.is("project_id", null);
deleteQuery =
kind === "file"
? deleteQuery.or("library_kind.eq.file,library_kind.is.null")
: deleteQuery.eq("library_kind", kind);
const { error } = await deleteQuery.in("id", documentIds);
return error ?? null;
}
// GET /library/:kind
libraryRouter.get("/:kind", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const db = createServerSupabase();
let documentsQuery = db
.from("documents")
.select("*")
.eq("user_id", userId)
.is("project_id", null);
documentsQuery =
kind === "file"
? documentsQuery.or("library_kind.eq.file,library_kind.is.null")
: documentsQuery.eq("library_kind", kind);
const [{ data: docs, error: docsError }, { data: folders, error: foldersError }] =
await Promise.all([
documentsQuery.order("created_at", { ascending: true }),
db
.from("library_folders")
.select("*")
.eq("user_id", userId)
.eq("library_kind", kind)
.order("created_at", { ascending: true }),
]);
if (docsError) return void res.status(500).json({ detail: docsError.message });
if (foldersError)
return void res.status(500).json({ detail: foldersError.message });
const docsTyped = (docs ?? []).map(mapLibraryDocument) as {
id: string;
current_version_id?: string | null;
}[];
await attachLatestVersionNumbers(db, docsTyped);
await attachActiveVersionPaths(db, docsTyped);
res.json({ documents: docsTyped, folders: folders ?? [] });
});
// POST /library/:kind/documents
libraryRouter.post(
"/:kind/documents",
requireAuth,
singleFileUpload("file"),
async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const db = createServerSupabase();
await handleDocumentUpload(req, res, userId, null, db, {
libraryKind: kind,
});
},
);
// POST /library/:kind/folders
libraryRouter.post("/:kind/folders", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { name, parent_folder_id } = req.body as {
name?: string;
parent_folder_id?: string | null;
};
if (!name?.trim())
return void res.status(400).json({ detail: "name is required" });
const db = createServerSupabase();
if (parent_folder_id) {
const parent = await loadLibraryFolder(db, userId, kind, parent_folder_id);
if (!parent)
return void res.status(404).json({ detail: "Parent folder not found" });
}
const { data, error } = await db
.from("library_folders")
.insert({
user_id: userId,
library_kind: kind,
name: name.trim(),
parent_folder_id: parent_folder_id ?? null,
})
.select("*")
.single();
if (error) return void res.status(500).json({ detail: error.message });
res.status(201).json(data);
});
// PATCH /library/:kind/folders/:folderId
libraryRouter.patch("/:kind/folders/:folderId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { folderId } = req.params;
const body = req.body as { name?: string; parent_folder_id?: string | null };
const db = createServerSupabase();
const folder = await loadLibraryFolder(db, userId, kind, folderId);
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
const updates: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (body.name != null) {
const trimmed = body.name.trim();
if (!trimmed)
return void res.status(400).json({ detail: "name is required" });
updates.name = trimmed;
}
if ("parent_folder_id" in body) {
if (body.parent_folder_id) {
let cur: string | null = body.parent_folder_id;
while (cur) {
if (cur === folderId) {
return void res.status(400).json({
detail: "Cannot move a folder into itself or a descendant",
});
}
const parent = await loadLibraryFolder(db, userId, kind, cur);
if (!parent)
return void res.status(404).json({ detail: "Parent folder not found" });
cur = parent.parent_folder_id ?? null;
}
}
updates.parent_folder_id = body.parent_folder_id ?? null;
}
const { data, error } = await db
.from("library_folders")
.update(updates)
.eq("id", folderId)
.eq("user_id", userId)
.eq("library_kind", kind)
.select("*")
.single();
if (error || !data)
return void res.status(404).json({ detail: "Folder not found" });
res.json(data);
});
// DELETE /library/:kind/folders/:folderId
libraryRouter.delete("/:kind/folders/:folderId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { folderId } = req.params;
const db = createServerSupabase();
const { data: allFolders, error: foldersError } = await db
.from("library_folders")
.select("id, parent_folder_id")
.eq("user_id", userId)
.eq("library_kind", kind);
if (foldersError)
return void res.status(500).json({ detail: foldersError.message });
if (!(allFolders ?? []).some((folder) => folder.id === folderId)) {
return void res.status(404).json({ detail: "Folder not found" });
}
const childrenByParent = new Map<string, string[]>();
for (const folder of allFolders ?? []) {
const parentId = folder.parent_folder_id as string | null;
if (!parentId) continue;
const children = childrenByParent.get(parentId) ?? [];
children.push(folder.id as string);
childrenByParent.set(parentId, children);
}
const folderIds = new Set<string>();
const stack = [folderId];
while (stack.length > 0) {
const id = stack.pop()!;
if (folderIds.has(id)) continue;
folderIds.add(id);
stack.push(...(childrenByParent.get(id) ?? []));
}
let documentsInFolderQuery = db
.from("documents")
.select("id")
.eq("user_id", userId)
.is("project_id", null);
documentsInFolderQuery =
kind === "file"
? documentsInFolderQuery.or("library_kind.eq.file,library_kind.is.null")
: documentsInFolderQuery.eq("library_kind", kind);
const { data: docs, error: docsError } = await documentsInFolderQuery.in(
"library_folder_id",
[...folderIds],
);
if (docsError) return void res.status(500).json({ detail: docsError.message });
const docIds = (docs ?? []).map((doc) => doc.id as string);
const deleteDocsError = await deleteLibraryDocumentsAndVersionFiles(
db,
userId,
kind,
docIds,
);
if (deleteDocsError)
return void res.status(500).json({ detail: deleteDocsError.message });
const { error } = await db
.from("library_folders")
.delete()
.eq("id", folderId)
.eq("user_id", userId)
.eq("library_kind", kind);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
});
// PATCH /library/:kind/documents/:documentId/folder
libraryRouter.patch(
"/:kind/documents/:documentId/folder",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { documentId } = req.params;
const { folder_id } = req.body as { folder_id: string | null };
const db = createServerSupabase();
if (folder_id) {
const folder = await loadLibraryFolder(db, userId, kind, folder_id);
if (!folder)
return void res.status(404).json({ detail: "Folder not found" });
}
let moveQuery = db
.from("documents")
.update({
library_folder_id: folder_id ?? null,
updated_at: new Date().toISOString(),
})
.eq("id", documentId)
.eq("user_id", userId)
.is("project_id", null);
moveQuery =
kind === "file"
? moveQuery.or("library_kind.eq.file,library_kind.is.null")
: moveQuery.eq("library_kind", kind);
const { data, error } = await moveQuery
.select("*")
.single();
if (error || !data)
return void res.status(404).json({ detail: "Document not found" });
res.json(mapLibraryDocument(data));
},
);
// PATCH /library/:kind/documents/:documentId
libraryRouter.patch(
"/:kind/documents/:documentId",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const kind = normalizeLibraryKind(req.params.kind);
if (!kind) return void res.status(404).json({ detail: "Library not found" });
const { documentId } = req.params;
const db = createServerSupabase();
let docQuery = db
.from("documents")
.select("id, current_version_id")
.eq("id", documentId)
.eq("user_id", userId)
.is("project_id", null);
docQuery =
kind === "file"
? docQuery.or("library_kind.eq.file,library_kind.is.null")
: docQuery.eq("library_kind", kind);
const { data: doc } = await docQuery.single();
if (!doc) return void res.status(404).json({ detail: "Document not found" });
const active = doc.current_version_id
? await db
.from("document_versions")
.select("filename")
.eq("id", doc.current_version_id)
.eq("document_id", documentId)
.single()
: null;
const currentName =
typeof active?.data?.filename === "string" && active.data.filename.trim()
? active.data.filename.trim()
: "Untitled document";
const filename = normalizeDocumentFilename(req.body?.filename, currentName);
if (!filename)
return void res.status(400).json({ detail: "filename is required" });
let updateQuery = db
.from("documents")
.update({ updated_at: new Date().toISOString() })
.eq("id", documentId)
.eq("user_id", userId)
.is("project_id", null);
updateQuery =
kind === "file"
? updateQuery.or("library_kind.eq.file,library_kind.is.null")
: updateQuery.eq("library_kind", kind);
const { data: updated, error } = await updateQuery
.select("*")
.single();
if (error || !updated)
return void res.status(404).json({ detail: "Document not found" });
if (doc.current_version_id) {
await db
.from("document_versions")
.update({ filename })
.eq("id", doc.current_version_id)
.eq("document_id", documentId);
}
res.json(mapLibraryDocument({ ...updated, filename }));
},
);

View file

@ -453,7 +453,11 @@ projectsRouter.post(
// Standalone → assign project_id
const { data: updated, error } = await db
.from("documents")
.update({ project_id: projectId, updated_at: new Date().toISOString() })
.update({
project_id: projectId,
library_folder_id: null,
updated_at: new Date().toISOString(),
})
.eq("id", documentId)
.select("*")
.single();

View file

@ -997,6 +997,29 @@ tabularRouter.delete(
},
);
// PATCH /tabular-review/:reviewId/chats/:chatId — rename a chat
tabularRouter.patch(
"/:reviewId/chats/:chatId",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const { chatId } = req.params;
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
if (!title)
return void res.status(400).json({ detail: "Title is required" });
const db = createServerSupabase();
// Owner-only rename — mirrors the delete rule above.
const { error } = await db
.from("tabular_review_chats")
.update({ title: title.slice(0, 200) })
.eq("id", chatId)
.eq("user_id", userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
},
);
// GET /tabular-review/:reviewId/chats/:chatId/messages — messages for a single chat
tabularRouter.get(
"/:reviewId/chats/:chatId/messages",

View file

@ -0,0 +1,30 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7AB8FF" stop-opacity="0.96"/>
<stop offset="0.45" stop-color="#4F92F7" stop-opacity="0.95"/>
<stop offset="1" stop-color="#2563EB" stop-opacity="0.97"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.12"/>
<stop offset="1" stop-color="#172554" stop-opacity="0.45"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="clip">
<path d="M18 6 H46 C52.6 6 58 11.4 58 18 V34 C58 40.6 52.6 46 46 46 H30 L19.4 55.4 C17.7 56.9 15 55.7 15 53.4 V45.6 C9.8 44.2 6 39.5 6 34 V18 C6 11.4 11.4 6 18 6 Z"/>
</clipPath>
</defs>
<path d="M18 6 H46 C52.6 6 58 11.4 58 18 V34 C58 40.6 52.6 46 46 46 H30 L19.4 55.4 C17.7 56.9 15 55.7 15 53.4 V45.6 C9.8 44.2 6 39.5 6 34 V18 C6 11.4 11.4 6 18 6 Z"
fill="url(#body)" stroke="url(#rim)" stroke-width="1.5"/>
<g clip-path="url(#clip)">
<ellipse cx="32" cy="10" rx="23" ry="6" fill="url(#sheen)" opacity="0.28"/>
<ellipse cx="14" cy="18" rx="5" ry="10" fill="url(#sheen)" opacity="0.16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,44 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F9D66A"/>
<stop offset="1" stop-color="#D69B1B"/>
</linearGradient>
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFE68A" stop-opacity="0.98"/>
<stop offset="0.5" stop-color="#F5BE3F" stop-opacity="0.98"/>
<stop offset="1" stop-color="#D89414" stop-opacity="0.98"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.42"/>
</linearGradient>
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.46"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="clipFront">
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"/>
</clipPath>
</defs>
<!-- back panel with tab, extends to bottom -->
<path d="M8 47 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V47 C56 49.8 53.8 52 51 52 H13 C10.2 52 8 49.8 8 47 Z"
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
<!-- front panel, covers all but the tab and a thin strip of back -->
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
<g clip-path="url(#clipFront)">
<ellipse cx="32" cy="23" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
<ellipse cx="11.5" cy="37" rx="4" ry="11" fill="url(#sheen)" opacity="0.26"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,54 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F9D66A"/>
<stop offset="1" stop-color="#D69B1B"/>
</linearGradient>
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFE68A" stop-opacity="0.98"/>
<stop offset="0.5" stop-color="#F5BE3F" stop-opacity="0.98"/>
<stop offset="1" stop-color="#D89414" stop-opacity="0.98"/>
</linearGradient>
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#E5E7EB"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.42"/>
</linearGradient>
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.46"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="clipFront">
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"/>
</clipPath>
</defs>
<!-- folder back -->
<path d="M8 46 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V46 Z"
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
<!-- paper peeking out -->
<rect x="14" y="17" width="36" height="14" rx="2.5" fill="url(#paper)"/>
<g stroke="#D1D5DB" stroke-width="1.4" stroke-linecap="round" opacity="0.8">
<line x1="19" y1="21.5" x2="45" y2="21.5"/>
</g>
<!-- open front, tilted out, tighter corners -->
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
<g clip-path="url(#clipFront)">
<ellipse cx="32" cy="30" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
<ellipse cx="11.5" cy="40" rx="4" ry="10" fill="url(#sheen)" opacity="0.26"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,99 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="leatherBrown" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4A2815"/>
<stop offset="0.18" stop-color="#8A5230"/>
<stop offset="0.52" stop-color="#A66A42"/>
<stop offset="0.82" stop-color="#744025"/>
<stop offset="1" stop-color="#351A0D"/>
</linearGradient>
<linearGradient id="leatherBlue" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#102B52"/>
<stop offset="0.18" stop-color="#235A96"/>
<stop offset="0.52" stop-color="#3678B9"/>
<stop offset="0.82" stop-color="#194A82"/>
<stop offset="1" stop-color="#0A203E"/>
</linearGradient>
<linearGradient id="leatherRed" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#54151A"/>
<stop offset="0.18" stop-color="#9C2930"/>
<stop offset="0.52" stop-color="#C44349"/>
<stop offset="0.82" stop-color="#812027"/>
<stop offset="1" stop-color="#3C0B10"/>
</linearGradient>
<linearGradient id="leatherGreen" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#09251B"/>
<stop offset="0.2" stop-color="#14533B"/>
<stop offset="0.5" stop-color="#287B58"/>
<stop offset="0.82" stop-color="#104730"/>
<stop offset="1" stop-color="#061B13"/>
</linearGradient>
<linearGradient id="spineGloss" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.08"/>
<stop offset="0.3" stop-color="#FFFFFF" stop-opacity="0.38"/>
<stop offset="0.58" stop-color="#FFFFFF" stop-opacity="0.04"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.42"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F5E0C8" stop-opacity="0.65"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.1"/>
<stop offset="1" stop-color="#2A1508" stop-opacity="0.55"/>
</linearGradient>
<linearGradient id="gilt" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F7DE9C"/>
<stop offset="0.5" stop-color="#D9A94E"/>
<stop offset="1" stop-color="#B07E2C"/>
</linearGradient>
<filter id="bookShadow" x="-30%" y="-10%" width="170%" height="130%">
<feDropShadow dx="1.2" dy="1.4" stdDeviation="1.1" flood-color="#111827" flood-opacity="0.28"/>
</filter>
</defs>
<!-- brown book -->
<g filter="url(#bookShadow)">
<rect x="6" y="3.8" width="11" height="49.2" rx="3" fill="url(#leatherBrown)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="6.7" y="4.5" width="9.6" height="47.8" rx="2.4" fill="url(#spineGloss)"/>
<path d="M8.1 8V49" stroke="#F0C7A2" stroke-opacity="0.28" stroke-width="0.65"/>
<path d="M15.3 7.5V49.5" stroke="#241007" stroke-opacity="0.55" stroke-width="0.8"/>
<rect x="7.4" y="10.8" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
<rect x="7.9" y="19" width="7.2" height="5" rx="1" fill="#3C2210" opacity="0.55"/>
<rect x="7.4" y="38" width="8.2" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
<rect x="7.4" y="46.5" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
</g>
<!-- blue book -->
<g filter="url(#bookShadow)">
<rect x="19" y="8" width="11.5" height="45" rx="3" fill="url(#leatherBlue)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="19.7" y="8.7" width="10.1" height="43.6" rx="2.4" fill="url(#spineGloss)"/>
<path d="M21.2 12V49" stroke="#B9DCFF" stroke-opacity="0.3" stroke-width="0.65"/>
<path d="M28.8 11.5V49.5" stroke="#071A31" stroke-opacity="0.62" stroke-width="0.8"/>
<rect x="20.5" y="14.6" width="8.5" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
<rect x="21" y="22.6" width="7.5" height="5" rx="1" fill="#071D38" opacity="0.65"/>
<rect x="20.5" y="39.5" width="8.5" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
<rect x="20.5" y="46.8" width="8.5" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
</g>
<!-- red book -->
<g filter="url(#bookShadow)">
<rect x="32.5" y="5" width="11" height="48" rx="3" fill="url(#leatherRed)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="33.2" y="5.7" width="9.6" height="46.6" rx="2.4" fill="url(#spineGloss)"/>
<path d="M34.6 9V49" stroke="#FFD0D2" stroke-opacity="0.3" stroke-width="0.65"/>
<path d="M41.8 8.5V49.5" stroke="#35070B" stroke-opacity="0.62" stroke-width="0.8"/>
<rect x="33.9" y="11.6" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
<rect x="34.4" y="19.6" width="7.2" height="5" rx="1" fill="#430B10" opacity="0.68"/>
<rect x="33.9" y="38.5" width="8.2" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
<rect x="33.9" y="46.5" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
</g>
<!-- green book, leaning against the red book -->
<g transform="rotate(-9 62.1 52)" filter="url(#bookShadow)">
<rect x="51.6" y="5.2" width="10.5" height="46.8" rx="3" fill="url(#leatherGreen)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="52.3" y="5.9" width="9.1" height="45.4" rx="2.4" fill="url(#spineGloss)"/>
<path d="M53.7 9.5V48.5" stroke="#FFFFFF" stroke-opacity="0.24" stroke-width="0.65"/>
<path d="M60.4 9V49" stroke="#000000" stroke-opacity="0.72" stroke-width="0.8"/>
<rect x="52.95" y="11.8" width="7.8" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
<rect x="53.45" y="19.8" width="6.8" height="5" rx="1" fill="#052219" opacity="0.76"/>
<rect x="52.95" y="37.5" width="7.8" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
<rect x="52.95" y="45.4" width="7.8" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,42 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#5B6472"/>
<stop offset="1" stop-color="#2F3947"/>
</linearGradient>
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#B6BCC6" stop-opacity="0.96"/>
<stop offset="0.48" stop-color="#596272" stop-opacity="0.96"/>
<stop offset="1" stop-color="#424C5A" stop-opacity="0.98"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#1F2937" stop-opacity="0.46"/>
</linearGradient>
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
<stop offset="1" stop-color="#1F2937" stop-opacity="0.5"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="clipFront">
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"/>
</clipPath>
</defs>
<path d="M8 47 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V47 C56 49.8 53.8 52 51 52 H13 C10.2 52 8 49.8 8 47 Z"
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
<g clip-path="url(#clipFront)">
<ellipse cx="32" cy="23" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
<ellipse cx="11.5" cy="37" rx="4" ry="11" fill="url(#sheen)" opacity="0.26"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,51 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#5B6472"/>
<stop offset="1" stop-color="#2F3947"/>
</linearGradient>
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#B6BCC6" stop-opacity="0.96"/>
<stop offset="0.48" stop-color="#596272" stop-opacity="0.96"/>
<stop offset="1" stop-color="#424C5A" stop-opacity="0.98"/>
</linearGradient>
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#E5E7EB"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#1F2937" stop-opacity="0.46"/>
</linearGradient>
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
<stop offset="1" stop-color="#1F2937" stop-opacity="0.5"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="clipFront">
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"/>
</clipPath>
</defs>
<path d="M8 46 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V46 Z"
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
<rect x="14" y="17" width="36" height="14" rx="2.5" fill="url(#paper)"/>
<g stroke="#9CA3AF" stroke-width="1.4" stroke-linecap="round" opacity="0.8">
<line x1="19" y1="21.5" x2="45" y2="21.5"/>
</g>
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
<g clip-path="url(#clipFront)">
<ellipse cx="32" cy="30" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
<ellipse cx="11.5" cy="40" rx="4" ry="10" fill="url(#sheen)" opacity="0.26"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,30 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bolt" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#60A5FA" stop-opacity="0.96"/>
<stop offset="0.5" stop-color="#2563EB" stop-opacity="0.95"/>
<stop offset="1" stop-color="#1D4ED8" stop-opacity="0.97"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#172554" stop-opacity="0.45"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="clipBolt">
<path d="M32.91 4.51 L9.79 35.53 Q8 37.94 11 37.94 L29.5 37.94 Q32 37.94 31.72 40.43 L29.64 58.92 Q29.3 61.9 31.09 59.49 L54.21 28.47 Q56 26.06 53 26.06 L34.5 26.06 Q32 26.06 32.28 23.58 L34.36 5.08 Q34.7 2.1 32.91 4.51 Z"/>
</clipPath>
</defs>
<path d="M32.91 4.51 L9.79 35.53 Q8 37.94 11 37.94 L29.5 37.94 Q32 37.94 31.72 40.43 L29.64 58.92 Q29.3 61.9 31.09 59.49 L54.21 28.47 Q56 26.06 53 26.06 L34.5 26.06 Q32 26.06 32.28 23.58 L34.36 5.08 Q34.7 2.1 32.91 4.51 Z"
fill="url(#bolt)" stroke="url(#rim)" stroke-width="1.5" stroke-linejoin="round"/>
<g clip-path="url(#clipBolt)">
<ellipse cx="30.5" cy="7.5" rx="11" ry="5" fill="url(#sheen)" opacity="0.4"/>
<ellipse cx="18" cy="33" rx="4" ry="8" fill="url(#sheen)" opacity="0.24" transform="rotate(-30 18 33)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,39 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="card" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F9FAFB" stop-opacity="0.96"/>
<stop offset="0.5" stop-color="#EDEFF2" stop-opacity="0.94"/>
<stop offset="1" stop-color="#D1D5DB" stop-opacity="0.96"/>
</linearGradient>
<linearGradient id="header" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#34D399"/>
<stop offset="1" stop-color="#059669"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.9"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#374151" stop-opacity="0.4"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="clipCard"><rect x="5" y="9" width="54" height="44" rx="10"/></clipPath>
</defs>
<rect x="5" y="9" width="54" height="44" rx="10" fill="url(#card)" stroke="url(#rim)" stroke-width="1.5"/>
<g clip-path="url(#clipCard)">
<rect x="5" y="9" width="54" height="12" fill="url(#header)"/>
<ellipse cx="32" cy="12" rx="25" ry="5" fill="url(#sheen)" opacity="0.6"/>
<g stroke="#9CA3AF" stroke-width="1.6" opacity="0.9">
<line x1="23" y1="21" x2="23" y2="53"/>
<line x1="41" y1="21" x2="41" y2="53"/>
<line x1="5" y1="31.67" x2="59" y2="31.67"/>
<line x1="5" y1="42.33" x2="59" y2="42.33"/>
</g>
<ellipse cx="10" cy="34" rx="4" ry="12" fill="url(#sheen)" opacity="0.38"/>
<ellipse cx="32" cy="24" rx="26" ry="4" fill="url(#sheen)" opacity="0.45"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,71 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="nPurple" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#C4B5FD" stop-opacity="0.96"/>
<stop offset="0.5" stop-color="#8B5CF6" stop-opacity="0.95"/>
<stop offset="1" stop-color="#6D28D9" stop-opacity="0.97"/>
</linearGradient>
<linearGradient id="nBlue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#93C5FD" stop-opacity="0.96"/>
<stop offset="0.5" stop-color="#3B82F6" stop-opacity="0.95"/>
<stop offset="1" stop-color="#1D4ED8" stop-opacity="0.97"/>
</linearGradient>
<linearGradient id="nYellow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FDE68A" stop-opacity="0.96"/>
<stop offset="0.5" stop-color="#F59E0B" stop-opacity="0.95"/>
<stop offset="1" stop-color="#B45309" stop-opacity="0.97"/>
</linearGradient>
<linearGradient id="nRed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FCA5A5" stop-opacity="0.96"/>
<stop offset="0.5" stop-color="#EF4444" stop-opacity="0.95"/>
<stop offset="1" stop-color="#B91C1C" stop-opacity="0.97"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#1F2937" stop-opacity="0.45"/>
</linearGradient>
<linearGradient id="pipe" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#D1D5DB" stop-opacity="0.9"/>
<stop offset="1" stop-color="#9CA3AF" stop-opacity="0.9"/>
</linearGradient>
<radialGradient id="sheen">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- grey glass pipes -->
<g fill="none" stroke="url(#pipe)" stroke-width="5" stroke-linecap="round">
<path d="M32 11 L11 32"/>
<path d="M11 32 H53"/>
<path d="M53 32 L32 53"/>
</g>
<g fill="none" stroke="#FFFFFF" stroke-width="1.3" stroke-linecap="round" opacity="0.55">
<path d="M26.4 15.6 L15.6 26.4"/>
<path d="M18 31.2 H46"/>
<path d="M47.7 36.8 L36.8 47.7"/>
</g>
<!-- top node: purple -->
<g>
<circle cx="32" cy="11" r="9.1" fill="url(#nPurple)" stroke="url(#rim)" stroke-width="1.4"/>
<ellipse cx="30.4" cy="7.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
</g>
<!-- left node: blue -->
<g>
<circle cx="11" cy="32" r="9.1" fill="url(#nBlue)" stroke="url(#rim)" stroke-width="1.4"/>
<ellipse cx="9.4" cy="28.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
</g>
<!-- right node: yellow -->
<g>
<circle cx="53" cy="32" r="9.1" fill="url(#nYellow)" stroke="url(#rim)" stroke-width="1.4"/>
<ellipse cx="51.4" cy="28.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
</g>
<!-- bottom node: red -->
<g>
<circle cx="32" cy="53" r="9.1" fill="url(#nRed)" stroke="url(#rim)" stroke-width="1.4"/>
<ellipse cx="30.4" cy="49.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="chatBody" x1="4" y1="3.5" x2="21" y2="19.5" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7DD3FC"/>
<stop offset="0.48" stop-color="#2588F0"/>
<stop offset="1" stop-color="#0F56B8"/>
</linearGradient>
<linearGradient id="chatGlass" x1="5" y1="5" x2="15.8" y2="12.4" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.72"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.12"/>
</linearGradient>
</defs>
<path d="M2.8 7.1c0-2.05 1.65-3.7 3.7-3.7h11c2.05 0 3.7 1.65 3.7 3.7v5.7c0 2.05-1.65 3.7-3.7 3.7H8.35l-4.3 3.2c-.52.36-1.25 0-1.25-.63V12.8V7.1Z" fill="#0B3F8C" opacity="0.22"/>
<path d="M2.8 6.65c0-2.05 1.65-3.7 3.7-3.7h11c2.05 0 3.7 1.65 3.7 3.7v5.7c0 2.05-1.65 3.7-3.7 3.7H8.35l-4.3 3.2c-.52.36-1.25 0-1.25-.63v-6.27v-5.7Z" fill="url(#chatBody)"/>
<path d="M5.25 5.85c0-.5.4-.9.9-.9h8.95c.9 0 1.24 1.18.49 1.66l-9.25 5.98c-.48.31-1.09-.04-1.09-.61V5.85Z" fill="url(#chatGlass)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="Excel document">
<defs>
<linearGradient id="excelDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#34D399" stop-opacity="0.97" />
<stop offset="1" stop-color="#087F5B" stop-opacity="0.97" />
</linearGradient>
<linearGradient id="excelRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
<stop offset="1" stop-color="#065F46" stop-opacity="0.45" />
</linearGradient>
<filter id="excelSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
<clipPath id="excelClip">
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
</clipPath>
</defs>
<path
fill="#065F46"
opacity="0.2"
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
/>
<path
fill="url(#excelDocumentGradient)"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<g clip-path="url(#excelClip)">
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#excelSoft)" />
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#excelSoft)" />
</g>
<path fill="#74DDB5" d="M45.385 3v18h15L45.385 3Z" />
<path
fill="none"
stroke="url(#excelRim)"
stroke-width="1.8"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<rect
x="20.5"
y="34"
width="29"
height="22"
rx="2.5"
fill="none"
stroke="#FFFFFF"
stroke-width="3.25"
/>
<path
fill="none"
stroke="#FFFFFF"
stroke-linecap="round"
stroke-width="3.25"
d="M35 34v22M20.5 45h29"
/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="PDF document">
<defs>
<linearGradient id="pdfDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FF6B6B" stop-opacity="0.97" />
<stop offset="1" stop-color="#C1121F" stop-opacity="0.97" />
</linearGradient>
<linearGradient id="pdfRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
<stop offset="1" stop-color="#7F0D16" stop-opacity="0.45" />
</linearGradient>
<filter id="pdfSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
<clipPath id="pdfClip">
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
</clipPath>
</defs>
<path
fill="#7F0D16"
opacity="0.2"
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
/>
<path
fill="url(#pdfDocumentGradient)"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<g clip-path="url(#pdfClip)">
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#pdfSoft)" />
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#pdfSoft)" />
</g>
<path fill="#FF9A9A" d="M45.385 3v18h15L45.385 3Z" />
<path
fill="none"
stroke="url(#pdfRim)"
stroke-width="1.8"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<path
fill="#FFFFFF"
d="M19.5 32a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5A2.5 2.5 0 0 1 22 40h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="PowerPoint document">
<defs>
<linearGradient id="pptDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FF7043" stop-opacity="0.97" />
<stop offset="1" stop-color="#B3261E" stop-opacity="0.97" />
</linearGradient>
<linearGradient id="pptRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
<stop offset="1" stop-color="#7A1712" stop-opacity="0.45" />
</linearGradient>
<filter id="pptSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
<clipPath id="pptClip">
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
</clipPath>
</defs>
<path
fill="#7A1712"
opacity="0.2"
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
/>
<path
fill="url(#pptDocumentGradient)"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<g clip-path="url(#pptClip)">
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#pptSoft)" />
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#pptSoft)" />
</g>
<path fill="#FF9E80" d="M45.385 3v18h15L45.385 3Z" />
<path
fill="none"
stroke="url(#pptRim)"
stroke-width="1.8"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<rect
x="18"
y="34.2875"
width="34"
height="22.425"
rx="3"
fill="none"
stroke="#FFFFFF"
stroke-width="3.25"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="Word document">
<defs>
<linearGradient id="wordDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#43A5F5" stop-opacity="0.97" />
<stop offset="1" stop-color="#064BAE" stop-opacity="0.97" />
</linearGradient>
<linearGradient id="wordRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
<stop offset="1" stop-color="#04347A" stop-opacity="0.45" />
</linearGradient>
<filter id="wordSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
<clipPath id="wordClip">
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
</clipPath>
</defs>
<path
fill="#04347A"
opacity="0.2"
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
/>
<path
fill="url(#wordDocumentGradient)"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<g clip-path="url(#wordClip)">
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#wordSoft)" />
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#wordSoft)" />
</g>
<path fill="#63A2E2" d="M45.385 3v18h15L45.385 3Z" />
<path
fill="none"
stroke="url(#wordRim)"
stroke-width="1.8"
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
/>
<path
fill="#FFFFFF"
d="M19.5 32a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5A2.5 2.5 0 0 1 22 40h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<defs>
<linearGradient id="nodePurple" x1="4" y1="4" x2="10" y2="10" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#C9B9FF"/>
<stop offset="1" stop-color="#6A55E8"/>
</linearGradient>
<linearGradient id="nodeCyan" x1="14" y1="4" x2="20" y2="10" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8DE2FF"/>
<stop offset="1" stop-color="#0E8DD0"/>
</linearGradient>
<linearGradient id="nodeOrange" x1="8" y1="14" x2="16" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFD88A"/>
<stop offset="1" stop-color="#EA6F2F"/>
</linearGradient>
</defs>
<path d="M8.55 8.35c2.44 1.3 4.46 1.3 6.9 0M8.25 9.1c1.15 2.95 2.05 4.45 3.75 6.4M15.75 9.1c-1.15 2.95-2.05 4.45-3.75 6.4" stroke="#617187" stroke-width="1.45" stroke-linecap="round" opacity="0.72"/>
<circle cx="6.8" cy="7.25" r="3.45" fill="#3B2E99" opacity="0.2"/>
<circle cx="17.2" cy="7.25" r="3.45" fill="#075985" opacity="0.2"/>
<rect x="8.35" y="14.05" width="7.3" height="6.05" rx="2.05" fill="#9A3412" opacity="0.2"/>
<circle cx="6.8" cy="6.9" r="3.35" fill="url(#nodePurple)"/>
<circle cx="17.2" cy="6.9" r="3.35" fill="url(#nodeCyan)"/>
<rect x="8.35" y="13.65" width="7.3" height="6.05" rx="2.05" fill="url(#nodeOrange)"/>
<path d="M5.75 5.9h2.1M16.15 5.9h2.1M10.65 15.75h2.7" stroke="#FFFFFF" stroke-width="0.9" stroke-linecap="round" opacity="0.86"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -3,10 +3,14 @@
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { useQuickActionsPreference } from "@/app/components/assistant/quickActionsPreferences";
import { AccountSection } from "../AccountSection";
import { AccountToggle } from "../AccountToggle";
export default function FeaturesPage() {
const { profile, updateLegalResearchUs } = useUserProfile();
const { visibleActions, showAllQuickActions, hideAllQuickActions } =
useQuickActionsPreference();
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
@ -26,6 +30,7 @@ export default function FeaturesPage() {
const hasChanges =
draftLegalResearchUs !== null &&
draftLegalResearchUs !== persistedLegalResearchUs;
const quickActionsEnabled = Object.values(visibleActions).some(Boolean);
const handleUpdateLegalResearch = async () => {
if (saving) return;
@ -46,6 +51,38 @@ export default function FeaturesPage() {
return (
<div className="space-y-8">
<section className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Assistant
</h2>
</div>
<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">
Quick actions
</p>
<p className="text-sm text-gray-500">
Show the quick actions row on the assistant
start screen.
</p>
</div>
<AccountToggle
checked={quickActionsEnabled}
size="md"
onChange={(checked) => {
if (checked) {
showAllQuickActions();
} else {
hideAllQuickActions();
}
}}
/>
</div>
</AccountSection>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-medium font-serif text-gray-900">

View file

@ -4,12 +4,14 @@ import { useEffect, useRef, useState } from "react";
import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import {
LiquidDropdownContent,
LiquidDropdownItem,
} from "@/app/components/ui/liquid-dropdown";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import {
@ -178,7 +180,7 @@ function ModelPreferenceDropdown({
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
<LiquidDropdownContent
className="z-50"
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
@ -198,7 +200,7 @@ function ModelPreferenceDropdown({
? isModelAvailable(m.id, apiKeys)
: true;
return (
<DropdownMenuItem
<LiquidDropdownItem
key={m.id}
className="cursor-pointer"
onSelect={() => onChange(m.id)}
@ -219,13 +221,13 @@ function ModelPreferenceDropdown({
{m.id === value && available && (
<Check className="h-3.5 w-3.5 text-gray-600 ml-1" />
)}
</DropdownMenuItem>
</LiquidDropdownItem>
);
})}
</div>
);
})}
</DropdownMenuContent>
</LiquidDropdownContent>
</DropdownMenu>
);
}

View file

@ -102,7 +102,7 @@ export default function MikeLayout({
},
}}
>
<div className="h-dvh flex flex-col bg-gray-50/80">
<div className="h-dvh flex flex-col bg-app-background">
<div className="flex-1 flex min-w-0 overflow-visible">
<AppSidebar
isOpen={isSidebarOpen}
@ -113,7 +113,7 @@ export default function MikeLayout({
<div className="relative z-20 flex md:hidden items-center gap-3 overflow-visible px-4 pt-3 pb-2 shrink-0">
<button
onClick={handleSidebarToggle}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
className="flex h-9 w-9 items-center justify-center rounded-full bg-app-surface text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-app-floating active:scale-95"
title="Open sidebar"
aria-label="Open sidebar"
>

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function LibraryFilesPage() {
redirect("/library");
}

View file

@ -0,0 +1,8 @@
"use client";
import type { ReactNode } from "react";
import { LibraryWorkspaceLayout } from "@/app/components/library/LibraryWorkspace";
export default function LibraryLayout({ children }: { children: ReactNode }) {
return <LibraryWorkspaceLayout>{children}</LibraryWorkspaceLayout>;
}

View file

@ -0,0 +1,5 @@
import { LibraryCollectionPage } from "@/app/components/library/LibraryWorkspace";
export default function LibraryPage() {
return <LibraryCollectionPage kind="files" />;
}

View file

@ -0,0 +1,7 @@
"use client";
import { LibraryCollectionPage } from "@/app/components/library/LibraryWorkspace";
export default function LibraryTemplatesPage() {
return <LibraryCollectionPage kind="templates" />;
}

View file

@ -59,6 +59,7 @@ import type {
} from "@/app/components/shared/types";
import {
expandCitationToEntries,
isDocxFilename,
isSpreadsheetFilename,
} from "@/app/components/shared/types";
@ -85,11 +86,6 @@ type EditScrollTarget = {
del_w_id?: string | null;
};
function isDocxTab(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase();
return ext === "docx" || ext === "doc";
}
const ICON_SIZE = 28;
const GAP = 14;
const EXPLORER_MIN = 160;
@ -1085,7 +1081,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
</div>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{activeTab ? (
isDocxTab(activeTab.filename) ? (
isDocxFilename(activeTab.filename) ? (
<DocxView
key={activeTab.documentId}
documentId={activeTab.documentId}
@ -1265,6 +1261,20 @@ export default function ProjectAssistantChatPage({ params }: Props) {
onCancel={cancel}
isLoading={isResponseLoading}
hideAddDocButton
projectId={projectId}
onDocumentsUploaded={(documents) =>
setProject((prev) =>
prev
? {
...prev,
documents: [
...(prev.documents ?? []),
...documents,
],
}
: prev,
)
}
projectName={project?.name}
projectCmNumber={project?.cm_number}
/>

View file

@ -1,7 +1,7 @@
"use client";
import { use, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { deleteChat, renameChat } from "@/app/lib/mikeApi";
import { ProjectAssistantTable } from "@/app/components/projects/ProjectAssistantTable";
@ -11,6 +11,7 @@ import {
} from "@/app/components/projects/ProjectWorkspace";
import type { Chat } from "@/app/components/shared/types";
import { useAuth } from "@/app/contexts/AuthContext";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
interface Props {
params: Promise<{ id: string }>;
@ -31,13 +32,12 @@ function SelectedChatActions({
return (
<div className="relative">
<button
<TabPillButton
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>
</TabPillButton>
{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
@ -56,7 +56,9 @@ export default function ProjectAssistantPage({ params }: Props) {
use(params);
const workspace = useProjectWorkspace();
const router = useRouter();
const searchParams = useSearchParams();
const { user } = useAuth();
const previewEmptyStates = searchParams.get("emptyStates") === "1";
const {
ensureProjectChats,
projectChats,
@ -70,7 +72,8 @@ export default function ProjectAssistantPage({ params }: Props) {
const [renameChatValue, setRenameChatValue] = useState("");
const [actionsOpen, setActionsOpen] = useState(false);
const chats = useMemo(() => projectChats ?? [], [projectChats]);
const loading = projectChats === null;
const visibleChats = previewEmptyStates ? [] : chats;
const loading = projectChats === null && !previewEmptyStates;
useEffect(() => {
void ensureProjectChats();
@ -78,8 +81,8 @@ export default function ProjectAssistantPage({ params }: Props) {
const q = search.toLowerCase();
const filteredChats = q
? chats.filter((c) => (c.title ?? "").toLowerCase().includes(q))
: chats;
? visibleChats.filter((c) => (c.title ?? "").toLowerCase().includes(q))
: visibleChats;
const allChatsSelected =
filteredChats.length > 0 &&
filteredChats.every((c) => selectedChatIds.includes(c.id));
@ -131,17 +134,17 @@ export default function ProjectAssistantPage({ params }: Props) {
return (
<>
<ProjectSectionToolbar
actions={
actions={selectedChatIds.length > 0 ? (
<SelectedChatActions
selectedCount={selectedChatIds.length}
open={actionsOpen}
onOpenChange={setActionsOpen}
onDelete={() => void handleDeleteSelectedChats()}
/>
}
) : undefined}
/>
<ProjectAssistantTable
chats={chats}
chats={visibleChats}
filteredChats={filteredChats}
selectedChatIds={selectedChatIds}
allChatsSelected={allChatsSelected}

View file

@ -1,7 +1,7 @@
"use client";
import { use, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { ChevronDown } from "lucide-react";
import {
deleteTabularReview,
@ -15,6 +15,7 @@ import {
} from "@/app/components/projects/ProjectWorkspace";
import type { TabularReview } from "@/app/components/shared/types";
import { useAuth } from "@/app/contexts/AuthContext";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
interface Props {
params: Promise<{ id: string }>;
@ -35,13 +36,12 @@ function SelectedReviewActions({
return (
<div className="relative">
<button
<TabPillButton
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>
</TabPillButton>
{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
@ -60,7 +60,9 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
use(params);
const workspace = useProjectWorkspace();
const router = useRouter();
const searchParams = useSearchParams();
const { user } = useAuth();
const previewEmptyStates = searchParams.get("emptyStates") === "1";
const {
ensureProjectReviews,
project,
@ -77,7 +79,8 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
const [actionsOpen, setActionsOpen] = useState(false);
const docs = project?.documents ?? [];
const reviews = useMemo(() => projectReviews ?? [], [projectReviews]);
const loading = projectReviews === null;
const visibleReviews = previewEmptyStates ? [] : reviews;
const loading = projectReviews === null && !previewEmptyStates;
useEffect(() => {
void ensureProjectReviews();
@ -85,8 +88,10 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
const q = search.toLowerCase();
const filteredReviews = q
? reviews.filter((r) => (r.title ?? "").toLowerCase().includes(q))
: reviews;
? visibleReviews.filter((r) =>
(r.title ?? "").toLowerCase().includes(q),
)
: visibleReviews;
const allReviewsSelected =
filteredReviews.length > 0 &&
filteredReviews.every((r) => selectedReviewIds.includes(r.id));
@ -167,18 +172,18 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
return (
<>
<ProjectSectionToolbar
actions={
actions={selectedReviewIds.length > 0 ? (
<SelectedReviewActions
selectedCount={selectedReviewIds.length}
open={actionsOpen}
onOpenChange={setActionsOpen}
onDelete={() => void handleDeleteSelectedReviews()}
/>
}
) : undefined}
/>
<ProjectReviewsTable
docs={docs}
reviews={reviews}
reviews={visibleReviews}
filteredReviews={filteredReviews}
selectedReviewIds={selectedReviewIds}
allReviewsSelected={allReviewsSelected}

View file

@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown, Table2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ChevronDown, Plus } from "lucide-react";
import {
RowActionMenuItems,
RowActions,
@ -21,10 +21,6 @@ import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularRevie
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { useAuth } from "@/app/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,
@ -33,21 +29,33 @@ import {
TableBody,
TableCell,
TableEmptyState,
TableFilters,
type TableFilterOption,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
type TableSortDirection,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import { LiquidDropdownSurface } from "@/app/components/ui/liquid-dropdown";
type ReviewScope = "all" | "in-project" | "standalone";
type ReviewSortKey = "name" | "columns" | "documents" | "created";
const REVIEW_SCOPES: { id: ReviewScope; label: string }[] = [
{ id: "all", label: "All" },
{ id: "in-project", label: "In Project" },
{ id: "standalone", label: "Standalone" },
];
const SORT_OPTIONS: TableFilterOption<TableSortDirection>[] = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
@ -68,13 +76,24 @@ export default function TabularReviewsPage() {
);
const [activeScope, setActiveScope] = useState<ReviewScope>("all");
const [projectFilter, setProjectFilter] = useState<string | null>(null);
const [sort, setSort] = useState<{
key: ReviewSortKey;
direction: TableSortDirection;
} | null>(null);
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const searchParams = useSearchParams();
const { user } = useAuth();
const previewEmptyStates = searchParams.get("emptyStates") === "1";
const effectiveLoading = loading && !previewEmptyStates;
const visibleReviews = useMemo(
() => (previewEmptyStates ? [] : reviews),
[previewEmptyStates, reviews],
);
useEffect(() => {
Promise.all([
@ -105,15 +124,56 @@ export default function TabularReviewsPage() {
return () => document.removeEventListener("mousedown", handleClick);
}, [actionsOpen]);
const projectNameById = useMemo(
() => new Map(projects.map((project) => [project.id, project.name])),
[projects],
);
const q = search.toLowerCase();
const filtered = reviews
.filter((r) => {
if (activeScope === "in-project") return !!r.project_id;
if (activeScope === "standalone") return !r.project_id;
return true;
})
.filter((r) => !projectFilter || r.project_id === projectFilter)
.filter((r) => !q || (r.title ?? "").toLowerCase().includes(q));
const filtered = useMemo(() => {
const rows = visibleReviews
.filter((r) => {
if (activeScope === "in-project") return !!r.project_id;
if (activeScope === "standalone") return !r.project_id;
return true;
})
.filter((r) => !projectFilter || r.project_id === projectFilter)
.filter((r) => !q || (r.title ?? "").toLowerCase().includes(q));
if (!sort) return rows;
return [...rows].sort((a, b) => {
const multiplier = sort.direction === "asc" ? 1 : -1;
if (sort.key === "columns") {
return (
((a.columns_config?.length ?? 0) -
(b.columns_config?.length ?? 0)) *
multiplier
);
}
if (sort.key === "documents") {
return (
((a.document_count ?? 0) - (b.document_count ?? 0)) *
multiplier
);
}
if (sort.key === "created") {
return (
(new Date(a.created_at).getTime() -
new Date(b.created_at).getTime()) *
multiplier
);
}
return (
(a.title ?? "Untitled Review").localeCompare(
b.title ?? "Untitled Review",
) * multiplier
);
});
}, [activeScope, projectFilter, q, sort, visibleReviews]);
const allSelected =
filtered.length > 0 &&
@ -132,6 +192,24 @@ export default function TabularReviewsPage() {
);
}
function clearSelection() {
setSelectedIds([]);
setActionsOpen(false);
}
function handleProjectFilterChange(value: string | null) {
setProjectFilter(value);
clearSelection();
}
function handleSortChange(
key: ReviewSortKey,
direction: TableSortDirection | null,
) {
setSort(direction ? { key, direction } : null);
clearSelection();
}
const handleNewReview = async (
title: string,
projectId?: string,
@ -210,7 +288,7 @@ export default function TabularReviewsPage() {
}
const projectFilterButton = (
<HeaderFilterDropdown
<TableFilters
label="Filter by project"
value={projectFilter}
allLabel="All Projects"
@ -218,29 +296,76 @@ export default function TabularReviewsPage() {
value: project.id,
label: project.name,
}))}
onChange={setProjectFilter}
onChange={handleProjectFilterChange}
/>
);
const nameSortDirection = sort?.key === "name" ? sort.direction : null;
const columnsSortDirection =
sort?.key === "columns" ? sort.direction : null;
const documentsSortDirection =
sort?.key === "documents" ? sort.direction : null;
const createdSortDirection =
sort?.key === "created" ? sort.direction : null;
const nameFilterButton = (
<TableFilters
label="Sort by review name"
value={nameSortDirection}
allLabel="Default Order"
widthClassName="w-40"
align="right"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("name", direction)}
/>
);
const columnsFilterButton = (
<TableFilters
label="Sort by columns"
value={columnsSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("columns", direction)}
/>
);
const documentsFilterButton = (
<TableFilters
label="Sort by documents"
value={documentsSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("documents", direction)}
/>
);
const createdFilterButton = (
<TableFilters
label="Sort by created date"
value={createdSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("created", direction)}
/>
);
const toolbarActions =
selectedIds.length > 0 ? (
<div ref={actionsRef} className="relative">
<button
<TabPillButton
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>
</TabPillButton>
{actionsOpen && (
<div className={`absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden ${GLASS_DROPDOWN}`}>
<LiquidDropdownSurface className="absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden">
<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>
</LiquidDropdownSurface>
)}
</div>
) : undefined;
@ -273,7 +398,10 @@ export default function TabularReviewsPage() {
<TableToolbar
items={REVIEW_SCOPES}
active={activeScope}
onChange={setActiveScope}
onChange={(scope) => {
setActiveScope(scope);
clearSelection();
}}
actions={toolbarActions}
/>
@ -282,8 +410,8 @@ export default function TabularReviewsPage() {
header={
<TableHeaderRow>
<TableStickyCell header>
{loading ? (
<SkeletonDot />
{effectiveLoading ? (
<SkeletonDot className="mr-4" />
) : (
<input
type="checkbox"
@ -295,25 +423,38 @@ export default function TabularReviewsPage() {
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
<span className="mr-1">Name</span>
{!loading && nameFilterButton}
</TableStickyCell>
<TableHeaderCell className="ml-auto w-24">
Columns
<div className="flex items-center gap-1">
<span>Columns</span>
{!loading && columnsFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-24">
<div className="flex items-center gap-1">
<span>Documents</span>
{!loading && documentsFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
<TableHeaderCell className="w-40">
<div className="flex items-center gap-1">
<span>Project</span>
{projectFilterButton}
{!loading && projectFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">
<div className="flex items-center gap-1">
<span>Created</span>
{!loading && createdFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
}
>
{loading ? (
{effectiveLoading ? (
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow
@ -324,7 +465,7 @@ export default function TabularReviewsPage() {
hover={false}
bgClassName="bg-transparent"
>
<SkeletonDot />
<SkeletonDot className="mr-4" />
<SkeletonLine className="h-3.5 w-48" />
</TableStickyCell>
<TableCell className="ml-auto w-24">
@ -347,7 +488,7 @@ export default function TabularReviewsPage() {
<TableEmptyState>
{activeScope === "all" && !projectFilter ? (
<>
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
<TabularReviewSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Tabular Reviews
</p>
@ -355,13 +496,16 @@ export default function TabularReviewsPage() {
Extract data from documents into tables
using AI.
</p>
<button
<PillButton
tone="black"
size="sm"
onClick={() => setNewTROpen(true)}
disabled={creating}
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"
className="mt-4 px-3"
>
+ Create New
</button>
<Plus className="h-3.5 w-3.5" />
Create
</PillButton>
</>
) : (
<p className="text-sm text-gray-400">
@ -372,18 +516,19 @@ export default function TabularReviewsPage() {
) : (
<TableBody>
{filtered.map((review) => {
const project = projects.find(
(p) => p.id === review.project_id,
);
const projectName = review.project_id
? projectNameById.get(review.project_id)
: null;
const rowBg = selectedIds.includes(review.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG;
return (
<TableRow
key={review.id}
rightClickDropdown={(close) => (
rightClickDropdown={(close, menuProps) => (
<RowActionMenuItems
onClose={close}
surfaceProps={menuProps}
onEditDetails={() => {
requestReviewDetails(review);
}}
@ -436,8 +581,8 @@ export default function TabularReviewsPage() {
{review.document_count ?? 0}
</TableCell>
<TableCell className="w-40 pr-2">
{project ? (
project.name
{projectName ? (
projectName
) : (
<span className="text-gray-300">

View file

@ -19,8 +19,8 @@ export function AddDocButton({
onClick={onBrowseAll}
className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${
selectedDocIds.length > 0
? "text-black hover:bg-gray-100"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
? "text-gray-700 hover:text-gray-900"
: "text-gray-400 hover:text-gray-700"
}`}
title="Add documents"
aria-label="Add documents"

View file

@ -1,27 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AlertCircle,
Check,
CornerDownLeft,
Loader2,
Upload,
X,
} from "lucide-react";
import { cn } from "@/app/lib/utils";
import { useCallback, useEffect, useState } from "react";
import { Check, X } from "lucide-react";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import type { AssistantEvent, Document } from "../shared/types";
import { FileTypeIcon } from "../shared/FileTypeIcon";
import {
AddDocumentsModal,
invalidateDirectoryCache,
} from "../modals/AddDocumentsModal";
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
import {
SUPPORTED_DOCUMENT_ACCEPT,
formatUnsupportedDocumentWarning,
partitionSupportedDocumentFiles,
} from "@/app/lib/documentUploadValidation";
import { AddDocumentsModal } from "../modals/AddDocumentsModal";
type AskInputsEvent = Extract<AssistantEvent, { type: "ask_inputs" }>;
type AskInputItem = AskInputsEvent["items"][number];
@ -48,34 +33,47 @@ export function AskInputPopup({
);
const [otherOpen, setOtherOpen] = useState<Record<string, boolean>>({});
const [otherValues, setOtherValues] = useState<Record<string, string>>({});
const [docsByInput, setDocsByInput] = useState<Record<string, Document[]>>(
{},
);
const [docsByInput, setDocsByInput] = useState<
Record<string, Record<number, Document[]>>
>({});
const [skipped, setSkipped] = useState<Set<string>>(() => new Set());
const [confirmed, setConfirmed] = useState<Set<string>>(() => new Set());
const [submitted, setSubmitted] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [uploadingInputId, setUploadingInputId] = useState<string | null>(
null,
);
const [dragActiveInputId, setDragActiveInputId] = useState<string | null>(
null,
);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [docSelectorInputId, setDocSelectorInputId] = useState<string | null>(
null,
);
const [docSelectorTarget, setDocSelectorTarget] = useState<{
inputId: string;
typeIndex: number;
} | null>(null);
const [activeInputId, setActiveInputId] = useState(
() => event.items[0]?.id ?? "",
);
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
const docsForItem = useCallback(
(inputId: string) => {
const seen = new Set<string>();
return Object.values(docsByInput[inputId] ?? {})
.flat()
.filter((doc) => {
if (seen.has(doc.id)) return false;
seen.add(doc.id);
return true;
});
},
[docsByInput],
);
const itemAnswered = useCallback(
(item: AskInputItem) => {
if (item.kind === "choice") return !!choiceAnswers[item.id]?.trim();
return docsForItem(item.id).length > 0;
},
[choiceAnswers, docsForItem],
);
const itemResolved = useCallback(
(item: AskInputItem) => {
if (skipped.has(item.id)) return true;
if (item.kind === "choice") return !!choiceAnswers[item.id]?.trim();
return (docsByInput[item.id] ?? []).length > 0;
},
[choiceAnswers, docsByInput, skipped],
(item: AskInputItem) =>
skipped.has(item.id) || confirmed.has(item.id),
[confirmed, skipped],
);
const firstUnresolvedId = useCallback(
@ -105,55 +103,52 @@ export function AskInputPopup({
if (shouldSkip) goToNextUnresolved(id);
};
const addDocs = (inputId: string, selected: Document[]) => {
const confirmItem = (id: string) => {
setConfirmed((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
goToNextUnresolved(id);
};
const addDocs = (
inputId: string,
typeIndex: number,
selected: Document[],
) => {
if (selected.length === 0) return;
setSkippedFor(inputId, false);
setDocsByInput((prev) => {
const current = prev[inputId] ?? [];
const byType = prev[inputId] ?? {};
const current = byType[typeIndex] ?? [];
const existing = new Set(current.map((doc) => doc.id));
return {
...prev,
[inputId]: [
...current,
...selected.filter((doc) => !existing.has(doc.id)),
],
[inputId]: {
...byType,
[typeIndex]: [
...current,
...selected.filter((doc) => !existing.has(doc.id)),
],
},
};
});
goToNextUnresolved(inputId);
};
const removeDoc = (inputId: string, docId: string) => {
setDocsByInput((prev) => ({
...prev,
[inputId]: (prev[inputId] ?? []).filter((doc) => doc.id !== docId),
}));
};
const handleFiles = async (inputId: string, incomingFiles: File[]) => {
if (!incomingFiles.length || submitted) return;
const { supported, unsupported } =
partitionSupportedDocumentFiles(incomingFiles);
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
if (supported.length === 0) {
const input = fileInputsRef.current[inputId];
if (input) input.value = "";
return;
}
setUploadingInputId(inputId);
try {
const uploaded = await Promise.all(
supported.map((file) => uploadStandaloneDocument(file)),
);
invalidateDirectoryCache();
addDocs(inputId, uploaded);
} catch (err) {
console.error("Document upload failed:", err);
} finally {
setUploadingInputId(null);
setDragActiveInputId(null);
const input = fileInputsRef.current[inputId];
if (input) input.value = "";
}
const removeDoc = (inputId: string, typeIndex: number, docId: string) => {
setDocsByInput((prev) => {
const byType = prev[inputId] ?? {};
return {
...prev,
[inputId]: {
...byType,
[typeIndex]: (byType[typeIndex] ?? []).filter(
(doc) => doc.id !== docId,
),
},
};
});
};
const chooseAnswer = (
@ -164,16 +159,12 @@ export function AskInputPopup({
if (!trimmed || submitted) return;
setSkippedFor(item.id, false);
setChoiceAnswers((prev) => ({ ...prev, [item.id]: trimmed }));
goToNextUnresolved(item.id);
setOtherOpen((prev) => ({ ...prev, [item.id]: false }));
};
const allResolved =
event.items.length > 0 && event.items.every(itemResolved);
const canSubmit =
!submitted &&
!uploadingInputId &&
allResolved &&
!!onSubmit;
const canSubmit = !submitted && allResolved && !!onSubmit;
const buildResponse = (): AskInputsResponse => {
const responses = event.items.map((item) => {
@ -203,9 +194,7 @@ export function AskInputPopup({
return {
id: item.id,
kind: "documents" as const,
filenames: (docsByInput[item.id] ?? []).map(
(doc) => doc.filename,
),
filenames: docsForItem(item.id).map((doc) => doc.filename),
};
});
return { type: "ask_inputs_response", responses };
@ -222,7 +211,7 @@ export function AskInputPopup({
) {
return [];
}
return docsByInput[item.id] ?? [];
return docsForItem(item.id);
});
const seen = new Set<string>();
return docs.flatMap((doc) => {
@ -279,43 +268,38 @@ export function AskInputPopup({
return (
<>
<div className="w-full overflow-hidden rounded-[18px] border border-white/65 bg-white/60 pb-3 font-serif shadow-[0_4px_10px_rgba(15,23,42,0.084),inset_0_1px_0_rgba(255,255,255,0.595),inset_0_-6px_14px_rgba(255,255,255,0.126)] backdrop-blur-2xl md:rounded-[22px]">
<div className="flex min-w-0 items-center justify-between gap-2 bg-gray-100/70 px-3 py-2">
<div className="flex min-w-0 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 items-center">
<div className="text-sm text-gray-500">
{submitted ? (
"Inputs sent"
) : (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{event.items.map((item, index) => {
<div className="flex flex-wrap gap-x-1.5 gap-y-1">
{event.items.map((item) => {
const isActive =
item.id === activeItem?.id;
const isResolved = itemResolved(item);
const label =
item.kind === "choice"
? `Question ${index + 1}`
: "Add Documents";
? "Question"
: "Documents";
return (
<button
<TabPillButton
key={item.id}
type="button"
active={isActive}
disabled={submitted}
onClick={() =>
setActiveInputId(item.id)
}
className={cn(
"inline-flex items-center gap-1 rounded-full py-0.5 font-sans text-[10px] transition-colors disabled:cursor-default",
isActive
? "text-gray-900"
: "text-gray-400 hover:text-gray-700",
)}
className="h-6 px-2 font-sans text-[10px]"
>
{label}
{isResolved ? (
<Check className="h-3 w-3" />
) : (
<span className="h-2.5 w-2.5 rounded-full border border-current opacity-70" />
)}
</button>
{label}
</TabPillButton>
);
})}
</div>
@ -323,20 +307,21 @@ export function AskInputPopup({
</div>
</div>
{!submitted && (
<button
<TabPillButton
type="button"
onClick={dismiss}
className="shrink-0 rounded-full py-0.5 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-700"
aria-label="Dismiss"
className="h-6 w-6 shrink-0 px-0"
>
Esc (end response)
</button>
<X className="h-3 w-3" />
</TabPillButton>
)}
</div>
<div className="px-3">
{activeItem && (
<div className="mt-3 flex min-h-54 flex-col">
<div className="mt-auto">
<div>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{activeItem.kind === "choice" ? (
@ -344,25 +329,9 @@ export function AskInputPopup({
{activeItem.question}
</p>
) : (
<DocumentPrompt item={activeItem} />
<DocumentPrompt />
)}
</div>
{!submitted && (
<button
type="button"
onClick={() =>
setSkippedFor(
activeItem.id,
!skipped.has(activeItem.id),
)
}
className="shrink-0 rounded-full py-0.5 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-800 disabled:cursor-default disabled:opacity-40"
>
{skipped.has(activeItem.id)
? "Unskip"
: "Skip"}
</button>
)}
</div>
<div className="pt-3">
@ -386,18 +355,36 @@ export function AskInputPopup({
onAnswer={(answer) =>
chooseAnswer(activeItem, answer)
}
onOtherOpen={() =>
onOtherOpen={() => {
setOtherOpen((prev) => ({
...prev,
[activeItem.id]: true,
}))
}
onOtherValue={(value) =>
}));
setChoiceAnswers((prev) => ({
...prev,
[activeItem.id]: (
otherValues[
activeItem.id
] ?? ""
).trim(),
}));
}}
onOtherValue={(value) => {
setOtherValues((prev) => ({
...prev,
[activeItem.id]: value,
}))
}
}));
setChoiceAnswers((prev) => ({
...prev,
[activeItem.id]:
value.trim(),
}));
if (value.trim())
setSkippedFor(
activeItem.id,
false,
);
}}
/>
) : (
<DocumentInput
@ -406,77 +393,97 @@ export function AskInputPopup({
submitted ||
skipped.has(activeItem.id)
}
docs={
docsByInput[activeItem.id] ?? []
docsByType={
docsByInput[activeItem.id] ?? {}
}
uploading={
uploadingInputId ===
activeItem.id
onOpenSelector={(typeIndex) =>
setDocSelectorTarget({
inputId: activeItem.id,
typeIndex,
})
}
dragActive={
dragActiveInputId ===
activeItem.id
}
fileInputRef={(node) => {
fileInputsRef.current[
activeItem.id
] = node;
}}
onFiles={(files) =>
void handleFiles(
onRemoveDoc={(typeIndex, docId) =>
removeDoc(
activeItem.id,
files,
typeIndex,
docId,
)
}
onDragActive={(active) =>
setDragActiveInputId(
active
? activeItem.id
: null,
)
}
onBrowse={() =>
setDocSelectorInputId(
activeItem.id,
)
}
onRemoveDoc={(docId) =>
removeDoc(activeItem.id, docId)
}
/>
)}
</div>
</div>
</div>
)}
{uploadWarning && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 font-sans text-xs text-gray-900">
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" />
<span className="min-w-0 flex-1">
{uploadWarning}
</span>
<button
type="button"
onClick={() => setUploadWarning(null)}
className="shrink-0 rounded p-0.5 text-black hover:bg-gray-100"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />
</button>
{!submitted && (
<div className="mt-auto flex items-center justify-end gap-2 pt-3">
<button
type="button"
onClick={() =>
setSkippedFor(
activeItem.id,
!skipped.has(activeItem.id),
)
}
className="px-1 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-800"
>
{skipped.has(activeItem.id)
? "Unskip"
: "Skip"}
</button>
<PillButton
tone="black"
type="button"
disabled={
skipped.has(activeItem.id) ||
confirmed.has(activeItem.id) ||
!itemAnswered(activeItem)
}
onClick={() =>
confirmItem(activeItem.id)
}
className="h-6 px-3 font-sans text-[10px]"
>
{confirmed.has(activeItem.id) ? (
<>
Confirmed
<Check className="h-3 w-3" />
</>
) : (
"Confirm"
)}
</PillButton>
</div>
)}
</div>
)}
</div>
</div>
<AddDocumentsModal
open={!!docSelectorInputId}
onClose={() => setDocSelectorInputId(null)}
open={!!docSelectorTarget}
keepMounted
onClose={() => setDocSelectorTarget(null)}
onSelect={(selected) => {
if (docSelectorInputId)
addDocs(docSelectorInputId, selected);
if (!docSelectorTarget) return;
// A document can only be added once per input, so docs
// already living in another type row are left where they
// are; only genuinely new picks join the targeted row.
const existing = new Set(
docsForItem(docSelectorTarget.inputId).map(
(doc) => doc.id,
),
);
addDocs(
docSelectorTarget.inputId,
docSelectorTarget.typeIndex,
selected.filter((doc) => !existing.has(doc.id)),
);
}}
breadcrumb={["Assistant", "Add Documents"]}
initialSelectedDocuments={
docSelectorTarget
? docsForItem(docSelectorTarget.inputId)
: []
}
/>
</>
);
@ -502,23 +509,23 @@ function OptionInput({
onOtherValue: (value: string) => void;
}) {
return (
<div className="mt-2 grid gap-2">
<div className="mt-2 grid gap-1.5">
{item.options.map((option, idx) => {
const answer = option.value.trim();
const isSelected = selectedAnswer === answer.trim();
const isSelected = !otherOpen && selectedAnswer === answer;
return (
<button
key={`${item.id}-${option.value}-${idx}`}
type="button"
disabled={disabled}
onClick={() => onAnswer(answer)}
className={`w-full rounded-lg p-2 text-left transition-colors ${
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${
isSelected
? "bg-gray-200/80 text-gray-900"
: "bg-gray-100/70 text-gray-700 hover:bg-gray-200/70 disabled:hover:bg-gray-100/70"
} disabled:cursor-default disabled:opacity-60`}
>
<span className="flex items-start gap-1.5">
<span className="flex items-start gap-1">
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
{idx + 1}.
</span>
@ -534,57 +541,48 @@ function OptionInput({
})}
{item.allow_other && (
<div
className={`w-full rounded-lg p-2 transition-colors ${
className={`w-full rounded-lg px-3 py-2 transition-colors ${
otherOpen
? "bg-gray-200/80"
: "cursor-pointer bg-gray-100/70 hover:bg-gray-200/70"
} ${disabled ? "cursor-default opacity-60" : ""}`}
onClick={() => !otherOpen && !disabled && onOtherOpen()}
>
<span className="flex items-start gap-1.5">
<span className="flex items-start gap-1">
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
{item.options.length + 1}.
</span>
{otherOpen ? (
<span className="min-w-0 flex-1 flex items-end gap-2">
<span className="min-w-0 flex-1 flex items-start gap-2">
<textarea
name={`other-${item.id}`}
rows={1}
autoFocus
value={otherValue}
disabled={disabled}
onFocus={(e) => {
const end = e.target.value.length;
e.target.setSelectionRange(end, end);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onChange={(e) => {
onOtherValue(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onAnswer(otherValue);
}
}}
placeholder="Type your answer..."
className="flex-1 resize-none overflow-hidden bg-transparent text-sm leading-5 text-gray-600 outline-none placeholder:text-gray-400"
/>
<button
type="button"
disabled={disabled || !otherValue.trim()}
onClick={(e) => {
e.stopPropagation();
onAnswer(otherValue);
}}
className="shrink-0 flex items-center gap-1 rounded-full bg-blue-600 px-3 py-0.5 font-sans text-[10px] text-white transition-colors hover:bg-blue-700 disabled:cursor-default disabled:opacity-40"
>
Set
<CornerDownLeft className="h-3 w-3" />
</button>
</span>
) : (
<span className="min-w-0 flex-1 text-sm text-gray-700">
{item.other_label || "Other"}
</span>
)}
{otherOpen && (
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-700" />
)}
</span>
</div>
)}
@ -592,139 +590,113 @@ function OptionInput({
);
}
function DocumentPrompt({
item,
}: {
item: Extract<AskInputItem, { kind: "documents" }>;
}) {
const documentTypes = item.document_types ?? [];
function DocumentPrompt() {
return (
<div className="mt-0.5 text-sm text-gray-800">
<p>Add the following documents if available:</p>
{documentTypes.length > 0 && (
<div className="mt-1 space-y-0.5 text-gray-700">
{documentTypes.map((documentType, index) => (
<p
key={`${documentType}-${index}`}
className="break-words"
>
{index + 1}. {documentType}
</p>
))}
</div>
)}
</div>
<p className="mt-0.5 text-sm text-gray-800">
Add the following documents if available:
</p>
);
}
function DocumentInput({
item,
disabled,
docs,
uploading,
dragActive,
fileInputRef,
onFiles,
onDragActive,
onBrowse,
docsByType,
onOpenSelector,
onRemoveDoc,
}: {
item: Extract<AskInputItem, { kind: "documents" }>;
disabled?: boolean;
docs: Document[];
uploading: boolean;
dragActive: boolean;
fileInputRef: (node: HTMLInputElement | null) => void;
onFiles: (files: File[]) => void;
onDragActive: (active: boolean) => void;
onBrowse: () => void;
onRemoveDoc: (docId: string) => void;
docsByType: Record<number, Document[]>;
onOpenSelector: (typeIndex: number) => void;
onRemoveDoc: (typeIndex: number, docId: string) => void;
}) {
const documentTypes = item.document_types ?? [];
const rows = documentTypes.length > 0 ? documentTypes : ["Documents"];
return (
<div className="mt-2">
<input
ref={fileInputRef}
type="file"
accept={SUPPORTED_DOCUMENT_ACCEPT}
multiple
className="hidden"
onChange={(e) => onFiles(Array.from(e.target.files || []))}
/>
<button
type="button"
disabled={disabled || uploading}
onClick={onBrowse}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
if (!disabled && !uploading) onDragActive(true);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
if (!disabled && !uploading) onDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
onDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
onDragActive(false);
onFiles(Array.from(e.dataTransfer.files || []));
}}
className={`flex h-[168px] w-full flex-col items-center justify-center gap-1.5 rounded-lg px-3 py-4 font-sans text-xs transition-colors disabled:cursor-default disabled:opacity-50 ${
dragActive
? "bg-gray-300 text-gray-900"
: "bg-gray-100/80 text-gray-500 hover:bg-gray-200/80 hover:text-gray-800"
}`}
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
<span className="text-gray-800">
{uploading
? "Uploading documents..."
: "Drop files here or click to choose documents"}
</span>
<span className="text-[11px] text-gray-400">
PDF, Word, Excel, or PowerPoint
</span>
</button>
{docs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{docs.map((doc) => {
return (
<div
key={`${item.id}-${doc.id}`}
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
>
<FileTypeIcon
fileType={doc.file_type}
className="h-2.5 w-2.5"
/>
<span className="max-w-[160px] truncate">
{doc.filename}
<div className="mt-2 grid gap-1.5">
{rows.map((documentType, idx) => {
const docs = docsByType[idx] ?? [];
return (
<div
key={`${item.id}-${documentType}-${idx}`}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onClick={() => !disabled && onOpenSelector(idx)}
onKeyDown={(e) => {
if (
!disabled &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault();
onOpenSelector(idx);
}
}}
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${
docs.length > 0
? "bg-gray-200/80 text-gray-900"
: "bg-gray-100/70 text-gray-700"
} ${
disabled
? "cursor-default opacity-60"
: "cursor-pointer hover:bg-gray-200/70"
}`}
>
<span className="flex items-start gap-1">
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
{idx + 1}.
</span>
<span className="min-w-0 flex-1">
<span className="block break-words text-sm">
{documentType}
</span>
{!disabled && (
<button
type="button"
onClick={() => onRemoveDoc(doc.id)}
className="ml-0.5 rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-900/5 hover:text-gray-700"
>
<X className="h-2.5 w-2.5" />
</button>
{docs.length > 0 && (
<span className="mt-1.5 flex flex-wrap gap-1.5">
{docs.map((doc) => (
<span
key={`${item.id}-${doc.id}`}
onClick={(e) =>
e.stopPropagation()
}
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
>
<FileTypeIcon
fileType={doc.file_type}
className="h-2.5 w-2.5"
/>
<span className="max-w-[160px] truncate">
{doc.filename}
</span>
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveDoc(
idx,
doc.id,
);
}}
className="ml-0.5 rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-900/5 hover:text-gray-700"
>
<X className="h-2.5 w-2.5" />
</button>
)}
</span>
))}
</span>
)}
</div>
);
})}
</div>
)}
</span>
<span className="mt-0.5 shrink-0 whitespace-nowrap font-sans text-[10px] text-gray-500">
{docs.length > 0
? `${docs.length} file${docs.length === 1 ? "" : "s"} added`
: "+ Add"}
</span>
</span>
</div>
);
})}
</div>
);
}

View file

@ -18,6 +18,7 @@ import {
type CaseTab,
} from "./CaseLawPanel";
import { cn } from "@/app/lib/utils";
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
// ---------------------------------------------------------------------------
// Tab data
@ -196,7 +197,8 @@ export function AssistantSidePanel({
ref={panelRef}
className={cn(
"relative flex h-full w-full shrink-0 flex-col md:my-3 md:mr-3 md:h-[calc(100%-1.5rem)] md:w-[var(--assistant-panel-width)]",
"rounded-2xl border border-white/70 bg-white shadow-[0_6px_18px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
LIQUID_PANEL_SURFACE_CLASS,
"overflow-hidden",
)}
style={{
"--assistant-panel-width": `${panelWidth}px`,

View file

@ -11,12 +11,14 @@ import {
import {
ArrowRight,
Check,
FolderOpen,
Library,
Loader2,
Square,
Waypoints,
X,
} from "lucide-react";
import { AddDocButton } from "./AddDocButton";
import { UploadOverlay } from "./UploadOverlay";
import { FileTypeIcon } from "../shared/FileTypeIcon";
import { AddDocumentsModal } from "../modals/AddDocumentsModal";
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
@ -30,10 +32,24 @@ import {
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { Document, Message } from "../shared/types";
import type { DirectoryTab } from "../shared/useDirectoryData";
import { cn } from "@/app/lib/utils";
import {
uploadProjectDocument,
uploadStandaloneDocument,
} from "@/app/lib/mikeApi";
import {
formatUnsupportedDocumentWarning,
partitionSupportedDocumentFiles,
} from "@/app/lib/documentUploadValidation";
export interface ChatInputHandle {
addDoc: (doc: Document) => void;
startWorkflowDocumentSelection: (
workflow: { id: string; title: string },
prompt?: string,
options?: { initialDocumentTab?: DirectoryTab },
) => void;
}
interface Props {
@ -42,9 +58,10 @@ interface Props {
isLoading: boolean;
hideAddDocButton?: boolean;
hideWorkflowButton?: boolean;
onProjectsClick?: () => void;
projectName?: string;
projectCmNumber?: string | null;
projectId?: string;
onDocumentsUploaded?: (documents: Document[]) => void;
}
export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
@ -54,9 +71,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
isLoading,
hideAddDocButton,
hideWorkflowButton,
onProjectsClick,
projectName,
projectCmNumber,
projectId,
onDocumentsUploaded,
}: Props,
ref,
) {
@ -73,9 +91,16 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
const controlsRef = useRef<HTMLDivElement>(null);
const [compactControls, setCompactControls] = useState(false);
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
const [docSelectorInitialTab, setDocSelectorInitialTab] =
useState<DirectoryTab>("files");
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
const [apiKeyModalProvider, setApiKeyModalProvider] =
useState<ModelProvider | null>(null);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [droppedDocuments, setDroppedDocuments] = useState<Document[]>([]);
const dragDepthRef = useRef(0);
useImperativeHandle(ref, () => ({
addDoc: (doc: Document) => {
@ -84,6 +109,19 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
return [...prev, doc];
});
},
startWorkflowDocumentSelection: (workflow, prompt, options) => {
setSelectedWorkflow(workflow);
setDocSelectorInitialTab(options?.initialDocumentTab ?? "files");
if (prompt) {
setValue((current) => current || prompt);
requestAnimationFrame(() => {
if (!textareaRef.current) return;
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
});
}
setDocSelectorOpen(true);
},
}));
useEffect(() => {
@ -109,6 +147,102 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
[],
);
const addAttachedDocuments = useCallback((documents: Document[]) => {
setAttachedDocs((prev) => {
const existing = new Set(prev.map((document) => document.id));
return [
...prev,
...documents.filter((document) => !existing.has(document.id)),
];
});
}, []);
const handleDroppedFiles = useCallback(
async (files: File[]) => {
const { supported, unsupported } =
partitionSupportedDocumentFiles(files);
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
if (supported.length === 0) return;
setUploadingFilenames(supported.map((file) => file.name));
const results = await Promise.allSettled(
supported.map((file) =>
projectId
? uploadProjectDocument(projectId, file)
: uploadStandaloneDocument(file),
),
);
const uploaded = results.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
);
if (uploaded.length > 0) {
addAttachedDocuments(uploaded);
setDroppedDocuments((prev) => {
const existing = new Set(
prev.map((document) => document.id),
);
return [
...prev,
...uploaded.filter(
(document) => !existing.has(document.id),
),
];
});
onDocumentsUploaded?.(uploaded);
}
if (results.some((result) => result.status === "rejected")) {
setUploadWarning(
uploaded.length > 0
? "Some documents could not be uploaded."
: "Documents could not be uploaded. Please try again.",
);
}
setUploadingFilenames([]);
},
[addAttachedDocuments, onDocumentsUploaded, projectId],
);
useEffect(() => {
const hasFiles = (dataTransfer: DataTransfer | null) =>
!!dataTransfer && Array.from(dataTransfer.types).includes("Files");
const handleDragEnter = (event: DragEvent) => {
if (!hasFiles(event.dataTransfer)) return;
event.preventDefault();
dragDepthRef.current += 1;
setIsDraggingFiles(true);
};
const handleDragOver = (event: DragEvent) => {
if (!hasFiles(event.dataTransfer)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
};
const handleDragLeave = (event: DragEvent) => {
if (!hasFiles(event.dataTransfer)) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
};
const handleDrop = (event: DragEvent) => {
if (!hasFiles(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = 0;
setIsDraggingFiles(false);
void handleDroppedFiles(Array.from(event.dataTransfer?.files ?? []));
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragover", handleDragOver);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragover", handleDragOver);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("drop", handleDrop);
};
}, [handleDroppedFiles]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
const el = e.target;
@ -216,12 +350,28 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
</div>
)}
{uploadingFilenames.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 px-2 pt-2">
{uploadingFilenames.map((filename, index) => (
<div
key={`${filename}-${index}`}
className="inline-flex items-center gap-1 rounded-[10px] bg-white/75 px-2 py-1 text-xs text-gray-600 shadow-[0_2px_6px_rgba(15,23,42,0.08)] backdrop-blur-xl"
>
<Loader2 className="h-2.5 w-2.5 animate-spin" />
<span className="max-w-[140px] truncate">
{filename}
</span>
</div>
))}
</div>
)}
{/* Input */}
<div className="px-4 pt-4">
<textarea
ref={textareaRef}
rows={1}
placeholder="Ask a question about your documents..."
placeholder="How can I help?"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
@ -237,7 +387,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
<div className="flex items-center gap-1">
{!hideAddDocButton && (
<AddDocButton
onBrowseAll={() => setDocSelectorOpen(true)}
onBrowseAll={() => {
setDocSelectorInitialTab("files");
setDocSelectorOpen(true);
}}
selectedDocIds={attachedDocs.map(
(d) => d.id,
)}
@ -252,14 +405,14 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
className={cn(
"flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors",
selectedWorkflow
? "text-blue-600 hover:bg-white/55"
: "text-gray-400 hover:bg-white/55 hover:text-gray-700",
? "text-blue-600 hover:text-blue-700"
: "text-gray-400 hover:text-gray-700",
)}
>
{selectedWorkflow ? (
<Check className="h-3.5 w-3.5" />
) : (
<Library className="h-3.5 w-3.5" />
<Waypoints className="h-3.5 w-3.5" />
)}
<span
className={
@ -272,22 +425,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
</span>
</button>
)}
{onProjectsClick && (
<button
type="button"
onClick={onProjectsClick}
aria-label="Open projects"
className={cn(
"flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:text-gray-700 transition-colors",
"hover:bg-white/55",
)}
>
<FolderOpen className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
Projects
</span>
</button>
)}
</div>
<div className="flex items-center gap-1">
@ -322,9 +459,18 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
<AddDocumentsModal
open={docSelectorOpen}
keepMounted
onClose={() => setDocSelectorOpen(false)}
onSelect={handleAddDocsFromSelector}
breadcrumb={["Assistant", "Add Documents"]}
initialSelectedDocuments={attachedDocs}
externalUploadedDocuments={droppedDocuments}
initialTab={docSelectorInitialTab}
projectId={projectId}
breadcrumb={
selectedWorkflow
? ["Assistant", selectedWorkflow.title, "Add Documents"]
: ["Assistant", "Add Documents"]
}
/>
<AssistantWorkflowModal
open={workflowModalOpen}
@ -344,6 +490,11 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
provider={apiKeyModalProvider}
onClose={() => setApiKeyModalProvider(null)}
/>
<UploadOverlay
open={isDraggingFiles}
warning={uploadWarning}
onWarningClose={() => setUploadWarning(null)}
/>
</>
);
});

View file

@ -14,7 +14,7 @@ export type CitationQuoteHeaderItem = {
};
const QUOTE_GLASS_SURFACE =
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
const QUOTE_CARD_SURFACE = "rounded-2xl bg-gray-100";
interface Props {

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { supabase } from "@/app/lib/supabase";
import { PillButton } from "@/app/components/ui/pill-button";
import { PdfView } from "../shared/views/PdfView";
import { DocxView } from "../shared/views/DocxView";
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
@ -17,6 +18,7 @@ import {
formatCitationPage,
formatCitationQuotePage,
getDocumentCitationQuotes,
isDocxFilename,
isSpreadsheetFilename,
} from "../shared/types";
import type {
@ -26,11 +28,6 @@ import type {
EditAnnotation,
} from "../shared/types";
function isDocxFilename(name: string): boolean {
const ext = name.split(".").pop()?.toLowerCase();
return ext === "docx" || ext === "doc";
}
/**
* Discriminated-union describing what the panel is showing above the viewer.
* - "document": title row + viewer.
@ -386,17 +383,13 @@ function DownloadButton({
const spinning = busy || isReloading;
return (
<button
onClick={handleClick}
disabled={spinning}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
<PillButton tone="white" onClick={handleClick} disabled={spinning}>
{spinning ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
Download
</button>
</PillButton>
);
}

View file

@ -1,12 +1,25 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { MoreHorizontal } from "lucide-react";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { ChatInput } from "./ChatInput";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal";
import type { Message } from "../shared/types";
import { QuickActionsModal } from "./QuickActionsModal";
import { NewProjectModal } from "../projects/NewProjectModal";
import { NewTRModal } from "../tabular/NewTRModal";
import { createTabularReview } from "@/app/lib/mikeApi";
import { useDirectoryData, type DirectoryTab } from "../shared/useDirectoryData";
import {
QUICK_ACTIONS,
type QuickActionId,
useQuickActionsPreference,
} from "./quickActionsPreferences";
import type { Message, Workflow } from "../shared/types";
interface InitialViewProps {
onSubmit: (message: Message) => void;
@ -14,18 +27,61 @@ interface InitialViewProps {
const ICON_SIZE = 30;
const GAP = 12; // gap-4 = 1rem = 16px
const DOCUMENT_WORKFLOW_ACTIONS: Partial<
Record<
QuickActionId,
{
workflowId: string;
title: string;
prompt: string;
initialDocumentTab?: DirectoryTab;
}
>
> = {
proofread: {
workflowId: "builtin-proofread",
title: "Proofread",
prompt: "proofread",
},
compareDocuments: {
workflowId: "builtin-compare-documents",
title: "Compare Documents",
prompt: "compare documents",
},
extractKeyTerms: {
workflowId: "builtin-extract-key-terms",
title: "Extract Key Terms",
prompt: "extract key terms",
},
draftFromTemplate: {
workflowId: "builtin-draft-from-template",
title: "Draft from Template",
prompt: "draft from template",
initialDocumentTab: "templates",
},
};
export function InitialView({ onSubmit }: InitialViewProps) {
const { user } = useAuth();
const { profile } = useUserProfile();
const router = useRouter();
const [loaded, setLoaded] = useState(false);
const [projectModalOpen, setProjectModalOpen] = useState(false);
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [newTROpen, setNewTROpen] = useState(false);
const [quickActionsModalOpen, setQuickActionsModalOpen] = useState(false);
const { visibleActions, setVisibleActions } = useQuickActionsPreference();
const [iconOffset, setIconOffset] = useState(0);
const [textOffset, setTextOffset] = useState(0);
const textRef = useRef<HTMLHeadingElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const { projects } = useDirectoryData(newTROpen, "projects");
const username =
profile?.displayName?.trim() || user?.email?.split("@")[0] || "there";
const visibleQuickActions = QUICK_ACTIONS.filter(
(action) => visibleActions[action.id],
);
useLayoutEffect(() => {
if (!profile || !textRef.current) return;
@ -40,60 +96,170 @@ export function InitialView({ onSubmit }: InitialViewProps) {
return () => clearTimeout(t);
}, [iconOffset]);
function handleDocumentWorkflowClick(id: QuickActionId) {
const config = DOCUMENT_WORKFLOW_ACTIONS[id];
if (!config) return;
chatInputRef.current?.startWorkflowDocumentSelection(
{
id: config.workflowId,
title: config.title,
},
config.prompt,
{ initialDocumentTab: config.initialDocumentTab },
);
}
async function handleNewReview(
title: string,
projectId?: string,
documentIds?: string[],
columnsConfig?: Workflow["columns_config"],
) {
const review = await createTabularReview({
title,
document_ids: documentIds ?? [],
columns_config: columnsConfig ?? [],
...(projectId && { project_id: projectId }),
});
setNewTROpen(false);
router.push(
projectId
? `/projects/${projectId}/tabular-reviews/${review.id}`
: `/tabular-reviews/${review.id}`,
);
}
function handleQuickAction(id: QuickActionId) {
if (id === "projectChat") {
setProjectModalOpen(true);
} else if (DOCUMENT_WORKFLOW_ACTIONS[id]) {
handleDocumentWorkflowClick(id);
} else if (id === "newProject") {
setNewProjectOpen(true);
} else if (id === "newTabularReview") {
setNewTROpen(true);
}
}
return (
<div className="flex flex-col h-full w-full px-6">
<div className="flex-1 flex flex-col items-center justify-center">
<div className="flex-col items-center w-full max-w-4xl relative px-0 xl:px-8">
<div className="mb-10 relative flex items-center justify-center">
<div
className="absolute h-[30px] w-[30px] top-[-14px]"
style={{
left: "50%",
transform: loaded
? `translateX(calc(-50% - ${iconOffset}px))`
: "translateX(-50%)",
transition:
"transform 900ms cubic-bezier(0.25, 0.46, 0.45, 0.94)",
}}
>
<MikeIcon size={ICON_SIZE} />
</div>
<h1
ref={textRef}
className="absolute text-4xl font-serif font-light text-gray-900 whitespace-nowrap"
style={{
left: "50%",
transform: loaded
? `translateX(calc(-50% + ${textOffset}px))`
: "translateX(-50%)",
opacity: loaded ? 1 : 0,
transition:
"transform 900ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 800ms ease-in-out 300ms",
}}
>
Hi, {username}
</h1>
</div>
<ChatInput
onSubmit={onSubmit}
onCancel={() => {}}
isLoading={false}
onProjectsClick={() => setProjectModalOpen(true)}
/>
<div className="text-center">
<p className="text-xs py-3 mb-3 text-gray-500">
AI can make mistakes. Answers are not legal advice.
</p>
<div className="grid h-full w-full grid-rows-[minmax(0,1fr)_auto_minmax(0,1fr)] px-6">
<div className="flex min-h-0 items-end justify-center pb-6">
<div className="relative h-10 w-full max-w-4xl px-0 xl:px-8">
<div
className="absolute h-[30px] w-[30px]"
style={{
left: "50%",
top: "50%",
transform: loaded
? `translate(calc(-50% - ${iconOffset}px), -50%)`
: "translate(-50%, -50%)",
transition:
"transform 900ms cubic-bezier(0.25, 0.46, 0.45, 0.94)",
}}
>
<MikeIcon size={ICON_SIZE} />
</div>
<h1
ref={textRef}
className="absolute text-4xl font-serif font-light text-gray-900 whitespace-nowrap"
style={{
left: "50%",
top: "50%",
transform: loaded
? `translate(calc(-50% + ${textOffset}px), -50%)`
: "translate(-50%, -50%)",
opacity: loaded ? 1 : 0,
transition:
"transform 900ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 800ms ease-in-out 300ms",
}}
>
Hi, {username}
</h1>
</div>
</div>
<div className="w-full max-w-4xl justify-self-center px-0 xl:px-8">
<ChatInput
ref={chatInputRef}
onSubmit={onSubmit}
onCancel={() => {}}
isLoading={false}
/>
</div>
<div className="min-h-0 w-full max-w-4xl justify-self-center px-0 pt-1 xl:px-8">
<div className="text-center">
<p className="text-xs py-2 mb-12 text-gray-500">
AI can make mistakes. Answers are not legal advice.
</p>
</div>
{visibleQuickActions.length > 0 && (
<div className="flex flex-col items-center">
<div className="group relative flex h-5 items-center justify-center">
<span className="flex items-center gap-1.5 text-xs font-medium text-gray-800">
<Image
src="/icons/app-sidebar/quick-actions.svg"
alt=""
width={14}
height={14}
unoptimized
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0"
/>
Quick actions
</span>
<button
type="button"
onClick={() => setQuickActionsModalOpen(true)}
aria-label="Configure quick actions"
className="absolute left-full ml-1.5 flex h-5 w-5 items-center justify-center text-gray-400 opacity-0 transition-all hover:text-gray-700 group-hover:opacity-100 focus:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 flex flex-wrap justify-center gap-2 text-xs">
{visibleQuickActions.map((action) => (
<button
key={action.id}
type="button"
onClick={() => handleQuickAction(action.id)}
className="inline-flex h-8 items-center justify-center rounded-full border border-white/70 bg-white/55 px-3 font-medium text-gray-600 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-all hover:bg-white hover:text-gray-900 active:scale-[0.98] disabled:cursor-default disabled:opacity-45 disabled:active:scale-100"
>
{action.label}
</button>
))}
</div>
</div>
)}
</div>
<QuickActionsModal
open={quickActionsModalOpen}
onClose={() => setQuickActionsModalOpen(false)}
visibleActions={visibleActions}
onVisibleActionsChange={setVisibleActions}
/>
<SelectAssistantProjectModal
open={projectModalOpen}
onClose={() => setProjectModalOpen(false)}
/>
<NewProjectModal
open={newProjectOpen}
onClose={() => setNewProjectOpen(false)}
onCreated={(project) => {
setNewProjectOpen(false);
router.push(`/projects/${project.id}`);
}}
/>
<NewTRModal
open={newTROpen}
onClose={() => setNewTROpen(false)}
onAdd={handleNewReview}
projects={projects}
/>
</div>
);
}

View file

@ -4,12 +4,14 @@ import { useState } from "react";
import { ChevronDown, Check, AlertCircle } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import {
LiquidDropdownContent,
LiquidDropdownItem,
} from "@/app/components/ui/liquid-dropdown";
import { isModelAvailable } from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
@ -47,6 +49,8 @@ export const DEFAULT_MODEL_ID = "gemini-3-flash-preview";
export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id));
const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"];
const itemClassName =
"rounded-xl px-2.5 py-1.5 text-gray-700 focus:bg-app-surface-hover focus:text-gray-900 data-[highlighted]:bg-app-surface-hover data-[highlighted]:text-gray-900";
interface Props {
value: string;
@ -67,7 +71,7 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) {
<DropdownMenuTrigger asChild>
<button
type="button"
className={`flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors cursor-pointer text-gray-400 hover:bg-gray-100 hover:text-gray-700 ${isOpen ? "bg-gray-100 text-gray-700" : ""}`}
className={`flex h-8 cursor-pointer items-center gap-1.5 rounded-full px-2 text-sm text-gray-400 transition-colors hover:text-gray-700 ${isOpen ? "text-gray-700" : ""}`}
title={
!selectedAvailable
? "API key missing for selected model"
@ -83,13 +87,19 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) {
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 z-50" side="top" align="end">
<LiquidDropdownContent
className="z-50 w-56 p-1.5 text-gray-700"
side="top"
align="end"
>
{GROUP_ORDER.map((group, gi) => {
const items = MODELS.filter((m) => m.group === group);
if (items.length === 0) return null;
return (
<div key={group}>
{gi > 0 && <DropdownMenuSeparator />}
{gi > 0 && (
<DropdownMenuSeparator className="-mx-1 my-1 bg-white/70" />
)}
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-gray-400">
{group}
</DropdownMenuLabel>
@ -98,9 +108,9 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) {
? isModelAvailable(m.id, apiKeys)
: true;
return (
<DropdownMenuItem
<LiquidDropdownItem
key={m.id}
className="cursor-pointer"
className={`${itemClassName} ${m.id === value ? "bg-app-surface-hover text-gray-900 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]" : ""}`}
onSelect={() => onChange(m.id)}
>
<span
@ -117,13 +127,13 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) {
{m.id === value && available && (
<Check className="h-3.5 w-3.5 text-gray-600 ml-1" />
)}
</DropdownMenuItem>
</LiquidDropdownItem>
);
})}
</div>
);
})}
</DropdownMenuContent>
</LiquidDropdownContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { Check } from "lucide-react";
import { Modal } from "../modals/Modal";
import { QUICK_ACTIONS, type QuickActionId } from "./quickActionsPreferences";
interface QuickActionsModalProps {
open: boolean;
visibleActions: Record<QuickActionId, boolean>;
onVisibleActionsChange: (
next:
| Record<QuickActionId, boolean>
| ((
prev: Record<QuickActionId, boolean>,
) => Record<QuickActionId, boolean>),
) => void;
onClose: () => void;
}
export function QuickActionsModal({
open,
visibleActions,
onVisibleActionsChange,
onClose,
}: QuickActionsModalProps) {
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={["Assistant", "Edit quick actions"]}
cancelAction={false}
primaryAction={{
label: "Done",
onClick: onClose,
}}
>
<div className="flex min-h-0 flex-1 flex-col pb-5">
<div className="grid grid-cols-[minmax(0,1fr)_112px] px-2 pb-1 pt-0.5 text-[11px] font-medium text-gray-400">
<span>Quick action</span>
<span className="flex items-center justify-end gap-2">
<span>Enabled</span>
</span>
</div>
<div className="w-full space-y-1">
{QUICK_ACTIONS.map((action) => {
const checked = visibleActions[action.id];
return (
<button
key={action.id}
type="button"
role="checkbox"
aria-checked={checked}
onClick={() =>
onVisibleActionsChange((prev) => ({
...prev,
[action.id]: !checked,
}))
}
className="grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_112px] items-center rounded-lg px-2 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100"
>
<span className="min-w-0 truncate">
{action.label}
</span>
<span
className={`ml-auto flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
checked
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{checked && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
</button>
);
})}
</div>
</div>
</Modal>
);
}

View file

@ -16,7 +16,7 @@ export function SelectAssistantProjectModal({ open, onClose }: Props) {
const [creating, setCreating] = useState(false);
const router = useRouter();
const { saveChat } = useChatHistoryContext();
const { loading, projects } = useDirectoryData(open);
const { loading, projects } = useDirectoryData(open, "projects");
useEffect(() => {
if (!open) return;

View file

@ -0,0 +1,37 @@
"use client";
import { createPortal } from "react-dom";
import { WarningPopup } from "../popups/WarningPopup";
interface Props {
open: boolean;
label?: string;
warning?: string | null;
onWarningClose?: () => void;
}
export function UploadOverlay({
open,
label = "Drop files here to add to chat",
warning,
onWarningClose,
}: Props) {
return (
<>
{open &&
createPortal(
<div className="fixed inset-0 z-[260] flex items-center justify-center bg-white/35 p-6 backdrop-blur-md">
<p className="font-serif text-xl text-gray-900">
{label}
</p>
</div>,
document.body,
)}
<WarningPopup
open={!!warning}
onClose={onWarningClose ?? (() => {})}
message={warning}
/>
</>
);
}

View file

@ -194,11 +194,13 @@ export function DocReadBlock({
onClick,
showConnector,
isStreaming,
showFileIcon = true,
}: {
filename: string;
onClick?: () => void;
showConnector?: boolean;
isStreaming?: boolean;
showFileIcon?: boolean;
}) {
return (
<EventBlock
@ -212,10 +214,12 @@ export function DocReadBlock({
</span>
{isStreaming ? (
<span className="flex min-w-0 items-center gap-1.5">
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
{showFileIcon && (
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
)}
<span className="truncate">{filename}...</span>
</span>
) : onClick ? (
@ -223,18 +227,22 @@ export function DocReadBlock({
onClick={onClick}
className="flex min-w-0 items-center gap-1.5 text-left transition-colors hover:text-gray-700 cursor-pointer"
>
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
{showFileIcon && (
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
)}
<span className="truncate">{filename}</span>
</button>
) : (
<span className="flex min-w-0 items-center gap-1.5">
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
{showFileIcon && (
<FileTypeIcon
fileType={filename}
className="h-3.5 w-3.5"
/>
)}
<span className="truncate">{filename}</span>
</span>
)}

View file

@ -0,0 +1,148 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
export type QuickActionId =
| "projectChat"
| "proofread"
| "compareDocuments"
| "extractKeyTerms"
| "draftFromTemplate"
| "newProject"
| "newTabularReview";
export const QUICK_ACTIONS: { id: QuickActionId; label: string }[] = [
{ id: "proofread", label: "Proofread" },
{ id: "compareDocuments", label: "Compare documents" },
{ id: "extractKeyTerms", label: "Extract key terms" },
{ id: "draftFromTemplate", label: "Draft from template" },
{ id: "newProject", label: "New project" },
{ id: "newTabularReview", label: "New tabular review" },
{ id: "projectChat", label: "Start chat in project" },
];
export const DEFAULT_QUICK_ACTIONS: Record<QuickActionId, boolean> = {
projectChat: true,
proofread: true,
compareDocuments: true,
extractKeyTerms: true,
draftFromTemplate: true,
newProject: false,
newTabularReview: false,
};
const QUICK_ACTIONS_STORAGE_KEY = "mike.quickActions.visible";
const QUICK_ACTIONS_UPDATED_EVENT = "mike:quick-actions-updated";
let cachedRawPreference: string | null | undefined;
let cachedPreference: Record<QuickActionId, boolean> = DEFAULT_QUICK_ACTIONS;
function normalizeQuickActions(value: unknown): Record<QuickActionId, boolean> {
if (!value || typeof value !== "object") return DEFAULT_QUICK_ACTIONS;
const record = value as Partial<Record<QuickActionId, unknown>>;
return QUICK_ACTIONS.reduce<Record<QuickActionId, boolean>>(
(next, action) => {
const storedValue = record[action.id];
next[action.id] =
typeof storedValue === "boolean"
? storedValue
: DEFAULT_QUICK_ACTIONS[action.id];
return next;
},
{ ...DEFAULT_QUICK_ACTIONS },
);
}
function readQuickActionsPreference(): Record<QuickActionId, boolean> {
if (typeof window === "undefined") return DEFAULT_QUICK_ACTIONS;
try {
const stored = window.localStorage.getItem(QUICK_ACTIONS_STORAGE_KEY);
if (stored === cachedRawPreference) return cachedPreference;
cachedRawPreference = stored;
cachedPreference = stored
? normalizeQuickActions(JSON.parse(stored))
: DEFAULT_QUICK_ACTIONS;
return cachedPreference;
} catch {
return DEFAULT_QUICK_ACTIONS;
}
}
function persistQuickActionsPreference(
value: Record<QuickActionId, boolean>,
) {
if (typeof window === "undefined") return;
const serialized = JSON.stringify(value);
cachedRawPreference = serialized;
cachedPreference = value;
window.localStorage.setItem(QUICK_ACTIONS_STORAGE_KEY, serialized);
window.dispatchEvent(new Event(QUICK_ACTIONS_UPDATED_EVENT));
}
export function useQuickActionsPreference() {
const visibleActions = useSyncExternalStore(
(handleQuickActionsUpdated) => {
if (typeof window === "undefined") return () => {};
window.addEventListener("storage", handleQuickActionsUpdated);
window.addEventListener(
QUICK_ACTIONS_UPDATED_EVENT,
handleQuickActionsUpdated,
);
return () => {
window.removeEventListener(
"storage",
handleQuickActionsUpdated,
);
window.removeEventListener(
QUICK_ACTIONS_UPDATED_EVENT,
handleQuickActionsUpdated,
);
};
},
readQuickActionsPreference,
() => DEFAULT_QUICK_ACTIONS,
);
const setVisibleActions = useCallback(
(
next:
| Record<QuickActionId, boolean>
| ((
prev: Record<QuickActionId, boolean>,
) => Record<QuickActionId, boolean>),
) => {
const prev = readQuickActionsPreference();
const resolved = typeof next === "function" ? next(prev) : next;
persistQuickActionsPreference(normalizeQuickActions(resolved));
},
[],
);
const showAllQuickActions = useCallback(() => {
setVisibleActions(DEFAULT_QUICK_ACTIONS);
}, [setVisibleActions]);
const hideAllQuickActions = useCallback(() => {
setVisibleActions(
QUICK_ACTIONS.reduce<Record<QuickActionId, boolean>>(
(next, action) => {
next[action.id] = false;
return next;
},
{ ...DEFAULT_QUICK_ACTIONS },
),
);
}, [setVisibleActions]);
return {
visibleActions,
setVisibleActions,
showAllQuickActions,
hideAllQuickActions,
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,357 @@
"use client";
import {
createContext,
type Dispatch,
type ReactNode,
type SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useRouter } from "next/navigation";
import { Plus, Upload } from "lucide-react";
import { DocTable } from "@/app/components/documents/DocTable";
import type { DocTableFolder } from "@/app/components/documents/DocTable";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { TableToolbar } from "@/app/components/shared/TableToolbar";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import {
createLibraryFolder,
deleteLibraryFolder,
getLibrary,
moveLibraryDocument,
moveLibraryFolder,
renameLibraryDocument,
renameLibraryFolder,
uploadLibraryDocument,
type LibraryKind,
} from "@/app/lib/mikeApi";
import type { Document } from "@/app/components/shared/types";
type LibraryViewCollection = {
documents: Document[];
folders: DocTableFolder[];
};
type LibraryWorkspaceContextValue = {
collections: Record<LibraryKind, LibraryViewCollection | null>;
loadingByKind: Record<LibraryKind, boolean>;
searchByKind: Record<LibraryKind, string>;
loadLibrary: (
kind: LibraryKind,
options?: { showLoading?: boolean },
) => Promise<void>;
setSearchForKind: (kind: LibraryKind, value: string) => void;
setDocumentsForKind: (
kind: LibraryKind,
update: SetStateAction<Document[]>,
) => void;
setFoldersForKind: (
kind: LibraryKind,
update: SetStateAction<DocTableFolder[]>,
) => void;
};
const LIBRARY_TABS: { id: LibraryKind; label: string }[] = [
{ id: "files", label: "Files" },
{ id: "templates", label: "Templates" },
];
const EMPTY_COLLECTION: LibraryViewCollection = {
documents: [],
folders: [],
};
const LibraryWorkspaceContext =
createContext<LibraryWorkspaceContextValue | null>(null);
function useLibraryWorkspace() {
const context = useContext(LibraryWorkspaceContext);
if (!context) {
throw new Error(
"useLibraryWorkspace must be used inside LibraryWorkspaceProvider",
);
}
return context;
}
export function LibraryWorkspaceProvider({
children,
}: {
children: ReactNode;
}) {
const [collections, setCollections] = useState<
Record<LibraryKind, LibraryViewCollection | null>
>({
files: null,
templates: null,
});
const [loadingByKind, setLoadingByKind] = useState<
Record<LibraryKind, boolean>
>({
files: false,
templates: false,
});
const [searchByKind, setSearchByKind] = useState<
Record<LibraryKind, string>
>({
files: "",
templates: "",
});
const loadLibrary = useCallback(
async (kind: LibraryKind, options: { showLoading?: boolean } = {}) => {
if (options.showLoading) {
setLoadingByKind((prev) => ({ ...prev, [kind]: true }));
}
try {
const loaded = await getLibrary(kind);
setCollections((prev) => ({
...prev,
[kind]: {
documents: loaded.documents,
folders: loaded.folders,
},
}));
} catch (error) {
console.error("[library] failed to load", error);
setCollections((prev) => ({
...prev,
[kind]: EMPTY_COLLECTION,
}));
} finally {
if (options.showLoading) {
setLoadingByKind((prev) => ({ ...prev, [kind]: false }));
}
}
},
[],
);
const setSearchForKind = useCallback((kind: LibraryKind, value: string) => {
setSearchByKind((prev) => ({ ...prev, [kind]: value }));
}, []);
const setDocumentsForKind = useCallback(
(kind: LibraryKind, update: SetStateAction<Document[]>) => {
setCollections((prev) => {
const current = prev[kind] ?? EMPTY_COLLECTION;
const nextDocuments =
typeof update === "function"
? update(current.documents)
: update;
return {
...prev,
[kind]: {
...current,
documents: nextDocuments,
},
};
});
},
[],
);
const setFoldersForKind = useCallback(
(kind: LibraryKind, update: SetStateAction<DocTableFolder[]>) => {
setCollections((prev) => {
const current = prev[kind] ?? EMPTY_COLLECTION;
const nextFolders =
typeof update === "function"
? update(current.folders)
: update;
return {
...prev,
[kind]: {
...current,
folders: nextFolders,
},
};
});
},
[],
);
const value = useMemo(
() => ({
collections,
loadingByKind,
searchByKind,
loadLibrary,
setSearchForKind,
setDocumentsForKind,
setFoldersForKind,
}),
[
collections,
loadingByKind,
loadLibrary,
searchByKind,
setDocumentsForKind,
setFoldersForKind,
setSearchForKind,
],
);
return (
<LibraryWorkspaceContext.Provider value={value}>
{children}
</LibraryWorkspaceContext.Provider>
);
}
export function LibraryWorkspaceLayout({ children }: { children: ReactNode }) {
return <LibraryWorkspaceProvider>{children}</LibraryWorkspaceProvider>;
}
export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) {
const router = useRouter();
const {
collections,
loadingByKind,
searchByKind,
loadLibrary,
setSearchForKind,
setDocumentsForKind,
setFoldersForKind,
} = useLibraryWorkspace();
const collection = collections[kind];
const search = searchByKind[kind];
const title = kind === "files" ? "Files" : "Templates";
useEffect(() => {
if (collection) return;
void loadLibrary(kind, { showLoading: true });
}, [collection, kind, loadLibrary]);
const setDocuments: Dispatch<SetStateAction<Document[]>> = useCallback(
(update) => setDocumentsForKind(kind, update),
[kind, setDocumentsForKind],
);
const setFolders: Dispatch<SetStateAction<DocTableFolder[]>> = useCallback(
(update) => setFoldersForKind(kind, update),
[kind, setFoldersForKind],
);
const [addDocumentsAction, setAddDocumentsAction] = useState<
(() => void) | null
>(null);
const [createFolderAction, setCreateFolderAction] = useState<
(() => void) | null
>(null);
const loading = !collection || loadingByKind[kind];
const addCollectionLabel = kind === "templates" ? "Templates" : "Files";
const handleAddDocumentsActionChange = useCallback(
(action: (() => void) | null) => {
setAddDocumentsAction(() => action);
},
[],
);
const handleCreateFolderActionChange = useCallback(
(action: (() => void) | null) => {
setCreateFolderAction(() => action);
},
[],
);
const operations = useMemo(
() => ({
uploadDocument: (file: File) => uploadLibraryDocument(kind, file),
refreshCollection: () => loadLibrary(kind),
createFolder: (name: string, parentFolderId?: string | null) =>
createLibraryFolder(kind, name, parentFolderId),
renameFolder: (folderId: string, name: string) =>
renameLibraryFolder(kind, folderId, name),
deleteFolder: (folderId: string) =>
deleteLibraryFolder(kind, folderId),
moveFolder: (folderId: string, parentFolderId: string | null) =>
moveLibraryFolder(kind, folderId, parentFolderId),
moveDocument: (documentId: string, folderId: string | null) =>
moveLibraryDocument(kind, documentId, folderId),
renameDocument: (documentId: string, filename: string) =>
renameLibraryDocument(kind, documentId, filename),
}),
[kind, loadLibrary],
);
return (
<div className="flex h-full min-h-0 flex-col">
<PageHeader
breadcrumbs={[{ label: "Library" }, { label: title }]}
actionGroups={[
{
actions: [
{
type: "search",
value: search,
onChange: (value) =>
setSearchForKind(kind, value),
placeholder: `Search ${title.toLowerCase()}...`,
},
],
},
{
actions: [
{
icon: <Upload className="h-3.5 w-3.5" />,
label: (
<span className="hidden sm:inline">
{addCollectionLabel}
</span>
),
title: `Add ${addCollectionLabel}`,
onClick: addDocumentsAction ?? undefined,
disabled: !addDocumentsAction || loading,
},
],
},
]}
/>
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<TableToolbar
items={LIBRARY_TABS}
active={kind}
onChange={(next) =>
router.push(
next === "files" ? "/library" : "/library/templates",
)
}
actions={
<TabPillButton
onClick={createFolderAction ?? undefined}
disabled={!createFolderAction || loading}
>
<Plus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Folder</span>
</TabPillButton>
}
/>
<DocTable
scopeKey={kind}
documents={collection?.documents ?? []}
setDocuments={setDocuments}
folders={collection?.folders ?? []}
setFolders={setFolders}
loading={loading}
search={search}
operations={operations}
onAddDocumentsActionChange={handleAddDocumentsActionChange}
onCreateFolderActionChange={
handleCreateFolderActionChange
}
enableHeaderFilters
emptyDropLabel={
kind === "templates"
? "Drop template files here"
: "Drop PDF, Word, Excel, or PowerPoint files here"
}
/>
</div>
</div>
);
}

View file

@ -9,10 +9,7 @@ import {
} from "@/app/lib/mikeApi";
import type { Document } from "../shared/types";
import { FileDirectory } from "../shared/FileDirectory";
import {
useDirectoryData,
invalidateDirectoryCache,
} from "../shared/useDirectoryData";
import type { DirectoryTab } from "../shared/useDirectoryData";
import { Modal } from "./Modal";
import {
SUPPORTED_DOCUMENT_ACCEPT,
@ -20,15 +17,19 @@ import {
partitionSupportedDocumentFiles,
} from "@/app/lib/documentUploadValidation";
export { invalidateDirectoryCache };
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: Document[], projectId?: string) => void;
breadcrumb: string[];
allowMultiple?: boolean;
initialTab?: DirectoryTab;
projectId?: string;
initialSelectedDocuments?: Document[];
/** Documents uploaded outside the modal while it is mounted. */
externalUploadedDocuments?: Document[];
/** Keep the modal mounted (hidden) while closed so the loaded
* directory listing survives close/reopen cycles. */
keepMounted?: boolean;
}
export function AddDocumentsModal({
@ -36,52 +37,91 @@ export function AddDocumentsModal({
onClose,
onSelect,
breadcrumb,
allowMultiple = true,
initialTab = "files",
projectId,
initialSelectedDocuments,
externalUploadedDocuments,
keepMounted = false,
}: Props) {
const { loading, standaloneDocuments, projects } = useDirectoryData(open);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectedDocuments, setSelectedDocuments] = useState<Document[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]);
// Tracks whether the modal has ever been opened, so keepMounted only
// keeps it (and its directory fetch) alive after first use rather than
// eagerly loading on page mount.
const [hasOpened, setHasOpened] = useState(open);
const fileInputRef = useRef<HTMLInputElement>(null);
const wasOpenRef = useRef(false);
useEffect(() => {
if (!open) return;
setSelectedIds(new Set());
setExtraUploadedDocs([]);
setUploadingFilenames([]);
setUploadWarning(null);
if (open) setHasOpened(true);
}, [open]);
if (!open) return null;
// Key the sync on the id list itself so a reopen targeting different
// documents (or ids arriving late) always re-seeds the selection.
const initialSelectionKey = (initialSelectedDocuments ?? [])
.map((document) => document.id)
.join("|");
useEffect(() => {
if (!open) {
wasOpenRef.current = false;
return;
}
setSelectedDocuments((prev) => {
if (!wasOpenRef.current) return initialSelectedDocuments ?? [];
const next = new Map(prev.map((document) => [document.id, document]));
for (const document of initialSelectedDocuments ?? []) {
next.set(document.id, document);
}
return [...next.values()];
});
setUploadingFilenames([]);
setUploadWarning(null);
if (!keepMounted) {
// When kept mounted there is no refetch on reopen, so the
// listing (including this session's uploads) must survive.
setExtraUploadedDocs([]);
}
wasOpenRef.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialSelectionKey]);
const allStandalone = [
...extraUploadedDocs.filter(
(u) => !standaloneDocuments.some((d) => d.id === u.id),
),
...standaloneDocuments,
];
const externalUploadKey = (externalUploadedDocuments ?? [])
.map((document) => document.id)
.join("|");
useEffect(() => {
if (!externalUploadedDocuments?.length) return;
setExtraUploadedDocs((prev) => {
const next = new Map(prev.map((document) => [document.id, document]));
for (const document of externalUploadedDocuments) {
next.set(document.id, document);
}
return [...next.values()];
});
if (open) {
setSelectedDocuments((prev) => {
const next = new Map(
prev.map((document) => [document.id, document]),
);
for (const document of externalUploadedDocuments) {
next.set(document.id, document);
}
return [...next.values()];
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalUploadKey]);
const availableProjects = projects
.filter((p) => p.id !== projectId)
.map((p) => ({
...p,
documents: p.documents || [],
}));
const allDocs = [
...allStandalone,
...availableProjects.flatMap((p) => p.documents || []),
];
if (!open && (!keepMounted || !hasOpened)) return null;
async function handleConfirm() {
const selected = allDocs.filter((d) => selectedIds.has(d.id));
if (projectId) {
const toAssign = selected.filter((d) => d.project_id !== projectId);
const alreadyHere = selected.filter(
const toAssign = selectedDocuments.filter(
(d) => d.project_id !== projectId,
);
const alreadyHere = selectedDocuments.filter(
(d) => d.project_id === projectId,
);
if (toAssign.length > 0) {
@ -106,11 +146,11 @@ export function AddDocumentsModal({
}
const projectIds = new Set(
selected.map((d) => d.project_id).filter(Boolean),
selectedDocuments.map((d) => d.project_id).filter(Boolean),
);
const singleProjectId =
projectIds.size === 1 ? [...projectIds][0]! : undefined;
onSelect(selected, singleProjectId);
onSelect(selectedDocuments, singleProjectId);
onClose();
}
@ -134,11 +174,14 @@ export function AddDocumentsModal({
: uploadStandaloneDocument(f),
),
);
invalidateDirectoryCache();
setExtraUploadedDocs((prev) => [...uploaded, ...prev]);
uploaded.forEach((d) =>
setSelectedIds((prev) => new Set([...prev, d.id])),
);
setSelectedDocuments((prev) => [
...prev,
...uploaded.filter(
(document) =>
!prev.some((selected) => selected.id === document.id),
),
]);
} catch (err) {
console.error("Upload failed:", err);
} finally {
@ -152,6 +195,7 @@ export function AddDocumentsModal({
<Modal
open={open}
onClose={onClose}
keepMounted={keepMounted}
breadcrumbs={breadcrumb}
secondaryAction={{
label: uploading ? "Uploading…" : "Upload",
@ -163,17 +207,10 @@ export function AddDocumentsModal({
onClick: () => fileInputRef.current?.click(),
disabled: uploading,
}}
footerStatus={
selectedIds.size > 0 ? (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
) : null
}
primaryAction={{
label: uploading ? "Saving…" : "Confirm",
onClick: handleConfirm,
disabled: selectedIds.size === 0 || uploading,
disabled: selectedDocuments.length === 0 || uploading,
}}
>
<input
@ -202,18 +239,13 @@ export function AddDocumentsModal({
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={allStandalone}
directoryProjects={availableProjects}
loading={loading}
selectedIds={selectedIds}
onChange={setSelectedIds}
allowMultiple={allowMultiple}
emptyMessage="No documents yet"
documents={extraUploadedDocs}
selectedDocuments={selectedDocuments}
onChange={setSelectedDocuments}
uploadingFilenames={uploadingFilenames}
searchable
searchAutoFocus
searchNoResultsMessage="No matches found"
showProjectTabs
showTabs
initialTab={initialTab}
excludeProjectId={projectId}
/>
</div>
</Modal>

View file

@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { X } from "lucide-react";
@ -28,6 +29,12 @@ interface ModalProps {
primaryAction?: ModalAction;
secondaryAction?: ModalAction;
cancelAction?: ModalAction | false;
/**
* Keep the modal (and its children's state) mounted while closed,
* rendering it hidden instead of unmounting. Lets content like loaded
* directory listings survive close/reopen cycles.
*/
keepMounted?: boolean;
}
const sizeClassName: Record<ModalSize, string> = {
@ -49,7 +56,12 @@ export function Modal({
primaryAction,
secondaryAction,
cancelAction,
keepMounted = false,
}: ModalProps) {
// Portals can't render during SSR, so a keep-mounted modal only renders
// (hidden) after the first client mount.
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => setHasMounted(true), []);
const hasHeader = breadcrumbs?.length;
const hasFooter =
footerStatus ||
@ -58,13 +70,14 @@ export function Modal({
cancelAction;
const resolvedCancelAction = cancelAction;
if (!open) return null;
if (!open && (!keepMounted || !hasMounted)) return null;
return createPortal(
<div
className={cn(
"fixed inset-0 z-[200] flex items-center justify-center px-4",
"bg-white/10 backdrop-blur-[2px]",
!open && "hidden",
)}
onClick={onClose}
>

View file

@ -29,7 +29,7 @@ export function ModalSegmentedToggle<T extends string>({
return (
<div
className={cn(
"inline-grid gap-1 rounded-full bg-gray-100",
"inline-grid gap-1 rounded-full bg-white/80 shadow-[0_6px_18px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(15,23,42,0.04)] backdrop-blur-xl",
size === "sm" ? "h-8 p-1" : "h-9 p-1",
className,
)}
@ -51,7 +51,7 @@ export function ModalSegmentedToggle<T extends string>({
"flex h-full items-center justify-center rounded-full text-xs transition-all disabled:cursor-not-allowed disabled:opacity-60",
size === "sm" ? "gap-1 px-3" : "gap-1.5 px-3",
active
? "bg-white/80 text-gray-900 shadow-[0_5px_16px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.62)] backdrop-blur-xl"
? "bg-gray-100 text-gray-900"
: "text-gray-500 hover:text-gray-700",
)}
>

View file

@ -1,8 +1,8 @@
"use client";
import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Folder } from "lucide-react";
import { SearchBar } from "@/app/components/ui/search-bar";
import { ClosedProjectSvgIcon } from "@/app/components/shared/FolderSvgIcon";
import type { Project } from "../shared/types";
import { Modal } from "./Modal";
@ -66,7 +66,7 @@ export function ProjectPickerModal({
key={i}
className="flex items-center gap-2 rounded-md px-2 py-2"
>
<div className="h-3.5 w-3.5 rounded-full border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-100 animate-pulse"
@ -110,7 +110,7 @@ export function ProjectPickerModal({
<span className="h-1.5 w-1.5 rounded-sm bg-white" />
)}
</span>
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<ClosedProjectSvgIcon className="h-3.5 w-3.5 shrink-0" />
<span
className={`flex-1 truncate ${isSelected ? "text-gray-900" : "text-gray-700"}`}
>

View file

@ -7,10 +7,9 @@ import {
createProject,
uploadProjectDocument,
} from "@/app/lib/mikeApi";
import { useDirectoryData } from "../shared/useDirectoryData";
import { FileDirectory } from "../shared/FileDirectory";
import { AddUserInput } from "../shared/AddUserInput";
import type { Project } from "../shared/types";
import type { Document, Project } from "../shared/types";
import type { UserLookupResult } from "@/app/lib/mikeApi";
import { useAuth } from "@/app/contexts/AuthContext";
import { Modal } from "../modals/Modal";
@ -30,7 +29,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
const [cmNumber, setCmNumber] = useState("");
const [practice, setPractice] = useState("");
const [sharedUsers, setSharedUsers] = useState<UserLookupResult[]>([]);
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(new Set());
const [selectedDocuments, setSelectedDocuments] = useState<Document[]>([]);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@ -39,8 +38,6 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
const formId = "new-project-modal-form";
const { loading: dirLoading, standaloneDocuments, projects: dirProjects } = useDirectoryData(open);
if (!open) return null;
function submitterValue(e: React.FormEvent<HTMLFormElement>) {
@ -81,10 +78,15 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
: sharedUsers.map((user) => user.email),
);
await Promise.all([
...[...selectedDocIds].map((id) => addDocumentToProject(project.id, id).catch(() => {})),
...selectedDocuments.map((document) =>
addDocumentToProject(project.id, document.id).catch(() => {}),
),
...pendingFiles.map((f) => uploadProjectDocument(project.id, f).catch(() => {})),
]);
onCreated({ ...project, document_count: selectedDocIds.size + pendingFiles.length });
onCreated({
...project,
document_count: selectedDocuments.length + pendingFiles.length,
});
resetForm();
onClose();
} catch (err: unknown) {
@ -100,7 +102,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
setCmNumber("");
setPractice("");
setSharedUsers([]);
setSelectedDocIds(new Set());
setSelectedDocuments([]);
setPendingFiles([]);
setError("");
}
@ -311,15 +313,9 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
) : (
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={standaloneDocuments}
directoryProjects={dirProjects}
loading={dirLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
emptyMessage="No existing documents"
searchable
searchAutoFocus
showProjectTabs
selectedDocuments={selectedDocuments}
onChange={setSelectedDocuments}
showTabs
/>
</div>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { type Dispatch, type SetStateAction } from "react";
import { MessageSquare } from "lucide-react";
import { useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { Plus } from "lucide-react";
import {
RowActionMenuItems,
RowActions,
@ -14,13 +14,18 @@ import {
TableBody,
TableCell,
TableEmptyState,
TableFilters,
type TableFilterOption,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
type TableSortDirection,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
import { PillButton } from "@/app/components/ui/pill-button";
import { ChatSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import type { Chat } from "@/app/components/shared/types";
import { formatDate } from "./ProjectPageParts";
@ -29,12 +34,17 @@ function creatorLabel(chat: Chat, currentUserId?: string | null) {
return chat.creator_display_name?.trim() || "Shared";
}
type ProjectChatSortKey = "name" | "created";
const SORT_OPTIONS: TableFilterOption<TableSortDirection>[] = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
export function ProjectAssistantTable({
chats,
filteredChats,
selectedChatIds,
allChatsSelected,
someChatsSelected,
renamingChatId,
renameChatValue,
currentUserId,
@ -66,34 +76,148 @@ export function ProjectAssistantTable({
setRenameChatValue: Dispatch<SetStateAction<string>>;
loading?: boolean;
}) {
const [creatorFilter, setCreatorFilter] = useState<string | null>(null);
const [sort, setSort] = useState<{
key: ProjectChatSortKey;
direction: TableSortDirection;
} | null>(null);
function clearSelection() {
setSelectedChatIds([]);
}
function handleCreatorFilterChange(value: string | null) {
setCreatorFilter(value);
clearSelection();
}
function handleSortChange(
key: ProjectChatSortKey,
direction: TableSortDirection | null,
) {
setSort(direction ? { key, direction } : null);
clearSelection();
}
const creatorOptions = useMemo(
() =>
Array.from(
new Set(chats.map((chat) => creatorLabel(chat, currentUserId))),
)
.sort((a, b) => a.localeCompare(b))
.map((creator) => ({ value: creator, label: creator })),
[chats, currentUserId],
);
const visibleChats = useMemo(() => {
const rows = filteredChats.filter(
(chat) =>
!creatorFilter ||
creatorLabel(chat, currentUserId) === creatorFilter,
);
if (!sort) return rows;
return [...rows].sort((a, b) => {
const multiplier = sort.direction === "asc" ? 1 : -1;
if (sort.key === "created") {
return (
(new Date(a.created_at).getTime() -
new Date(b.created_at).getTime()) *
multiplier
);
}
return (
(a.title ?? "Untitled Chat").localeCompare(
b.title ?? "Untitled Chat",
) * multiplier
);
});
}, [creatorFilter, currentUserId, filteredChats, sort]);
const allVisibleChatsSelected =
visibleChats.length > 0 &&
visibleChats.every((chat) => selectedChatIds.includes(chat.id));
const someVisibleChatsSelected =
!allVisibleChatsSelected &&
visibleChats.some((chat) => selectedChatIds.includes(chat.id));
const nameSortDirection = sort?.key === "name" ? sort.direction : null;
const createdSortDirection =
sort?.key === "created" ? sort.direction : null;
const nameFilterButton = (
<TableFilters
label="Sort by chat name"
value={nameSortDirection}
allLabel="Default Order"
widthClassName="w-40"
align="right"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("name", direction)}
/>
);
const creatorFilterButton = (
<TableFilters
label="Filter by creator"
value={creatorFilter}
allLabel="All Creators"
widthClassName="w-44"
options={creatorOptions}
onChange={handleCreatorFilterChange}
/>
);
const createdFilterButton = (
<TableFilters
label="Sort by created date"
value={createdSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("created", direction)}
/>
);
return (
<TableScrollArea
header={
<TableHeaderRow className="pr-8 md:pr-8">
<TableStickyCell header>
{loading ? (
<SkeletonDot />
<SkeletonDot className="mr-4" />
) : (
<input
type="checkbox"
checked={allChatsSelected}
checked={allVisibleChatsSelected}
ref={(el) => {
if (el) el.indeterminate = someChatsSelected;
if (el)
el.indeterminate =
someVisibleChatsSelected;
}}
onChange={() => {
if (allChatsSelected) setSelectedChatIds([]);
if (allVisibleChatsSelected)
setSelectedChatIds([]);
else
setSelectedChatIds(
filteredChats.map((c) => c.id),
visibleChats.map((c) => c.id),
);
}}
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Chats</span>
<span className="mr-1">Chats</span>
{!loading && nameFilterButton}
</TableStickyCell>
<TableHeaderCell className="ml-auto w-32">Creator</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="ml-auto w-32">
<div className="flex items-center gap-1">
<span>Creator</span>
{!loading && creatorFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">
<div className="flex items-center gap-1">
<span>Created</span>
{!loading && createdFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
}
@ -102,7 +226,7 @@ export function ProjectAssistantTable({
<ProjectAssistantLoadingRows />
) : chats.length === 0 ? (
<TableEmptyState>
<MessageSquare className="h-8 w-8 text-gray-300 mb-4" />
<ChatSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Assistant
</p>
@ -110,21 +234,25 @@ export function ProjectAssistantTable({
Ask questions and get answers grounded in the documents
in this project.
</p>
<button
<PillButton
tone="black"
size="sm"
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"
className="mt-4 px-3"
>
+ Create New
</button>
<Plus className="h-3.5 w-3.5" />
Create
</PillButton>
</TableEmptyState>
) : (
<TableBody>
{filteredChats.map((chat) => (
{visibleChats.map((chat) => (
<TableRow
key={chat.id}
rightClickDropdown={(close) => (
rightClickDropdown={(close, menuProps) => (
<RowActionMenuItems
onClose={close}
surfaceProps={menuProps}
onRename={() => {
if (
currentUserId &&
@ -217,8 +345,8 @@ function ProjectAssistantLoadingRows() {
className="pr-8 md:pr-8"
>
<TableStickyCell hover={false}>
<div className="flex min-w-0 items-center gap-4">
<SkeletonDot />
<div className="flex min-w-0 items-center">
<SkeletonDot className="mr-4" />
<SkeletonLine
className={`h-3.5 ${titleWidths[i - 1]}`}
/>

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,8 @@
import { useEffect, useRef, useState } from "react";
import {
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
FolderPlus,
Trash2,
} from "lucide-react";
import type {
@ -15,6 +12,10 @@ import type {
} from "@/app/components/shared/types";
import { VersionChip } from "@/app/components/shared/VersionChip";
import { FileTypeIcon } from "@/app/components/shared/FileTypeIcon";
import {
ProjectSvgIcon,
SubfolderSvgIcon,
} from "@/app/components/shared/FolderSvgIcon";
interface Props {
projectName?: string | null;
@ -91,7 +92,11 @@ export function ProjectExplorer({
function toggleFolder(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
@ -162,7 +167,7 @@ export function ProjectExplorer({
}
function renderLevel(parentId: string | null, depth: number): React.ReactNode {
const basePadding = 28 + (depth - 1) * 16; // pl-7 at depth 1, +16px per level
const basePadding = 24 + (depth - 1) * 16;
const childFolders = folders
.filter((f) => f.parent_folder_id === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
@ -177,7 +182,7 @@ export function ProjectExplorer({
style={{ paddingLeft: basePadding }}
>
<ChevronRight className="h-3 w-3 text-gray-300 shrink-0" />
<FolderPlus className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<SubfolderSvgIcon className="h-3.5 w-3.5 shrink-0" />
<input
ref={newFolderInputRef}
autoFocus
@ -242,10 +247,10 @@ export function ProjectExplorer({
? <ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
: <ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
}
{isExpanded
? <FolderOpen className="h-3.5 w-3.5 text-amber-500 shrink-0" />
: <Folder className="h-3.5 w-3.5 text-amber-500 shrink-0" />
}
<SubfolderSvgIcon
open={isExpanded}
className="h-3.5 w-3.5 shrink-0"
/>
{isRenaming ? (
<input
autoFocus
@ -346,7 +351,7 @@ export function ProjectExplorer({
className="flex items-center gap-2 px-2 py-1.5 select-none"
onContextMenu={(e) => { e.stopPropagation(); openContextMenu(e, null); }}
>
<FolderOpen className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<ProjectSvgIcon open className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs text-gray-500 truncate">{projectName}</span>
</li>
)}
@ -382,7 +387,7 @@ export function ProjectExplorer({
setNewFolderName("");
}}
>
<FolderPlus className="h-3.5 w-3.5 text-gray-400" />
<SubfolderSvgIcon className="h-3.5 w-3.5 shrink-0" />
New subfolder
</button>
)}

View file

@ -4,13 +4,16 @@ import { type CSSProperties, useRef, useState } from "react";
import {
CornerDownRight,
Loader2,
MessageSquare,
Pencil,
Table2,
Plus,
Trash2,
Upload,
Users,
} from "lucide-react";
import { PageHeader } from "@/app/components/shared/PageHeader";
import {
PageHeader,
type PageHeaderAction,
} from "@/app/components/shared/PageHeader";
import { FileTypeIcon } from "@/app/components/shared/FileTypeIcon";
import type { Project } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
@ -32,7 +35,7 @@ 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";
const TREE_CONTROL_WIDTH_PX = 32;
const TREE_CONTROL_WIDTH_PX = 29;
const TREE_NAME_PADDING_PX = 16;
export function treeNameCellStyle(depth: number): CSSProperties | undefined {
@ -349,6 +352,7 @@ export function DocVersionHistory({
export function ProjectPageHeader({
project,
search,
activeSection,
creatingChat,
creatingReview,
docsCount,
@ -360,9 +364,11 @@ export function ProjectPageHeader({
onOpenPeople,
onNewChat,
onNewReview,
onAddDocuments,
}: {
project: Project | null;
search: string;
activeSection: ProjectWorkspaceSection;
creatingChat: boolean;
creatingReview: boolean;
docsCount: number;
@ -374,7 +380,43 @@ export function ProjectPageHeader({
onOpenPeople: () => void;
onNewChat: () => void;
onNewReview: () => void;
onAddDocuments?: (() => void) | null;
}) {
const sectionAction: PageHeaderAction =
activeSection === "documents"
? {
onClick: onAddDocuments ?? undefined,
disabled: !onAddDocuments,
icon: <Upload className="h-4 w-4" />,
label: <span className="hidden sm:inline">Documents</span>,
title: "Add documents",
}
: activeSection === "assistant"
? {
onClick: onNewChat,
disabled: creatingChat,
icon: creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
),
label: <span className="hidden sm:inline">Chat</span>,
title: "Create chat",
}
: {
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
icon: creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
),
label: <span className="hidden sm:inline">Review</span>,
title: "Create review",
tooltip:
docsCount === 0 ? "Upload a document first" : null,
};
return (
<PageHeader
breadcrumbs={[
@ -431,42 +473,7 @@ export function ProjectPageHeader({
),
},
],
{
actions: [
{
onClick: onNewChat,
disabled: creatingChat,
icon: creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
New Chat
</span>
),
},
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
icon: creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Table2 className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
New Review
</span>
),
tooltip:
docsCount === 0
? "Upload a document first"
: null,
},
],
},
[sectionAction],
]}
/>
);

View file

@ -1,7 +1,7 @@
"use client";
import { type Dispatch, type SetStateAction } from "react";
import { Table2 } from "lucide-react";
import { useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { Plus } from "lucide-react";
import {
RowActionMenuItems,
RowActions,
@ -14,23 +14,33 @@ import {
TableBody,
TableCell,
TableEmptyState,
TableFilters,
type TableFilterOption,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
type TableSortDirection,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import type { Document, TabularReview } from "@/app/components/shared/types";
import { formatDate } from "./ProjectPageParts";
type ProjectReviewSortKey = "name" | "columns" | "documents" | "created";
const SORT_OPTIONS: TableFilterOption<TableSortDirection>[] = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
export function ProjectReviewsTable({
docs,
reviews,
filteredReviews,
selectedReviewIds,
allReviewsSelected,
someReviewsSelected,
creatingReview,
currentUserId,
onCreateReview,
@ -57,35 +67,163 @@ export function ProjectReviewsTable({
setSelectedReviewIds: Dispatch<SetStateAction<string[]>>;
loading?: boolean;
}) {
const [sort, setSort] = useState<{
key: ProjectReviewSortKey;
direction: TableSortDirection;
} | null>(null);
function clearSelection() {
setSelectedReviewIds([]);
}
function handleSortChange(
key: ProjectReviewSortKey,
direction: TableSortDirection | null,
) {
setSort(direction ? { key, direction } : null);
clearSelection();
}
const visibleReviews = useMemo(() => {
if (!sort) return filteredReviews;
return [...filteredReviews].sort((a, b) => {
const multiplier = sort.direction === "asc" ? 1 : -1;
if (sort.key === "columns") {
return (
((a.columns_config?.length ?? 0) -
(b.columns_config?.length ?? 0)) *
multiplier
);
}
if (sort.key === "documents") {
return (
((a.document_count ?? 0) - (b.document_count ?? 0)) *
multiplier
);
}
if (sort.key === "created") {
return (
(new Date(a.created_at).getTime() -
new Date(b.created_at).getTime()) *
multiplier
);
}
return (
(a.title ?? "Untitled Review").localeCompare(
b.title ?? "Untitled Review",
) * multiplier
);
});
}, [filteredReviews, sort]);
const allVisibleReviewsSelected =
visibleReviews.length > 0 &&
visibleReviews.every((review) => selectedReviewIds.includes(review.id));
const someVisibleReviewsSelected =
!allVisibleReviewsSelected &&
visibleReviews.some((review) => selectedReviewIds.includes(review.id));
const nameSortDirection = sort?.key === "name" ? sort.direction : null;
const columnsSortDirection =
sort?.key === "columns" ? sort.direction : null;
const documentsSortDirection =
sort?.key === "documents" ? sort.direction : null;
const createdSortDirection =
sort?.key === "created" ? sort.direction : null;
const nameFilterButton = (
<TableFilters
label="Sort by review name"
value={nameSortDirection}
allLabel="Default Order"
widthClassName="w-40"
align="right"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("name", direction)}
/>
);
const columnsFilterButton = (
<TableFilters
label="Sort by columns"
value={columnsSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("columns", direction)}
/>
);
const documentsFilterButton = (
<TableFilters
label="Sort by documents"
value={documentsSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("documents", direction)}
/>
);
const createdFilterButton = (
<TableFilters
label="Sort by created date"
value={createdSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("created", direction)}
/>
);
return (
<TableScrollArea
header={
<TableHeaderRow className="pr-8 md:pr-8">
<TableStickyCell header>
{loading ? (
<SkeletonDot />
<SkeletonDot className="mr-4" />
) : (
<input
type="checkbox"
checked={allReviewsSelected}
checked={allVisibleReviewsSelected}
ref={(el) => {
if (el) el.indeterminate = someReviewsSelected;
if (el)
el.indeterminate =
someVisibleReviewsSelected;
}}
onChange={() => {
if (allReviewsSelected) setSelectedReviewIds([]);
if (allVisibleReviewsSelected)
setSelectedReviewIds([]);
else
setSelectedReviewIds(
filteredReviews.map((r) => r.id),
visibleReviews.map((r) => r.id),
);
}}
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
<span className="mr-1">Name</span>
{!loading && nameFilterButton}
</TableStickyCell>
<TableHeaderCell className="ml-auto w-24">Columns</TableHeaderCell>
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="ml-auto w-24">
<div className="flex items-center gap-1">
<span>Columns</span>
{!loading && columnsFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-24">
<div className="flex items-center gap-1">
<span>Documents</span>
{!loading && documentsFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">
<div className="flex items-center gap-1">
<span>Created</span>
{!loading && createdFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
}
@ -94,29 +232,33 @@ export function ProjectReviewsTable({
<ProjectReviewsLoadingRows />
) : reviews.length === 0 ? (
<TableEmptyState>
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
<TabularReviewSkeuoIcon className="mb-4 h-8 w-8" />
<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
<PillButton
tone="black"
size="sm"
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"
className="mt-4 px-3"
>
+ Create New
</button>
<Plus className="h-3.5 w-3.5" />
Create
</PillButton>
</TableEmptyState>
) : (
<TableBody>
{filteredReviews.map((review) => (
{visibleReviews.map((review) => (
<TableRow
key={review.id}
rightClickDropdown={(close) => (
rightClickDropdown={(close, menuProps) => (
<RowActionMenuItems
onClose={close}
surfaceProps={menuProps}
onEditDetails={() => {
if (
currentUserId &&
@ -206,8 +348,8 @@ function ProjectReviewsLoadingRows() {
className="pr-8 md:pr-8"
>
<TableStickyCell hover={false}>
<div className="flex min-w-0 items-center gap-4">
<SkeletonDot />
<div className="flex min-w-0 items-center">
<SkeletonDot className="mr-4" />
<SkeletonLine
className={`h-3.5 ${titleWidths[i - 1]}`}
/>

View file

@ -67,6 +67,7 @@ type ProjectWorkspaceValue = {
creatingReview: boolean;
createChat: () => Promise<void>;
openNewReview: () => void;
setAddDocumentsHeaderAction: (action: (() => void) | null) => void;
setOwnerOnlyAction: React.Dispatch<React.SetStateAction<string | null>>;
};
@ -131,6 +132,8 @@ export function ProjectWorkspaceProvider({
const [newTRModalOpen, setNewTRModalOpen] = useState(false);
const [creatingChat, setCreatingChat] = useState(false);
const [creatingReview, setCreatingReview] = useState(false);
const [addDocumentsHeaderAction, setAddDocumentsHeaderActionState] =
useState<{ action: (() => void) | null }>({ action: null });
const segments = useSelectedLayoutSegments();
const activeSection = activeSectionFromSegments(segments);
@ -153,6 +156,13 @@ export function ProjectWorkspaceProvider({
projectReviewsPromiseRef.current = null;
}, [projectId]);
const setAddDocumentsHeaderAction = useCallback(
(action: (() => void) | null) => {
setAddDocumentsHeaderActionState({ action });
},
[],
);
useEffect(() => {
if (!showShell) {
setProjectLoading(false);
@ -378,6 +388,7 @@ export function ProjectWorkspaceProvider({
creatingReview,
createChat,
openNewReview,
setAddDocumentsHeaderAction,
setOwnerOnlyAction,
}),
[
@ -399,6 +410,7 @@ export function ProjectWorkspaceProvider({
creatingReview,
createChat,
openNewReview,
setAddDocumentsHeaderAction,
],
);
@ -416,6 +428,7 @@ export function ProjectWorkspaceProvider({
<ProjectPageHeader
project={project}
search={search}
activeSection={activeSection}
creatingChat={creatingChat}
creatingReview={creatingReview}
docsCount={project?.documents?.length ?? 0}
@ -427,6 +440,7 @@ export function ProjectWorkspaceProvider({
onOpenPeople={() => setPeopleModalOpen(true)}
onNewChat={() => void createChat()}
onNewReview={openNewReview}
onAddDocuments={addDocumentsHeaderAction.action}
/>
{children}
@ -534,7 +548,7 @@ export function ProjectSectionToolbar({
<TableToolbar
items={[
{ id: "documents", label: "Documents" },
{ id: "assistant", label: "Assistant Chats" },
{ id: "assistant", label: "Chats" },
{ id: "reviews", label: "Tabular Reviews" },
]}
active={activeSection}

View file

@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { FolderOpen, ChevronDown } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ChevronDown, Plus } from "lucide-react";
import {
listProjects,
updateProject,
@ -19,6 +19,10 @@ import {
RowActions,
} from "@/app/components/shared/RowActions";
import { PageHeader } from "@/app/components/shared/PageHeader";
import {
ClosedProjectSvgIcon,
OpenProjectSvgIcon,
} from "@/app/components/shared/FolderSvgIcon";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
@ -27,13 +31,18 @@ import {
TableBody,
TableCell,
TableEmptyState,
TableFilters,
type TableFilterOption,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
type TableSortDirection,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
@ -53,6 +62,18 @@ function getProjectOwnerLabel(project: Project, currentUserId?: string | null) {
}
type ProjectFilter = "all" | "mine" | "shared-with-me";
type ProjectSortKey =
| "name"
| "cm"
| "files"
| "chats"
| "reviews"
| "created";
const SORT_OPTIONS: TableFilterOption<TableSortDirection>[] = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
export function ProjectsOverview() {
const [projects, setProjects] = useState<Project[]>([]);
@ -61,13 +82,26 @@ export function ProjectsOverview() {
const [modalOpen, setModalOpen] = useState(false);
const [detailsProject, setDetailsProject] = useState<Project | null>(null);
const [activeFilter, setActiveFilter] = useState<ProjectFilter>("all");
const [practiceFilter, setPracticeFilter] = useState<string | null>(null);
const [ownerFilter, setOwnerFilter] = useState<string | null>(null);
const [sort, setSort] = useState<{
key: ProjectSortKey;
direction: TableSortDirection;
} | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [search, setSearch] = useState("");
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const searchParams = useSearchParams();
const { user, isAuthenticated, authLoading } = useAuth();
const previewEmptyStates = searchParams.get("emptyStates") === "1";
const effectiveLoading = loading && !previewEmptyStates;
const visibleProjects = useMemo(
() => (previewEmptyStates ? [] : projects),
[previewEmptyStates, projects],
);
useEffect(() => {
let cancelled = false;
@ -122,19 +156,111 @@ export function ProjectsOverview() {
}, [actionsOpen]);
const q = search.toLowerCase();
const filtered = (
activeFilter === "all"
? projects
: 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(
(p) =>
!q ||
p.name.toLowerCase().includes(q) ||
(p.cm_number ?? "").toLowerCase().includes(q) ||
(p.practice ?? "").toLowerCase().includes(q),
const practices = useMemo(
() =>
Array.from(
new Set(
visibleProjects
.map((project) => project.practice?.trim())
.filter((practice): practice is string => !!practice),
),
).sort((a, b) => a.localeCompare(b)),
[visibleProjects],
);
const ownerOptions = useMemo(
() =>
Array.from(
new Set(
visibleProjects.map((project) =>
getProjectOwnerLabel(project, user?.id),
),
),
)
.sort((a, b) => a.localeCompare(b))
.map((owner) => ({ value: owner, label: owner })),
[visibleProjects, user?.id],
);
const filtered = useMemo(() => {
const rows = (
activeFilter === "all"
? visibleProjects
: activeFilter === "mine"
? visibleProjects.filter(
(p) => p.is_owner ?? p.user_id === user?.id,
)
: visibleProjects.filter(
(p) => !(p.is_owner ?? p.user_id === user?.id),
)
)
.filter(
(p) =>
!q ||
p.name.toLowerCase().includes(q) ||
(p.cm_number ?? "").toLowerCase().includes(q) ||
(p.practice ?? "").toLowerCase().includes(q),
)
.filter(
(p) =>
!practiceFilter ||
(p.practice?.trim() ?? "") === practiceFilter,
)
.filter(
(p) =>
!ownerFilter ||
getProjectOwnerLabel(p, user?.id) === ownerFilter,
);
if (!sort) return rows;
return [...rows].sort((a, b) => {
const multiplier = sort.direction === "asc" ? 1 : -1;
if (sort.key === "cm") {
return (
(a.cm_number ?? "").localeCompare(b.cm_number ?? "") *
multiplier
);
}
if (sort.key === "files") {
return (
((a.document_count ?? 0) - (b.document_count ?? 0)) *
multiplier
);
}
if (sort.key === "chats") {
return (
((a.chat_count ?? 0) - (b.chat_count ?? 0)) * multiplier
);
}
if (sort.key === "reviews") {
return (
((a.review_count ?? 0) - (b.review_count ?? 0)) *
multiplier
);
}
if (sort.key === "created") {
return (
(new Date(a.created_at).getTime() -
new Date(b.created_at).getTime()) *
multiplier
);
}
return a.name.localeCompare(b.name) * multiplier;
});
}, [
activeFilter,
ownerFilter,
practiceFilter,
q,
sort,
user?.id,
visibleProjects,
]);
const allSelected =
filtered.length > 0 &&
@ -156,11 +282,125 @@ export function ProjectsOverview() {
);
}
function clearSelection() {
setSelectedIds([]);
setActionsOpen(false);
}
function handlePracticeFilterChange(value: string | null) {
setPracticeFilter(value);
clearSelection();
}
function handleOwnerFilterChange(value: string | null) {
setOwnerFilter(value);
clearSelection();
}
function handleSortChange(
key: ProjectSortKey,
direction: TableSortDirection | null,
) {
setSort(direction ? { key, direction } : null);
clearSelection();
}
const filters: { id: ProjectFilter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "mine", label: "Mine" },
{ id: "shared-with-me", label: "Shared with me" },
];
const nameSortDirection = sort?.key === "name" ? sort.direction : null;
const cmSortDirection = sort?.key === "cm" ? sort.direction : null;
const filesSortDirection = sort?.key === "files" ? sort.direction : null;
const chatsSortDirection = sort?.key === "chats" ? sort.direction : null;
const reviewsSortDirection =
sort?.key === "reviews" ? sort.direction : null;
const createdSortDirection =
sort?.key === "created" ? sort.direction : null;
const nameFilterButton = (
<TableFilters
label="Sort by project name"
value={nameSortDirection}
allLabel="Default Order"
widthClassName="w-40"
align="right"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("name", direction)}
/>
);
const cmFilterButton = (
<TableFilters
label="Sort by CM"
value={cmSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("cm", direction)}
/>
);
const practiceFilterButton = (
<TableFilters
label="Filter by practice"
value={practiceFilter}
allLabel="All Practices"
options={practices.map((practice) => ({
value: practice,
label: practice,
}))}
onChange={handlePracticeFilterChange}
/>
);
const ownerFilterButton = (
<TableFilters
label="Filter by owner"
value={ownerFilter}
allLabel="All Owners"
widthClassName="w-44"
options={ownerOptions}
onChange={handleOwnerFilterChange}
/>
);
const filesFilterButton = (
<TableFilters
label="Sort by files"
value={filesSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("files", direction)}
/>
);
const chatsFilterButton = (
<TableFilters
label="Sort by chats"
value={chatsSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("chats", direction)}
/>
);
const reviewsFilterButton = (
<TableFilters
label="Sort by tabular reviews"
value={reviewsSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("reviews", direction)}
/>
);
const createdFilterButton = (
<TableFilters
label="Sort by created date"
value={createdSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("created", direction)}
/>
);
async function handleProjectDetailsSave(values: {
name: string;
@ -215,31 +455,27 @@ export function ProjectsOverview() {
}
}
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>
)}
</>
);
const toolbarActions =
selectedIds.length > 0 ? (
<div ref={actionsRef} className="relative">
<TabPillButton
onClick={() => setActionsOpen((v) => !v)}
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</TabPillButton>
{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>
) : undefined;
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
@ -270,7 +506,7 @@ export function ProjectsOverview() {
active={activeFilter}
onChange={(nextFilter) => {
setActiveFilter(nextFilter);
setSelectedIds([]);
clearSelection();
}}
actions={toolbarActions}
/>
@ -280,8 +516,8 @@ export function ProjectsOverview() {
header={
<TableHeaderRow>
<TableStickyCell header>
{loading ? (
<SkeletonDot />
{effectiveLoading ? (
<SkeletonDot className="mr-4" />
) : (
<input
type="checkbox"
@ -293,23 +529,56 @@ export function ProjectsOverview() {
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
<span className="mr-1">Name</span>
{!loading && nameFilterButton}
</TableStickyCell>
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
<TableHeaderCell className="w-36">Practice</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
<TableHeaderCell className="ml-auto w-32">
<div className="flex items-center gap-1">
<span>CM</span>
{!loading && cmFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-36">
<div className="flex items-center gap-1">
<span>Practice</span>
{!loading && practiceFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">
<div className="flex items-center gap-1">
<span>Owner</span>
{!loading && ownerFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-24">
<div className="flex items-center gap-1">
<span>Files</span>
{!loading && filesFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-24">
<div className="flex items-center gap-1">
<span>Chats</span>
{!loading && chatsFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-36">
<div className="flex items-center gap-1">
<span>Tabular Reviews</span>
{!loading && reviewsFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">
<div className="flex items-center gap-1">
<span>Created</span>
{!loading && createdFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
}
>
{loading ? (
{effectiveLoading ? (
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow
@ -320,7 +589,8 @@ export function ProjectsOverview() {
hover={false}
bgClassName="bg-transparent"
>
<SkeletonDot />
<SkeletonDot className="mr-4" />
<div className="mr-2 h-4 w-4 shrink-0 rounded bg-gray-100 animate-pulse" />
<SkeletonLine className="h-3.5 w-48" />
</TableStickyCell>
<TableCell className="ml-auto w-32">
@ -350,7 +620,7 @@ export function ProjectsOverview() {
</TableBody>
) : loadError ? (
<TableEmptyState>
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
<OpenProjectSvgIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Projects
</p>
@ -362,7 +632,7 @@ export function ProjectsOverview() {
<TableEmptyState>
{activeFilter === "all" || activeFilter === "mine" ? (
<>
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
<OpenProjectSvgIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Projects
</p>
@ -371,12 +641,15 @@ export function ProjectsOverview() {
commence chats and tabular reviews with
them.
</p>
<button
<PillButton
tone="black"
size="sm"
onClick={() => setModalOpen(true)}
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"
className="mt-4 px-3"
>
+ Create New
</button>
<Plus className="h-3.5 w-3.5" />
Create
</PillButton>
</>
) : (
<p className="text-sm text-gray-400">
@ -396,9 +669,10 @@ export function ProjectsOverview() {
rightClickDropdown={
(project.is_owner ??
project.user_id === user?.id)
? (close) => (
? (close, menuProps) => (
<RowActionMenuItems
onClose={close}
surfaceProps={menuProps}
onEditDetails={() => {
setDetailsProject(project);
}}
@ -429,8 +703,12 @@ export function ProjectsOverview() {
onSelectionChange={() =>
toggleOne(project.id)
}
label={project.name}
/>
>
<ClosedProjectSvgIcon className="mr-2 h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{project.name}
</span>
</TablePrimaryCell>
<TableCell className="ml-auto w-32">
{project.cm_number ?? (

View file

@ -7,6 +7,7 @@ import {
lookupUserByEmail,
type UserLookupResult,
} from "@/app/lib/mikeApi";
import { PillButton } from "@/app/components/ui/pill-button";
import { cn } from "@/app/lib/utils";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -102,19 +103,21 @@ export function AddUserInput({
autoFocus={autoFocus}
/>
{showAddButton && (
<button
<PillButton
tone="blue"
size="sm"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void commitUser()}
disabled={busy || checking}
title={submitLabel}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-blue-500/35 bg-blue-600/90 px-2.5 text-[11px] font-medium leading-none text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
className="h-6 shrink-0 px-2.5 text-[11px] leading-none"
>
{(busy || checking) && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Add
</button>
</PillButton>
)}
</div>
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}

View file

@ -3,11 +3,6 @@
import { useState, useEffect, useMemo } from "react";
import {
PanelLeft,
MessageSquare,
Folder,
FolderOpen,
Table2,
Library,
User,
ChevronsUpDown,
ChevronDown,
@ -19,15 +14,26 @@ import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
import {
ChatSkeuoIcon,
FolderSkeuoIcon,
LibrarySkeuoIcon,
TabularReviewSkeuoIcon,
WorkflowSkeuoIcon,
} from "@/app/components/shared/AppSidebarSkeuoIcons";
import {
ProjectSvgIcon,
} from "@/app/components/shared/FolderSvgIcon";
import { listProjects } from "@/app/lib/mikeApi";
import type { Project } from "@/app/components/shared/types";
import { cn } from "@/app/lib/utils";
const NAV_ITEMS = [
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
{ href: "/projects", label: "Projects", icon: FolderOpen },
{ href: "/tabular-reviews", label: "Tabular Review", icon: Table2 },
{ href: "/workflows", label: "Workflows", icon: Library },
{ href: "/assistant", label: "Assistant", icon: ChatSkeuoIcon },
{ href: "/projects", label: "Projects", icon: FolderSkeuoIcon },
{ href: "/library", label: "Library", icon: LibrarySkeuoIcon },
{ href: "/tabular-reviews", label: "Tabular Review", icon: TabularReviewSkeuoIcon },
{ href: "/workflows", label: "Workflows", icon: WorkflowSkeuoIcon },
];
interface AppSidebarProps {
@ -137,8 +143,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
<div
className={cn(
isOpen
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65"
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto",
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-app-surface"
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-app-surface h-auto bg-transparent pointer-events-none md:pointer-events-auto",
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
)}
@ -195,7 +201,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
className={cn(
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
isActive
? "bg-gray-200/60 text-gray-900"
? "bg-app-surface-active text-gray-900"
: "text-gray-700 hover:bg-gray-100",
!isOpen ? "hidden md:flex" : "flex",
)}
@ -247,7 +253,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
{[50, 65, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
className="flex h-8 items-center rounded-md px-3"
>
<div
className="h-3 bg-gray-200 rounded animate-pulse"
@ -293,17 +299,16 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
}
title={project.name}
className={cn(
"flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors",
"flex h-8 w-full items-center gap-2 rounded-md px-2.5 py-1 text-left text-xs transition-colors",
isActive
? "bg-gray-200/60 text-gray-900"
? "bg-app-surface-active text-gray-900"
: "text-gray-700 hover:bg-gray-100",
)}
>
{isActive ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-600" />
)}
<ProjectSvgIcon
open={isActive}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="min-w-0 flex-1 truncate">
{project.name}
</span>
@ -337,12 +342,13 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
}`}
>
{!chats ? (
<div className="space-y-1 px-2.5">
<div className="space-y-1.5 px-2.5">
{[40, 60, 50, 70, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
className="flex h-8 items-center rounded-md px-2.5"
>
<div className="mr-2 h-3.5 w-3.5 shrink-0 rounded bg-gray-200 animate-pulse" />
<div
className="h-3 bg-gray-200 rounded animate-pulse"
style={{ width: `${w}%` }}
@ -363,7 +369,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
) : (
<>
<div
className={`space-y-1 px-2.5 ${
className={`space-y-1.5 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
@ -430,7 +436,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
"rounded-xl border-white/60",
!isOpen ? "hidden md:flex" : "",
pathname === "/account" || isDropdownOpen
? "bg-gray-200/60"
? "bg-app-surface-active"
: "hover:bg-gray-100",
)}
title={!isOpen ? user.email : undefined}
@ -464,7 +470,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
className={cn(
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
isOpen ? "right-0" : "w-56",
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
"bg-app-floating rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
)}
>
<button
@ -474,7 +480,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
}}
className={cn(
"w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md",
"hover:bg-white/70",
"hover:bg-white",
)}
>
<User className="h-4 w-4" />

View file

@ -0,0 +1,49 @@
import Image, { type ImageProps } from "next/image";
type IconProps = Omit<
ImageProps,
"alt" | "src" | "width" | "height" | "unoptimized"
>;
const ICON_BASE_PATH = "/icons/app-sidebar";
const ICON_VERSION = "27";
function AppSidebarIcon({
name,
className,
...props
}: IconProps & { name: string }) {
return (
<Image
src={`${ICON_BASE_PATH}/${name}.svg?v=${ICON_VERSION}`}
alt=""
width={64}
height={64}
unoptimized
aria-hidden="true"
draggable={false}
className={`${className ?? ""} object-contain`}
{...props}
/>
);
}
export function ChatSkeuoIcon(props: IconProps) {
return <AppSidebarIcon name="chat" {...props} />;
}
export function FolderSkeuoIcon(props: IconProps) {
return <AppSidebarIcon name="project-closed" {...props} />;
}
export function LibrarySkeuoIcon(props: IconProps) {
return <AppSidebarIcon name="library" {...props} />;
}
export function TabularReviewSkeuoIcon(props: IconProps) {
return <AppSidebarIcon name="tabular-review" {...props} />;
}
export function WorkflowSkeuoIcon(props: IconProps) {
return <AppSidebarIcon name="workflow" {...props} />;
}

View file

@ -13,25 +13,28 @@ import {
X,
} from "lucide-react";
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
import { FileTypeIcon } from "@/app/components/shared/FileTypeIcon";
import { PdfView } from "@/app/components/shared/views/PdfView";
import { DocxView } from "@/app/components/shared/views/DocxView";
import { SpreadsheetView } from "@/app/components/shared/views/SpreadsheetView";
import { PillButton } from "@/app/components/ui/pill-button";
import { WarningPopup } from "@/app/components/popups/WarningPopup";
import type { Document } from "@/app/components/shared/types";
import { isSpreadsheetFilename } from "@/app/components/shared/types";
import {
isDocxFilename,
isSpreadsheetFilename,
} from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
import { cn } from "@/app/lib/utils";
import { formatBytes } from "./ProjectPageParts";
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
import { formatBytes } from "@/app/components/projects/ProjectPageParts";
const MIN_DOC_COLUMN_WIDTH = 420;
const DEFAULT_DOC_COLUMN_WIDTH = 620;
const MIN_DATA_COLUMN_WIDTH = 280;
const DEFAULT_DATA_COLUMN_WIDTH = 340;
const RESIZER_WIDTH = 6;
const RESIZER_WIDTH = 0;
const MAX_PANEL_WIDTH = 1180;
const primaryGlassButtonClass =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-blue-800/35 bg-blue-700/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(30,64,175,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(30,64,175,0.18)] backdrop-blur-xl transition-all hover:bg-blue-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
const dangerGlassButtonClass =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-red-700/35 bg-red-600/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl transition-all hover:bg-red-600 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
interface DocumentSidePanelProps {
doc: Document | null;
@ -181,6 +184,11 @@ export function DocumentSidePanel({
selectedVersion != null
? fileTypeForVersion(selectedVersion, doc.file_type)
: doc.file_type;
const selectedFileTypeKey = selectedFileType?.toLowerCase() ?? "";
const selectedIsDocx =
isDocxFilename(selectedFilename) ||
selectedFileTypeKey === "docx" ||
selectedFileTypeKey === "doc";
const selectedSizeBytes =
selectedVersion?.size_bytes === undefined
? doc.size_bytes
@ -191,8 +199,6 @@ export function DocumentSidePanel({
: selectedVersion.page_count;
const selectedVersionNumber =
selectedVersion?.version_number ?? doc.active_version_number ?? null;
const selectedVersionTag =
selectedVersionNumber != null ? `V${selectedVersionNumber}` : null;
const selectedUploadedAt = selectedVersion?.created_at ?? doc.created_at;
const selectedExtension = filenameExtension(selectedFilename);
const replaceFileType = replaceTargetVersion
@ -201,9 +207,7 @@ export function DocumentSidePanel({
const replaceVersionAccept =
replaceFileType === "pdf" ? ".pdf" : ".docx,.doc";
const ownerLabel =
doc.owner_display_name?.trim() ||
doc.owner_email?.trim() ||
"—";
doc.owner_display_name?.trim() || doc.owner_email?.trim() || "—";
async function handleSaveName() {
if (!selectedVersionId) return;
@ -405,7 +409,8 @@ export function DocumentSidePanel({
ref={panelRef}
className={cn(
"fixed z-[190] flex flex-col",
"inset-3 md:left-auto rounded-2xl border border-white/70 bg-gray-50/80 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
LIQUID_PANEL_SURFACE_CLASS,
"inset-3 md:left-auto overflow-hidden",
)}
style={isMobile ? undefined : { width: panelWidth }}
>
@ -414,13 +419,12 @@ export function DocumentSidePanel({
className="absolute inset-y-0 left-0 z-20 hidden w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60 md:block"
title="Resize document view"
/>
<div className="flex min-h-11 shrink-0 items-center justify-between gap-3 px-4 py-2 md:h-11 md:py-0">
<div className="mx-3 flex min-h-11 shrink-0 items-center justify-between gap-3 py-2 md:h-11 md:py-0">
<div className="flex min-w-0 items-center gap-2">
{selectedVersionTag && (
<span className="inline-flex h-5 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white/75 px-2 text-[10px] font-semibold text-gray-600">
{selectedVersionTag}
</span>
)}
<FileTypeIcon
fileType={selectedFileType ?? selectedFilename}
className="h-4 w-4"
/>
<div className="min-w-0 truncate text-sm font-medium text-gray-700">
{selectedFilename}
</div>
@ -455,10 +459,10 @@ export function DocumentSidePanel({
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-gray-500 transition-colors hover:text-gray-900"
title="Close"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
aria-label="Close"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
@ -477,18 +481,18 @@ export function DocumentSidePanel({
mobilePane === "document" ? "flex" : "hidden",
)}
>
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
"rounded-xl border border-gray-200 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
)}
>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{isSpreadsheetFilename(selectedFilename) ? (
<SpreadsheetView
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
documentId={doc.id}
versionId={selectedVersionId}
rounded={false}
/>
) : selectedIsDocx ? (
<DocxView
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
documentId={doc.id}
versionId={selectedVersionId}
/>
) : (
<PdfView
@ -497,7 +501,6 @@ export function DocumentSidePanel({
document_id: doc.id,
version_id: selectedVersionId,
}}
rounded={false}
/>
)}
</div>
@ -506,15 +509,15 @@ export function DocumentSidePanel({
<div
onMouseDown={handleResizeMouseDown}
className={cn(
"relative hidden cursor-col-resize transition-colors md:block",
"bg-white/25 hover:bg-blue-400/60",
"relative z-10 hidden w-1.5 -translate-x-1/2 cursor-col-resize transition-colors md:block",
"bg-transparent hover:bg-blue-400/60",
)}
title="Resize document panel"
/>
<aside
className={cn(
"mx-3 mb-3 min-h-0 flex-col overflow-hidden rounded-xl md:ml-2 md:mr-3",
"mx-3 mb-3 min-h-0 flex-col overflow-hidden rounded-xl",
mobilePane === "details" ? "flex" : "hidden md:flex",
"border border-white/70 bg-white shadow-[0_3px_9px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
)}
@ -621,9 +624,7 @@ export function DocumentSidePanel({
label="Uploaded"
value={
selectedUploadedAt
? formatDateTime(
selectedUploadedAt,
)
? formatDateTime(selectedUploadedAt)
: "—"
}
/>
@ -668,9 +669,7 @@ export function DocumentSidePanel({
</div>
) : (
<div className="space-y-1.5">
{uploading && (
<VersionUploadSkeleton />
)}
{uploading && <VersionUploadSkeleton />}
{orderedVersions.map((version) => {
const title =
versionTitleFor(version);
@ -747,8 +746,8 @@ export function DocumentSidePanel({
? "text-gray-300"
: typeLabel ===
"PDF"
? "text-red-600"
: "text-blue-600",
? "text-red-600"
: "text-blue-600",
)}
>
{typeLabel}
@ -899,14 +898,15 @@ export function DocumentSidePanel({
className="hidden"
onChange={handleReplaceFileInputChange}
/>
<button
type="button"
<PillButton
tone="danger"
size="sm"
onClick={requestDeleteDocument}
disabled={deletingDocument}
className={cn(
dangerGlassButtonClass,
"h-8 px-3",
!canDelete &&
"cursor-not-allowed opacity-45 hover:bg-red-600/90 active:scale-100",
"cursor-not-allowed opacity-45 active:scale-100",
)}
title={
canDelete
@ -920,12 +920,13 @@ export function DocumentSidePanel({
<Trash2 className="h-3.5 w-3.5 shrink-0" />
)}
Delete
</button>
<button
type="button"
</PillButton>
<PillButton
tone="blue"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className={primaryGlassButtonClass}
className="h-8 px-3"
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
@ -933,7 +934,7 @@ export function DocumentSidePanel({
<Upload className="h-3.5 w-3.5 shrink-0" />
)}
Upload new version
</button>
</PillButton>
</div>
</aside>
</div>
@ -951,9 +952,7 @@ export function DocumentSidePanel({
title="Replace version?"
message={`This will wipe ${versionTitleFor(replaceTargetVersion)} and replace it with ${replaceFile?.name ?? "the selected file"}. Save as a new version instead if you want to keep both copies.`}
confirmLabel="Replace"
confirmStatus={
replacingVersionId != null ? "loading" : "idle"
}
confirmStatus={replacingVersionId != null ? "loading" : "idle"}
cancelLabel="Cancel"
onCancel={() => {
if (replacingVersionId != null) return;

View file

@ -1,19 +1,29 @@
"use client";
import { useState } from "react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import {
Check,
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
Loader2,
} from "lucide-react";
import type { Document, Project } from "./types";
import { VersionChip } from "./VersionChip";
import type { Document, LibraryFolder } from "./types";
import { FileTypeIcon } from "./FileTypeIcon";
import { ProjectSvgIcon, SubfolderSvgIcon } from "./FolderSvgIcon";
import { SearchBar } from "@/app/components/ui/search-bar";
import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import { SkeletonLine } from "./TablePrimitive";
import { useDirectoryData, type DirectoryTab } from "./useDirectoryData";
const DIRECTORY_GRID_CLASS =
"grid grid-cols-[14px_14px_minmax(0,1fr)_48px_84px_64px] items-center gap-2";
const DIRECTORY_TABS: { value: DirectoryTab; label: string }[] = [
{ value: "files", label: "Files" },
{ value: "templates", label: "Templates" },
{ value: "projects", label: "Projects" },
];
const EMPTY_DOCUMENTS: Document[] = [];
const EMPTY_FOLDERS: LibraryFolder[] = [];
function formatDate(iso: string | null) {
if (!iso) return null;
@ -24,62 +34,129 @@ function formatDate(iso: string | null) {
});
}
function formatBytes(bytes: number | null | undefined) {
if (bytes == null) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function versionLabel(doc: Document) {
const n = doc.active_version_number ?? doc.latest_version_number;
return typeof n === "number" && Number.isFinite(n) && n >= 1
? `${n}`
: null;
}
export function DocFileIcon({ fileType }: { fileType: string | null }) {
return <FileTypeIcon fileType={fileType} className="h-3.5 w-3.5" />;
}
interface FileDirectoryProps {
standaloneDocs: Document[];
directoryProjects: Project[];
loading: boolean;
selectedIds: Set<string>;
onChange: (ids: Set<string>) => void;
allowMultiple?: boolean;
forceExpanded?: boolean;
emptyMessage?: string;
documents?: Document[];
loading?: boolean;
selectedDocuments: Document[];
onChange: (documents: Document[]) => void;
uploadingFilenames?: string[];
searchable?: boolean;
searchPlaceholder?: string;
searchAutoFocus?: boolean;
searchNoResultsMessage?: string;
showProjectTabs?: boolean;
showTabs: boolean;
initialTab?: DirectoryTab;
excludeProjectId?: string;
}
export function FileDirectory({
standaloneDocs,
directoryProjects,
loading,
selectedIds,
documents = EMPTY_DOCUMENTS,
loading: externalLoading = false,
selectedDocuments,
onChange,
allowMultiple = true,
forceExpanded = false,
emptyMessage = "No documents yet",
uploadingFilenames = [],
searchable = false,
searchPlaceholder = "Search...",
searchAutoFocus = false,
searchNoResultsMessage = "No matches found",
showProjectTabs,
showTabs,
initialTab = "files",
excludeProjectId,
}: FileDirectoryProps) {
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
new Set(),
);
const [selectedTab, setSelectedTab] = useState<"files" | "projects">(
"files",
);
const [expandedLibraryFolders, setExpandedLibraryFolders] = useState<
Set<string>
>(new Set());
const [selectedTab, setSelectedTab] = useState<DirectoryTab>(initialTab);
// Follow initialTab changes so keep-mounted parents (which never remount
// this component) can still steer the starting tab per open.
useEffect(() => {
setSelectedTab(initialTab);
}, [initialTab]);
const [search, setSearch] = useState("");
const {
loadingTabs,
standaloneDocuments,
templateDocuments,
fileFolders: loadedFileFolders,
templateFolders: loadedTemplateFolders,
projects,
loadTab,
} = useDirectoryData(showTabs, initialTab);
useEffect(() => {
if (!showTabs || initialTab === "templates") return;
void loadTab("templates");
}, [initialTab, showTabs, loadTab]);
const directoryStandaloneDocs = useMemo(
() =>
showTabs
? [
...documents.filter(
(doc) =>
!standaloneDocuments.some(
(loadedDoc) => loadedDoc.id === doc.id,
),
),
...standaloneDocuments,
]
: documents,
[documents, showTabs, standaloneDocuments],
);
const directoryTemplateDocs = showTabs
? templateDocuments
: EMPTY_DOCUMENTS;
const directoryFileFolders = showTabs
? loadedFileFolders
: EMPTY_FOLDERS;
const directoryTemplateFolders = showTabs
? loadedTemplateFolders
: EMPTY_FOLDERS;
const localDirectoryProjects = useMemo(
() =>
showTabs
? projects.filter(
(project) => project.id !== excludeProjectId,
)
: [],
[excludeProjectId, projects, showTabs],
);
const selectedIds = useMemo(
() => new Set(selectedDocuments.map((document) => document.id)),
[selectedDocuments],
);
const q = search.trim().toLowerCase();
const visibleStandaloneDocs = q
? standaloneDocs.filter((doc) => doc.filename.toLowerCase().includes(q))
: standaloneDocs;
? directoryStandaloneDocs.filter((doc) =>
doc.filename.toLowerCase().includes(q),
)
: directoryStandaloneDocs;
const visibleUploadingFilenames = q
? uploadingFilenames.filter((filename) =>
filename.toLowerCase().includes(q),
)
: uploadingFilenames;
const visibleTemplateDocs = q
? directoryTemplateDocs.filter((doc) =>
doc.filename.toLowerCase().includes(q),
)
: directoryTemplateDocs;
const visibleDirectoryProjects = q
? directoryProjects
? localDirectoryProjects
.map((project) => {
const docs = project.documents ?? [];
const projectMatches =
@ -102,39 +179,35 @@ export function FileDirectory({
(project.cm_number ?? "").toLowerCase().includes(q)
);
})
: directoryProjects;
const showTabs = showProjectTabs ?? directoryProjects.length > 0;
: localDirectoryProjects;
const activeTab = showTabs ? selectedTab : "files";
const activeLoading = showTabs
? !!loadingTabs[activeTab]
: externalLoading;
const hasVisibleFiles =
visibleStandaloneDocs.length > 0 ||
visibleUploadingFilenames.length > 0;
const hasVisibleProjects = visibleDirectoryProjects.length > 0;
const hasVisibleTemplates = visibleTemplateDocs.length > 0;
const activeTabHasNoResults =
q &&
((activeTab === "files" && !hasVisibleFiles) ||
(activeTab === "projects" && !hasVisibleProjects));
(activeTab === "projects" && !hasVisibleProjects) ||
(activeTab === "templates" && !hasVisibleTemplates));
const allDocs = [
...standaloneDocs,
...directoryProjects.flatMap((p) => p.documents ?? []),
];
function toggle(docId: string) {
if (!allowMultiple) {
onChange(new Set([docId]));
return;
}
const next = new Set(selectedIds);
if (next.has(docId)) {
next.delete(docId);
function toggle(doc: Document) {
const next = new Map(
selectedDocuments.map((document) => [document.id, document]),
);
if (next.has(doc.id)) {
next.delete(doc.id);
} else {
next.add(docId);
next.set(doc.id, doc);
}
onChange(next);
onChange([...next.values()]);
}
function toggleFolder(projectId: string) {
if (forceExpanded) return;
setExpandedProjects((prev) => {
const next = new Set(prev);
if (next.has(projectId)) {
@ -146,68 +219,267 @@ export function FileDirectory({
});
}
if (loading) {
function toggleDocuments(docs: Document[]) {
if (docs.length === 0) return;
const allSelected = docs.every((doc) => selectedIds.has(doc.id));
const next = new Map(
selectedDocuments.map((document) => [document.id, document]),
);
if (allSelected) {
docs.forEach((doc) => next.delete(doc.id));
} else {
docs.forEach((doc) => next.set(doc.id, doc));
}
onChange([...next.values()]);
}
function documentFolderId(doc: Document) {
return doc.folder_id ?? doc.library_folder_id ?? null;
}
function childFolders(
folders: LibraryFolder[],
parentFolderId: string | null,
) {
return folders.filter(
(folder) => (folder.parent_folder_id ?? null) === parentFolderId,
);
}
function folderDocuments(docs: Document[], folderId: string | null) {
return docs.filter((doc) => documentFolderId(doc) === folderId);
}
function collectFolderDocuments(
folders: LibraryFolder[],
docs: Document[],
folderId: string,
): Document[] {
const directDocs = folderDocuments(docs, folderId);
const nestedDocs = childFolders(folders, folderId).flatMap((folder) =>
collectFolderDocuments(folders, docs, folder.id),
);
return [...directDocs, ...nestedDocs];
}
function toggleLibraryFolder(folderId: string) {
setExpandedLibraryFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}
function handleTabChange(tab: DirectoryTab) {
setSelectedTab(tab);
void loadTab(tab);
}
function indentedRowPadding(depth: number) {
if (depth <= 0) return 8;
return 4 + depth * 20;
}
function renderDocumentRow(doc: Document, depth = 0) {
const selected = selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() => toggle(doc)}
style={{ paddingLeft: indentedRowPadding(depth) }}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left ${
selected ? "bg-gray-100" : "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{selected && <Check className="h-2.5 w-2.5 text-white" />}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`min-w-0 truncate ${
selected ? "text-gray-900" : "text-gray-700"
}`}
>
{doc.filename}
</span>
<FileDirectoryMetaCells
version={versionLabel(doc)}
created={formatDate(doc.created_at)}
size={formatBytes(doc.size_bytes)}
/>
</button>
);
}
function renderLibraryFolderRows(
folders: LibraryFolder[],
docs: Document[],
parentFolderId: string | null,
depth = 0,
): ReactNode {
return childFolders(folders, parentFolderId).map((folder) => {
const docsInFolder = collectFolderDocuments(folders, docs, folder.id);
const allSelected =
docsInFolder.length > 0 &&
docsInFolder.every((doc) => selectedIds.has(doc.id));
const someSelected =
docsInFolder.some((doc) => selectedIds.has(doc.id)) &&
!allSelected;
const isExpanded = !!q || expandedLibraryFolders.has(folder.id);
return (
<div key={folder.id}>
<button
type="button"
onClick={() => toggleLibraryFolder(folder.id)}
style={{ paddingLeft: indentedRowPadding(depth) }}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left hover:bg-gray-100/70`}
>
<span
role="checkbox"
aria-checked={someSelected ? "mixed" : allSelected}
aria-label={`Select all files in ${folder.name}`}
onClick={(e) => {
e.stopPropagation();
toggleDocuments(docsInFolder);
}}
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
allSelected || someSelected
? "bg-gray-900 border-gray-900"
: docsInFolder.length === 0
? "border-gray-200 bg-gray-50"
: "border-gray-300"
}`}
>
{allSelected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
{someSelected && <span className="h-px w-2 bg-white" />}
</span>
<SubfolderSvgIcon
open={isExpanded}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="min-w-0 truncate font-medium text-gray-700">
{folder.name}
</span>
<span className="truncate text-gray-400">-</span>
<span className="truncate text-gray-400">
{formatDate(folder.created_at) ?? "--"}
</span>
<span className="truncate text-right text-gray-400">
{docsInFolder.length}{" "}
{docsInFolder.length === 1 ? "file" : "files"}
</span>
</button>
{isExpanded && (
<div>
{renderLibraryFolderRows(
folders,
docs,
folder.id,
depth + 1,
)}
{folderDocuments(docs, folder.id).map((doc) =>
renderDocumentRow(doc, depth + 1),
)}
{docsInFolder.length === 0 && (
<p
className="py-1 text-xs text-gray-400"
style={{
paddingLeft:
indentedRowPadding(depth + 1),
}}
>
Empty
</p>
)}
</div>
)}
</div>
);
});
}
if (activeLoading) {
return (
<div className="flex min-h-0 flex-1 flex-col space-y-2">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
{showTabs && (
<FileDirectoryTabs
<SearchBar
value={search}
onValueChange={setSearch}
placeholder="Search..."
autoFocus
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
{(showTabs || selectedIds.size > 0) && (
<FileDirectoryControls
activeTab={activeTab}
onChange={setSelectedTab}
onChange={handleTabChange}
selectedCount={selectedIds.size}
showTabs={showTabs}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{[60, 45, 75, 55, 40].map((w, i) => (
<div
key={i}
className="flex items-center gap-2 rounded-md px-2 py-2"
>
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectoryHeader />
<div className="min-h-0 flex-1 overflow-y-auto">
{[60, 45, 75, 55, 40].map((w, i) => (
<div
className="h-3 rounded bg-gray-100 animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
key={i}
className={`${DIRECTORY_GRID_CLASS} rounded-md px-2 py-2`}
>
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-gray-100 animate-pulse"
style={{ width: `${w}%` }}
/>
<SkeletonLine className="w-8" />
<SkeletonLine className="w-14" />
<SkeletonLine className="ml-auto w-10" />
</div>
))}
</div>
</div>
</div>
);
}
if (
allDocs.length === 0 &&
directoryProjects.length === 0 &&
!showTabs &&
directoryStandaloneDocs.length === 0 &&
uploadingFilenames.length === 0
) {
return (
<div className="flex min-h-0 flex-1 flex-col space-y-2">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
{showTabs && (
<FileDirectoryTabs
<SearchBar
value={search}
onValueChange={setSearch}
placeholder="Search..."
autoFocus
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
{(showTabs || selectedIds.size > 0) && (
<FileDirectoryControls
activeTab={activeTab}
onChange={setSelectedTab}
onChange={handleTabChange}
selectedCount={selectedIds.size}
showTabs={showTabs}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
No documents yet
</p>
</div>
</div>
@ -216,99 +488,88 @@ export function FileDirectory({
return (
<div className="flex min-h-0 flex-1 flex-col space-y-2 rounded-sm">
{searchable && (
<SearchBar
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
autoFocus={searchAutoFocus}
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
)}
{showTabs && (
<FileDirectoryTabs
<SearchBar
value={search}
onValueChange={setSearch}
placeholder="Search..."
autoFocus
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
/>
{(showTabs || selectedIds.size > 0) && (
<FileDirectoryControls
activeTab={activeTab}
onChange={setSelectedTab}
onChange={handleTabChange}
selectedCount={selectedIds.size}
showTabs={showTabs}
/>
)}
{activeTabHasNoResults ? (
<div className="min-h-0 flex-1 overflow-y-auto">
<p className="text-center text-sm text-gray-400 py-8">
{searchNoResultsMessage}
No matches found
</p>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectoryHeader />
<div className="min-h-0 flex-1 overflow-y-auto">
{activeTab === "files" && (
<>
{visibleUploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
className={`w-full ${DIRECTORY_GRID_CLASS} py-2 pl-2 pr-2 text-xs text-left`}
>
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
<span className="flex-1 truncate text-gray-400">
{filename}
</span>
<span className="shrink-0 text-gray-300">
Uploading
</span>
<FileDirectoryMetaCells
version={null}
created="Uploading"
size={null}
/>
</div>
))}
{visibleStandaloneDocs.map((doc) => {
const selected = selectedIds.has(doc.id);
return (
<button
type="button"
key={doc.id}
onClick={() => toggle(doc.id)}
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
selected
? "bg-gray-900 border-gray-900"
: "border-gray-300"
}`}
>
{selected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
</span>
<DocFileIcon fileType={doc.file_type} />
<span
className={`flex-1 truncate ${
selected
? "text-gray-900"
: "text-gray-700"
}`}
>
{doc.filename}
</span>
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(doc.created_at)}
</span>
)}
</button>
);
})}
{!q &&
renderLibraryFolderRows(
directoryFileFolders,
directoryStandaloneDocs,
null,
)}
{(q
? visibleStandaloneDocs
: folderDocuments(directoryStandaloneDocs, null)
).map((doc) => renderDocumentRow(doc))}
{!q &&
visibleStandaloneDocs.length === 0 &&
directoryFileFolders.length === 0 &&
visibleUploadingFilenames.length === 0 && (
<p className="text-center text-sm text-gray-400 py-8">
{emptyMessage}
No documents yet
</p>
)}
</>
)}
{activeTab === "templates" && (
<>
{!q &&
renderLibraryFolderRows(
directoryTemplateFolders,
directoryTemplateDocs,
null,
)}
{(q
? visibleTemplateDocs
: folderDocuments(directoryTemplateDocs, null)
).map((doc) => renderDocumentRow(doc))}
{!q &&
visibleTemplateDocs.length === 0 &&
directoryTemplateFolders.length === 0 && (
<p className="text-center text-sm text-gray-400 py-8">
No templates yet
</p>
)}
</>
@ -317,10 +578,18 @@ export function FileDirectory({
{activeTab === "projects" &&
visibleDirectoryProjects.map((project) => {
const isExpanded =
forceExpanded ||
!!q ||
expandedProjects.has(project.id);
!!q || expandedProjects.has(project.id);
const docs = project.documents ?? [];
const projectDocIds = docs.map((doc) => doc.id);
const allProjectDocsSelected =
projectDocIds.length > 0 &&
projectDocIds.every((id) =>
selectedIds.has(id),
);
const someProjectDocsSelected =
projectDocIds.some((id) =>
selectedIds.has(id),
) && !allProjectDocsSelected;
return (
<div key={project.id}>
<button
@ -328,19 +597,41 @@ export function FileDirectory({
onClick={() =>
toggleFolder(project.id)
}
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70`}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
)}
<span className="flex-1 truncate font-medium text-gray-700">
<span
role="checkbox"
aria-checked={
someProjectDocsSelected
? "mixed"
: allProjectDocsSelected
}
aria-label={`Select all files in ${project.name}`}
onClick={(e) => {
e.stopPropagation();
toggleDocuments(docs);
}}
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
allProjectDocsSelected ||
someProjectDocsSelected
? "bg-gray-900 border-gray-900"
: docs.length === 0
? "border-gray-200 bg-gray-50"
: "border-gray-300"
}`}
>
{allProjectDocsSelected && (
<Check className="h-2.5 w-2.5 text-white" />
)}
{someProjectDocsSelected && (
<span className="h-px w-2 bg-white" />
)}
</span>
<ProjectSvgIcon
open={isExpanded}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="min-w-0 truncate font-medium text-gray-700">
{project.name}
{project.cm_number && (
<span className="ml-1 font-normal text-gray-400">
@ -348,8 +639,18 @@ export function FileDirectory({
</span>
)}
</span>
<span className="text-xs text-gray-400 shrink-0">
{docs.length}
<span className="truncate text-gray-400">
-
</span>
<span className="truncate text-gray-400">
{formatDate(project.created_at) ??
"--"}
</span>
<span className="truncate text-right text-gray-400">
{docs.length}{" "}
{docs.length === 1
? "file"
: "files"}
</span>
</button>
{isExpanded && (
@ -367,9 +668,9 @@ export function FileDirectory({
type="button"
key={doc.id}
onClick={() =>
toggle(doc.id)
toggle(doc)
}
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pl-7 pr-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
@ -392,7 +693,7 @@ export function FileDirectory({
}
/>
<span
className={`flex-1 truncate min-w-0 ${
className={`min-w-0 truncate ${
selected
? "text-gray-900"
: "text-gray-700"
@ -400,19 +701,17 @@ export function FileDirectory({
>
{doc.filename}
</span>
<VersionChip
n={
doc.active_version_number ??
doc.latest_version_number
}
<FileDirectoryMetaCells
version={versionLabel(
doc,
)}
created={formatDate(
doc.created_at,
)}
size={formatBytes(
doc.size_bytes,
)}
/>
{doc.created_at && (
<span className="shrink-0 text-gray-300">
{formatDate(
doc.created_at,
)}
</span>
)}
</button>
);
})
@ -429,29 +728,85 @@ export function FileDirectory({
No projects yet
</p>
)}
</div>
</div>
)}
</div>
);
}
function FileDirectoryTabs({
activeTab,
onChange,
}: {
activeTab: "files" | "projects";
onChange: (tab: "files" | "projects") => void;
}) {
function FileDirectoryHeader({ indented = false }: { indented?: boolean }) {
return (
<ModalSegmentedToggle
value={activeTab}
onChange={onChange}
options={[
{ value: "files", label: "Files" },
{ value: "projects", label: "Projects" },
]}
size="sm"
className="self-start"
/>
<div
className={`${DIRECTORY_GRID_CLASS} ${
indented ? "pl-7 pr-2" : "px-2"
} pb-1 pt-0.5 text-[11px] font-medium text-gray-400`}
>
<span />
<span className="col-span-2">Name</span>
<span>Version</span>
<span>Created</span>
<span className="text-right">Size</span>
</div>
);
}
function FileDirectoryMetaCells({
version,
created,
size,
}: {
version: string | null;
created: string | null;
size: string | null;
}) {
return (
<>
<span className="truncate text-gray-400">{version ?? "--"}</span>
<span className="truncate text-gray-400">{created ?? "--"}</span>
<span className="truncate text-right text-gray-400">
{size ?? "--"}
</span>
</>
);
}
function FileDirectoryControls({
activeTab,
onChange,
selectedCount,
showTabs,
}: {
activeTab: DirectoryTab;
onChange: (tab: DirectoryTab) => void;
selectedCount: number;
showTabs: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3 pr-2">
{showTabs ? (
<div className="flex items-center gap-1.5">
{DIRECTORY_TABS.map((tab) => {
const active = activeTab === tab.value;
return (
<TabPillButton
key={tab.value}
active={active}
onClick={() => onChange(tab.value)}
>
{tab.label}
</TabPillButton>
);
})}
</div>
) : (
<span />
)}
{selectedCount > 0 && (
<span className="shrink-0 text-xs text-gray-400">
{selectedCount} selected
</span>
)}
</div>
);
}

View file

@ -1,4 +1,5 @@
import { File, FileChartPie, FileSpreadsheet, FileText } from "lucide-react";
import Image from "next/image";
import { File } from "lucide-react";
export type FileTypeKind = "pdf" | "word" | "excel" | "ppt" | "other";
@ -33,16 +34,81 @@ export function FileTypeIcon({
muted?: boolean;
}) {
const cls = `${className} shrink-0`;
if (muted) return <File className={`${cls} text-gray-300`} />;
switch (fileTypeKind(fileType)) {
const kind = fileTypeKind(fileType);
if (muted) {
const src =
kind === "pdf"
? "/icons/file-types/pdf.svg"
: kind === "word"
? "/icons/file-types/word.svg"
: kind === "excel"
? "/icons/file-types/excel.svg"
: kind === "ppt"
? "/icons/file-types/ppt.svg"
: null;
return src ? (
<Image
src={src}
alt=""
aria-hidden="true"
width={64}
height={64}
unoptimized
className={`${cls} object-contain grayscale opacity-35`}
/>
) : (
<File className={`${cls} text-gray-300`} />
);
}
switch (kind) {
case "pdf":
return <FileText className={`${cls} text-red-500`} />;
return (
<Image
src="/icons/file-types/pdf.svg"
alt=""
aria-hidden="true"
width={64}
height={64}
unoptimized
className={`${cls} object-contain`}
/>
);
case "word":
return <File className={`${cls} text-blue-500`} />;
return (
<Image
src="/icons/file-types/word.svg"
alt=""
aria-hidden="true"
width={64}
height={64}
unoptimized
className={`${cls} object-contain`}
/>
);
case "excel":
return <FileSpreadsheet className={`${cls} text-emerald-500`} />;
return (
<Image
src="/icons/file-types/excel.svg"
alt=""
aria-hidden="true"
width={64}
height={64}
unoptimized
className={`${cls} object-contain`}
/>
);
case "ppt":
return <FileChartPie className={`${cls} text-red-500`} />;
return (
<Image
src="/icons/file-types/ppt.svg"
alt=""
aria-hidden="true"
width={64}
height={64}
unoptimized
className={`${cls} object-contain`}
/>
);
default:
return <File className={`${cls} text-gray-500`} />;
}

View file

@ -0,0 +1,79 @@
import Image, { type ImageProps } from "next/image";
type FolderSvgIconProps = Omit<
ImageProps,
"alt" | "src" | "width" | "height" | "unoptimized"
>;
type FolderIconName =
| "folder-closed"
| "folder-open"
| "project-closed"
| "project-opened";
type FolderStateIconProps = FolderSvgIconProps & {
open?: boolean;
};
const FOLDER_ICON_VERSION = "19";
const FOLDER_ICON_BASE_PATH = "/icons/app-sidebar";
function FolderSvgIcon({
name,
className,
...props
}: FolderSvgIconProps & { name: FolderIconName }) {
return (
<Image
src={`${FOLDER_ICON_BASE_PATH}/${name}.svg?v=${FOLDER_ICON_VERSION}`}
alt=""
width={64}
height={64}
unoptimized
aria-hidden="true"
draggable={false}
className={`${className ?? ""} object-contain`}
{...props}
/>
);
}
export function ClosedSubfolderSvgIcon(props: FolderSvgIconProps) {
return <FolderSvgIcon name="folder-closed" {...props} />;
}
export function OpenSubfolderSvgIcon(props: FolderSvgIconProps) {
return <FolderSvgIcon name="folder-open" {...props} />;
}
export function SubfolderSvgIcon({ open = false, ...props }: FolderStateIconProps) {
return open ? (
<OpenSubfolderSvgIcon {...props} />
) : (
<ClosedSubfolderSvgIcon {...props} />
);
}
export function ClosedProjectSvgIcon(props: FolderSvgIconProps) {
return <FolderSvgIcon name="project-closed" {...props} />;
}
export function OpenProjectSvgIcon(props: FolderSvgIconProps) {
return <FolderSvgIcon name="project-opened" {...props} />;
}
export function ProjectSvgIcon({ open = false, ...props }: FolderStateIconProps) {
return open ? (
<OpenProjectSvgIcon {...props} />
) : (
<ClosedProjectSvgIcon {...props} />
);
}
export function ClosedFolderSvgIcon(props: FolderSvgIconProps) {
return <ClosedSubfolderSvgIcon {...props} />;
}
export function OpenFolderSvgIcon(props: FolderSvgIconProps) {
return <OpenSubfolderSvgIcon {...props} />;
}

View file

@ -3,10 +3,12 @@
import { MoreHorizontal, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import {
LiquidDropdownContent,
LiquidDropdownItem,
} from "@/app/components/ui/liquid-dropdown";
import { cn } from "@/app/lib/utils";
export type HeaderActionsMenuItem = {
@ -39,11 +41,11 @@ export function HeaderActionsMenu({
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[160] w-48 bg-white">
<LiquidDropdownContent align="end" className="z-[160] w-48">
{items.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
<LiquidDropdownItem
key={item.label}
disabled={item.disabled}
variant={
@ -60,10 +62,10 @@ export function HeaderActionsMenu({
>
{Icon && <Icon className="h-3.5 w-3.5" />}
{item.label}
</DropdownMenuItem>
</LiquidDropdownItem>
);
})}
</DropdownMenuContent>
</LiquidDropdownContent>
</DropdownMenu>
);
}

View file

@ -1,119 +0,0 @@
"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

@ -1,8 +1,6 @@
"use client";
import {
Fragment,
isValidElement,
useEffect,
useRef,
useState,
@ -10,7 +8,7 @@ import {
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { ChevronLeft, Loader2, Plus, Search } from "lucide-react";
import { usePageChrome } from "@/app/contexts/PageChromeContext";
import { cn } from "@/app/lib/utils";
@ -24,13 +22,12 @@ export interface PageHeaderBreadcrumb {
}
type PageHeaderButtonAction = {
type?: "button";
type?: never;
icon?: ReactNode;
label?: ReactNode;
onClick?: () => void;
disabled?: boolean;
title?: string;
variant?: "default" | "danger";
iconOnly?: boolean;
tooltip?: ReactNode;
};
@ -42,14 +39,6 @@ type PageHeaderSearchAction = {
placeholder?: string;
};
type PageHeaderDeleteAction = {
type: "delete";
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
title?: string;
};
type PageHeaderNewAction = {
type: "new";
onClick?: () => void;
@ -66,23 +55,22 @@ type PageHeaderCustomAction = {
export type PageHeaderAction =
| PageHeaderButtonAction
| PageHeaderSearchAction
| PageHeaderDeleteAction
| PageHeaderNewAction
| PageHeaderCustomAction
| ReactNode;
| PageHeaderCustomAction;
type MaybePageHeaderAction = PageHeaderAction | null | false | undefined;
type PageHeaderActionGroup =
| PageHeaderAction[]
| MaybePageHeaderAction[]
| {
actions: PageHeaderAction[];
actions: MaybePageHeaderAction[];
};
interface PageHeaderProps {
children?: ReactNode;
actions?: PageHeaderAction[];
actions?: MaybePageHeaderAction[];
actionGroups?: PageHeaderActionGroup[];
shrink?: boolean;
className?: string;
breadcrumbs?: PageHeaderBreadcrumb[];
loading?: boolean;
}
@ -92,7 +80,6 @@ export function PageHeader({
actions,
actionGroups,
shrink = false,
className,
breadcrumbs,
loading = false,
}: PageHeaderProps) {
@ -104,7 +91,7 @@ export function PageHeader({
);
const actionsDisabled =
loading || !!breadcrumbs?.some((item) => item.loading);
const actionItems = actions?.filter(Boolean) ?? [];
const actionItems = actions?.filter(isPresentAction) ?? [];
const groupedActionItems = (
actionGroups
?.map(normalizeActionGroup)
@ -117,10 +104,9 @@ export function PageHeader({
<div
className={cn(
"flex items-center justify-between",
"px-4 md:px-10",
"mx-4 md:mx-6",
"min-h-[76px] pb-4 pt-5.5",
shrink && "shrink-0",
className,
)}
>
{headerContent}
@ -163,16 +149,15 @@ function PageHeaderActionGroups({
key={groupIndex}
className={cn(
"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",
"rounded-full border border-white/70 bg-app-surface px-1 py-1 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl",
)}
>
{group.actions.map((action, index) => (
<Fragment key={index}>
<PageHeaderActionRenderer
action={action}
disabled={actionsDisabled}
/>
</Fragment>
<PageHeaderActionRenderer
key={index}
action={action}
disabled={actionsDisabled}
/>
))}
</div>
))}
@ -183,14 +168,18 @@ function PageHeaderActionGroups({
function normalizeActionGroup(group: PageHeaderActionGroup) {
if (Array.isArray(group)) {
return {
actions: group.filter(Boolean),
actions: group.filter(isPresentAction),
};
}
return {
actions: group.actions.filter(Boolean),
actions: group.actions.filter(isPresentAction),
};
}
function isPresentAction(action: MaybePageHeaderAction): action is PageHeaderAction {
return Boolean(action);
}
function PageHeaderActionRenderer({
action,
disabled,
@ -198,16 +187,6 @@ function PageHeaderActionRenderer({
action: PageHeaderAction;
disabled: boolean;
}) {
if (!isPageHeaderActionObject(action)) {
return disabled ? (
<span className="inline-flex h-7 items-center opacity-40 pointer-events-none">
{action}
</span>
) : (
<>{action}</>
);
}
switch (action.type) {
case "search":
return (
@ -216,13 +195,6 @@ function PageHeaderActionRenderer({
disabled={disabled}
/>
);
case "delete":
return (
<PageHeaderDeleteActionControl
action={action}
disabled={disabled}
/>
);
case "new":
return (
<PageHeaderNewActionControl
@ -241,7 +213,6 @@ function PageHeaderActionRenderer({
{action.render}
</span>
);
case "button":
default:
return (
<PageHeaderButtonActionControl
@ -252,12 +223,6 @@ function PageHeaderActionRenderer({
}
}
function isPageHeaderActionObject(
action: PageHeaderAction,
): action is Exclude<PageHeaderAction, ReactNode> {
return !!action && typeof action === "object" && !isValidElement(action);
}
function PageHeaderButtonActionControl({
action,
disabled,
@ -273,7 +238,6 @@ function PageHeaderButtonActionControl({
disabled={disabled || action.disabled}
title={action.title}
aria-label={action.title}
variant={action.variant}
iconOnly={iconOnly}
>
{action.icon}
@ -313,32 +277,6 @@ function PageHeaderNewActionControl({
);
}
function PageHeaderDeleteActionControl({
action,
disabled,
}: {
action: PageHeaderDeleteAction;
disabled: boolean;
}) {
const title = action.title ?? "Delete";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={disabled || action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
variant="danger"
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</PageHeaderActionButton>
);
}
function PageHeaderSearchActionControl({
action,
disabled,
@ -370,7 +308,7 @@ function PageHeaderSearchActionControl({
className:
"cursor-text justify-start gap-2 px-3 text-gray-700 hover:text-gray-700",
}),
"w-56 bg-gray-100 sm:w-80",
"w-56 bg-app-surface-active sm:w-80",
)}
>
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
@ -403,40 +341,33 @@ type PageHeaderActionButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
variant?: "default" | "danger";
iconOnly?: boolean;
};
type PageHeaderActionControlClassNameOptions = {
variant?: "default" | "danger";
iconOnly?: boolean;
disabled?: boolean;
className?: string;
};
function pageHeaderActionControlClassName({
variant = "default",
iconOnly = false,
disabled = false,
className,
}: PageHeaderActionControlClassNameOptions = {}) {
return cn(
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-app-surface-hover active:bg-app-surface-active disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
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"
? "text-gray-500 hover:text-red-600"
: "text-gray-500 hover:text-gray-900",
"text-gray-500 hover:text-gray-900",
className,
);
}
function PageHeaderActionButton({
children,
variant = "default",
iconOnly = false,
disabled,
...props
@ -445,7 +376,6 @@ function PageHeaderActionButton({
<button
disabled={disabled}
className={pageHeaderActionControlClassName({
variant,
iconOnly,
disabled,
})}

View file

@ -1,28 +1,38 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
forwardRef,
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
} from "react";
import { createPortal } from "react-dom";
import {
Download,
Eye,
EyeOff,
FolderMinus,
FolderPlus,
Hash,
History,
Pencil,
Trash2,
Upload,
} from "lucide-react";
import { SubfolderSvgIcon } from "@/app/components/shared/FolderSvgIcon";
import {
GLASS_DROPDOWN,
GLASS_MENU_ITEM,
} from "@/app/components/shared/HeaderFilterDropdown";
CLOSE_ROW_ACTIONS_EVENT,
closeRowActionMenus,
} from "@/app/components/shared/TablePrimitive";
import {
LiquidDropdownButton,
LiquidDropdownSurface,
} from "@/app/components/ui/liquid-dropdown";
import { cn } from "@/app/lib/utils";
export const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
export { CLOSE_ROW_ACTIONS_EVENT, closeRowActionMenus };
export function closeRowActionMenus() {
document.dispatchEvent(new Event(CLOSE_ROW_ACTIONS_EVENT));
}
export type RowActionMenuSurfaceProps = ComponentPropsWithoutRef<"div">;
interface Props {
onDelete?: () => void;
@ -43,7 +53,19 @@ interface Props {
deleteLabel?: string;
}
export function RowActionMenuItems({
type RowActionMenuItemsProps = Props & {
onClose: () => void;
surfaceProps?: RowActionMenuSurfaceProps;
};
const ROW_ACTION_ITEM_CLASS =
"flex items-center gap-2 w-full px-3 py-2 text-gray-600";
const ROW_ACTION_LEFT_ITEM_CLASS = `text-left ${ROW_ACTION_ITEM_CLASS}`;
export const RowActionMenuItems = forwardRef<
HTMLDivElement,
RowActionMenuItemsProps
>(function RowActionMenuItems({
onDelete,
onHide,
onUnhide,
@ -61,98 +83,106 @@ export function RowActionMenuItems({
renameLabel = "Rename",
deleteLabel = "Delete",
onClose,
}: Props & { onClose: () => void }) {
surfaceProps,
}, ref) {
const { className: surfaceClassName, ...restSurfaceProps } =
surfaceProps ?? {};
return (
<>
<LiquidDropdownSurface
ref={ref}
className={cn("w-48 overflow-hidden", surfaceClassName)}
{...restSurfaceProps}
>
{onNewSubfolder && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onNewSubfolder(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_LEFT_ITEM_CLASS}
>
<FolderPlus className="h-3.5 w-3.5 shrink-0" />
<SubfolderSvgIcon className="h-3.5 w-3.5 shrink-0" />
{newSubfolderLabel}
</button>
</LiquidDropdownButton>
)}
{onRename && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onRename(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_ITEM_CLASS}
>
<Pencil className="h-3.5 w-3.5" />
{renameLabel}
</button>
</LiquidDropdownButton>
)}
{onEditDetails && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onEditDetails(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_ITEM_CLASS}
>
<Pencil className="h-3.5 w-3.5" />
Edit details
</button>
</LiquidDropdownButton>
)}
{onUpdateCmNumber && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onUpdateCmNumber(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_ITEM_CLASS}
>
<Hash className="h-3.5 w-3.5" />
Edit CM No.
</button>
</LiquidDropdownButton>
)}
{onDownload && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onDownload(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_ITEM_CLASS}
>
<Download className="h-3.5 w-3.5" />
Download
</button>
</LiquidDropdownButton>
)}
{onShowAllVersions && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onShowAllVersions(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_LEFT_ITEM_CLASS}
>
<History className="h-3.5 w-3.5 shrink-0" />
Show all versions
</button>
</LiquidDropdownButton>
)}
{onUploadNewVersion && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onUploadNewVersion(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_LEFT_ITEM_CLASS}
>
<Upload className="h-3.5 w-3.5 shrink-0" />
Upload new version
</button>
</LiquidDropdownButton>
)}
{onRemoveFromFolder && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onRemoveFromFolder(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_LEFT_ITEM_CLASS}
>
<FolderMinus className="h-3.5 w-3.5 shrink-0" />
Remove from subfolder
</button>
</LiquidDropdownButton>
)}
{onUnhide && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onUnhide(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_ITEM_CLASS}
>
<Eye className="h-3.5 w-3.5" />
Activate
</button>
</LiquidDropdownButton>
)}
{onHide && (
<button
<LiquidDropdownButton
onClick={() => { onClose(); onHide(); }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
className={ROW_ACTION_ITEM_CLASS}
>
<EyeOff className="h-3.5 w-3.5" />
Deactivate
</button>
</LiquidDropdownButton>
)}
{onDelete && (
<button
@ -172,9 +202,9 @@ export function RowActionMenuItems({
{deleteLabel}
</button>
)}
</>
</LiquidDropdownSurface>
);
}
});
export function RowActions(props: Props) {
const [open, setOpen] = useState(false);
@ -224,23 +254,28 @@ export function RowActions(props: Props) {
<button
ref={btnRef}
onClick={handleToggle}
className="flex items-center justify-center w-6 h-6 rounded text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-colors leading-none"
className="flex items-center justify-center w-6 h-6 rounded text-gray-700 hover:text-gray-900 hover:bg-app-surface-hover transition-colors leading-none"
>
<span className="tracking-widest text-xs">···</span>
</button>
{open && (
<div
style={{ position: "fixed", top: coords.top, right: coords.right }}
className={`z-[120] w-48 overflow-hidden ${GLASS_DROPDOWN}`}
onClick={(e) => e.stopPropagation()}
>
{open &&
createPortal(
<RowActionMenuItems
{...props}
onClose={() => setOpen(false)}
/>
</div>
)}
surfaceProps={{
style: {
position: "fixed",
top: coords.top,
right: coords.right,
},
className: "z-[120]",
onClick: (e) => e.stopPropagation(),
}}
/>,
document.body,
)}
</>
);
}

View file

@ -4,14 +4,17 @@ import { useState, useRef, useEffect } from "react";
import { MoreHorizontal, Pencil, Trash2, Check, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import {
LiquidDropdownContent,
LiquidDropdownItem,
} from "@/app/components/ui/liquid-dropdown";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { useAuth } from "@/app/contexts/AuthContext";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import type { Chat } from "@/app/components/shared/types";
import { ChatSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import { cn } from "@/app/lib/utils";
interface Props {
@ -50,8 +53,10 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
return (
<div
className={cn(
"group relative flex items-center w-full h-9 rounded-md transition-colors",
isActive ? "bg-gray-200/60" : "hover:bg-gray-100",
"group relative flex h-8 w-full items-center rounded-md transition-colors",
isActive
? "bg-gray-200/60 pr-1"
: "pr-3 hover:bg-gray-100 hover:pr-1",
)}
>
{isRenaming ? (
@ -82,6 +87,7 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
</div>
) : (
<>
<ChatSkeuoIcon className="ml-2.5 h-3.5 w-3.5 shrink-0" />
<button
onClick={onSelect}
onMouseEnter={(e) => {
@ -92,9 +98,12 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
onMouseLeave={(e) => {
e.currentTarget.scrollTo({ left: 0, behavior: "smooth" });
}}
className={`flex-1 min-w-0 text-left px-3 py-2 text-xs overflow-x-hidden whitespace-nowrap scrollbar-none ${
isActive ? "text-gray-900" : "text-gray-700"
}`}
className={cn(
"min-w-0 flex-1 overflow-x-hidden whitespace-nowrap scrollbar-none py-1 pl-2 text-left text-xs",
isActive
? "pr-3 text-gray-900"
: "pr-0 text-gray-700 group-hover:pr-3",
)}
title={projectName ? `${projectName}: ${chat.title ?? "Untitled chat"}` : (chat.title ?? "Untitled chat")}
>
{projectName && (
@ -106,17 +115,17 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`mr-1 rounded-md p-1 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 ${
className={`flex h-6 w-0 shrink-0 items-center justify-center overflow-hidden rounded-md bg-transparent text-gray-500 opacity-0 transition-opacity hover:text-gray-900 ${
isActive
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
? "w-6 opacity-100"
: "pointer-events-none group-hover:w-6 group-hover:pointer-events-auto group-hover:opacity-100"
}`}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-101">
<DropdownMenuItem
<LiquidDropdownContent align="end" className="z-101">
<LiquidDropdownItem
onClick={() => {
if (!isChatOwner) {
setOwnerOnlyAction("rename this chat");
@ -128,8 +137,8 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
>
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
</LiquidDropdownItem>
<LiquidDropdownItem
onClick={() => {
if (!isChatOwner) {
setOwnerOnlyAction("delete this chat");
@ -141,8 +150,8 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</LiquidDropdownItem>
</LiquidDropdownContent>
</DropdownMenu>
</>
)}

View file

@ -5,24 +5,142 @@ import {
useRef,
useState,
type HTMLAttributes,
type MouseEvent,
type ComponentType,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/app/lib/utils";
import {
CLOSE_ROW_ACTIONS_EVENT,
closeRowActionMenus,
} from "@/app/components/shared/RowActions";
import { GLASS_DROPDOWN } from "@/app/components/shared/HeaderFilterDropdown";
DropdownMenu,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import {
LiquidDropdownContent,
LiquidDropdownItem,
} from "@/app/components/ui/liquid-dropdown";
import { LIQUID_TABLE_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
export const TABLE_STICKY_CELL_BG = "bg-[#fafbfc]";
export const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
export function closeRowActionMenus() {
document.dispatchEvent(new Event(CLOSE_ROW_ACTIONS_EVENT));
}
function canPortalToDocument() {
return typeof document !== "undefined";
}
export const TABLE_STICKY_CELL_BG = "bg-app-surface";
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";
"mr-4 h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black";
type DivProps = HTMLAttributes<HTMLDivElement>;
export type TableFilterOption<T extends string> = {
value: T;
label: string;
icon?: ComponentType<{ className?: string }>;
className?: string;
};
export type TableSortDirection = "asc" | "desc";
export function TableFilters<T extends string>({
label,
value,
allLabel,
options,
onChange,
widthClassName = "w-52",
align = "left",
}: {
label: string;
value: T | null;
allLabel: string;
options: TableFilterOption<T>[];
onChange: (value: T | null) => void;
widthClassName?: string;
/**
* Which side the menu opens toward. "left" (default) anchors the menu's
* right edge to the button and extends leftward; "right" anchors the menu's
* left edge to the button and extends rightward.
*/
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
aria-label={label}
title={selected?.label ?? label}
className={`flex h-[18px] w-[22px] items-center justify-center rounded-sm transition-colors ${
value
? "text-gray-700 hover:bg-app-surface-hover hover:text-gray-900"
: "text-gray-400 hover:bg-app-surface-hover hover:text-gray-700"
}`}
>
<ChevronDown
className={`h-3 w-3 transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</button>
</DropdownMenuTrigger>
<LiquidDropdownContent
align={align === "right" ? "start" : "end"}
className={`z-[120] overflow-hidden ${widthClassName}`}
>
<LiquidDropdownItem
onSelect={() => onChange(null)}
className="flex w-full items-center justify-between px-3 py-2"
>
{allLabel}
{!value && <Check className="h-3.5 w-3.5 text-gray-400" />}
</LiquidDropdownItem>
{options.length > 0 && (
<DropdownMenuSeparator className="-mx-1 my-1 bg-white/60" />
)}
{options.map((option) => {
const Icon = option.icon;
return (
<LiquidDropdownItem
key={option.value}
onSelect={() => onChange(option.value)}
className="flex w-full items-center justify-between px-3 py-2"
>
<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" />
)}
</LiquidDropdownItem>
);
})}
</LiquidDropdownContent>
</DropdownMenu>
);
}
export function SkeletonLine({ className }: { className?: string }) {
return (
<div
@ -45,31 +163,37 @@ export function SkeletonDot({ className }: { className?: string }) {
export function TableScrollArea({
children,
className,
innerClassName,
header,
}: DivProps & { innerClassName?: string; header?: ReactNode }) {
const bodyRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
function syncHeader() {
if (headerRef.current && bodyRef.current) {
headerRef.current.scrollLeft = bodyRef.current.scrollLeft;
}
}
scrollRef,
onScroll,
}: DivProps & {
header?: ReactNode;
scrollRef?: RefObject<HTMLDivElement | null>;
}) {
const headerViewportRef = useRef<HTMLDivElement>(null);
return (
<div className={cn("w-full min-h-0 flex-1 flex flex-col overflow-hidden", className)}>
{header !== undefined && (
<div ref={headerRef} className="shrink-0 overflow-hidden">
{header}
</div>
)}
<div
ref={bodyRef}
className="min-h-0 flex-1 overflow-auto"
onScroll={header !== undefined ? syncHeader : undefined}
>
<div className={cn("flex min-h-full min-w-max flex-col", innerClassName)}>
<div className={cn("mx-4 mb-2 min-h-0 min-w-0 flex-1 rounded-2xl md:mx-6 md:mb-3", className)}>
<div className={cn("flex h-full min-h-0 min-w-0 flex-col overflow-hidden", LIQUID_TABLE_SURFACE_CLASS)}>
{header && (
<div
ref={headerViewportRef}
className="min-w-0 shrink-0 overflow-hidden"
>
{header}
</div>
)}
<div
ref={scrollRef}
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-auto overscroll-x-none"
onScroll={(event) => {
if (headerViewportRef.current) {
headerViewportRef.current.scrollLeft =
event.currentTarget.scrollLeft;
}
onScroll?.(event);
}}
>
{children}
</div>
</div>
@ -81,7 +205,7 @@ export function TableHeaderRow({ children, className, ...props }: DivProps) {
return (
<div
className={cn(
"sticky top-0 z-[70] flex h-8 items-center border-b border-gray-200 bg-[#fafbfc] pr-3 text-xs font-medium text-gray-500 select-none md:pr-10",
"z-[70] flex h-10 min-w-max items-center bg-app-surface pr-3 text-xs font-medium text-gray-500 select-none backdrop-blur-xl",
className,
)}
{...props}
@ -100,7 +224,9 @@ export function TableRow({
...props
}: DivProps & {
interactive?: boolean;
rightClickDropdown?: ReactNode | ((close: () => void) => ReactNode);
rightClickDropdown?:
| ReactNode
| ((close: () => void, menuProps: DivProps) => ReactNode);
}) {
const [menuCoords, setMenuCoords] = useState<{
top: number;
@ -130,7 +256,7 @@ export function TableRow({
setMenuCoords(null);
}
function handleContextMenu(e: MouseEvent<HTMLDivElement>) {
function handleContextMenu(e: ReactMouseEvent<HTMLDivElement>) {
onContextMenu?.(e);
if (!rightClickDropdown || e.defaultPrevented) return;
e.preventDefault();
@ -147,8 +273,8 @@ export function TableRow({
<>
<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",
"group flex h-10 min-w-max items-center pr-3 transition-colors",
interactive && "cursor-pointer hover:bg-app-surface-hover",
className,
)}
onContextMenu={handleContextMenu}
@ -156,22 +282,24 @@ export function TableRow({
>
{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>
)}
{menuCoords &&
rightClickDropdown &&
canPortalToDocument() &&
createPortal(
typeof rightClickDropdown === "function"
? rightClickDropdown(closeRightClickDropdown, {
style: {
position: "fixed",
top: menuCoords.top,
left: menuCoords.left,
},
className: "z-[120]",
onClick: (e) => e.stopPropagation(),
onContextMenu: (e) => e.preventDefault(),
})
: rightClickDropdown,
document.body,
)}
</>
);
}
@ -192,13 +320,13 @@ export function TableStickyCell({
return (
<div
className={cn(
"sticky left-0 z-[60] flex gap-4 pl-4 pr-2 text-left",
"sticky left-0 z-[60] flex pl-4 pr-2 text-left",
widthClassName,
bgClassName,
header
? "z-[80] items-center self-stretch"
: "py-2 transition-colors",
!header && hover && "group-hover:bg-gray-100",
!header && hover && "group-hover:bg-app-surface-hover",
className,
)}
>
@ -264,7 +392,7 @@ export function TablePrimaryCell({
bgClassName={bgClassName}
className={className}
>
<div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 items-center">
<input
type="checkbox"
checked={selected}
@ -281,7 +409,10 @@ export function TablePrimaryCell({
export function TableHeaderCell({ children, className, ...props }: DivProps) {
return (
<div className={cn("shrink-0 text-left", className)} {...props}>
<div
className={cn("flex shrink-0 items-center text-left", className)}
{...props}
>
{children}
</div>
);

View file

@ -1,4 +1,31 @@
import React from "react";
"use client";
import React, { useSyncExternalStore } from "react";
import { Settings2 } from "lucide-react";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import {
DropdownMenu,
DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import { LiquidDropdownContent } from "@/app/components/ui/liquid-dropdown";
const DESKTOP_QUERY = "(min-width: 768px)";
function subscribeToDesktopQuery(onStoreChange: () => void) {
if (typeof window === "undefined") return () => {};
const query = window.matchMedia(DESKTOP_QUERY);
query.addEventListener("change", onStoreChange);
return () => query.removeEventListener("change", onStoreChange);
}
function getDesktopSnapshot() {
if (typeof window === "undefined") return true;
return window.matchMedia(DESKTOP_QUERY).matches;
}
function getDesktopServerSnapshot() {
return true;
}
interface ToolbarItem<T extends string> {
id: T;
@ -6,51 +33,68 @@ interface ToolbarItem<T extends string> {
}
interface Props<T extends string> {
items: ToolbarItem<T>[];
active: T;
onChange: (id: T) => void;
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,
items = [],
active,
onChange,
actions,
}: Props<T>) {
const hasItems = items.length > 0;
const isDesktop = useSyncExternalStore(
subscribeToDesktopQuery,
getDesktopSnapshot,
getDesktopServerSnapshot,
);
return (
<div className="flex items-center h-10 px-4 border-b border-gray-200 md:px-10">
<div className="mx-4 mb-2 flex h-10 items-center md:mx-6">
{hasItems && (
<div className="flex-1 flex items-center gap-5">
<div className="flex flex-1 items-center gap-1.5 overflow-x-auto">
{items.map((item) => (
<button
<TabPillButton
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"
}`}
active={active === item.id}
onClick={() => onChange?.(item.id)}
>
{item.label}
</button>
</TabPillButton>
))}
</div>
)}
{actions && (
<div
className={
hasItems
? "flex items-center gap-2"
: "flex flex-1 items-center gap-2"
}
>
{actions && isDesktop && (
<div className="ml-auto flex items-center gap-2">
{actions}
</div>
)}
{actions && !isDesktop && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="Toolbar actions"
aria-label="Toolbar actions"
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/70 bg-white/65 text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.05),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors hover:bg-white hover:text-gray-900 active:scale-[0.98]"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<LiquidDropdownContent
align="end"
className="z-[130] min-w-40 p-1"
>
<div className="flex flex-col gap-0.5 [&_.hidden]:inline [&>div]:flex [&>div]:flex-col [&>div]:items-stretch [&>div]:gap-0.5 [&_button]:h-auto [&_button]:w-full [&_button]:justify-start [&_button]:rounded-lg [&_button]:border-0 [&_button]:bg-transparent [&_button]:px-3 [&_button]:py-2 [&_button]:text-left [&_button]:text-xs [&_button]:font-medium [&_button]:text-gray-700 [&_button]:shadow-none [&_button]:backdrop-blur-none [&_button]:transition-colors [&_button]:active:scale-100 [&_button:hover]:bg-app-surface-hover [&_button:disabled]:opacity-40">
{actions}
</div>
</LiquidDropdownContent>
</DropdownMenu>
)}
</div>
);
}

View file

@ -10,6 +10,16 @@ export interface Folder {
updated_at: string;
}
export interface LibraryFolder {
id: string;
user_id: string;
library_kind: "file" | "template";
name: string;
parent_folder_id: string | null;
created_at: string;
updated_at: string;
}
export interface Project {
id: string;
user_id: string;
@ -34,6 +44,8 @@ export interface Document {
user_id?: string;
project_id: string | null;
folder_id?: string | null;
library_kind?: "file" | "template";
library_folder_id?: string | null;
filename: string;
owner_email?: string | null;
owner_display_name?: string | null;
@ -371,6 +383,11 @@ export function isSpreadsheetFilename(filename: string): boolean {
return ext === "xlsx" || ext === "xlsm" || ext === "xls";
}
export function isDocxFilename(filename: string): boolean {
const ext = filename.split(".").pop()?.toLowerCase();
return ext === "docx" || ext === "doc";
}
/**
* Human-readable cell locator for a spreadsheet citation, e.g. "Sheet1!B7".
* Falls back to whichever of `sheet`/`cell` is present.

View file

@ -1,73 +1,155 @@
"use client";
import { useEffect, useState } from "react";
import { getProject, listProjects, listStandaloneDocuments } from "@/app/lib/mikeApi";
import type { Document, Project } from "./types";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getLibrary,
getProject,
listProjects,
} from "@/app/lib/mikeApi";
import type { Document, LibraryFolder, Project } from "./types";
const CACHE_TTL_MS = 30_000;
export type DirectoryTab = "files" | "templates" | "projects";
interface DirectoryCache {
standaloneDocuments: Document[];
projects: Project[];
fetchedAt: number;
const EMPTY_LOADING: Record<DirectoryTab, boolean> = {
files: false,
templates: false,
projects: false,
};
const EMPTY_LOADED: Record<DirectoryTab, boolean> = {
files: false,
templates: false,
projects: false,
};
function sortDocuments(docs: Document[]) {
return [...docs].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
);
}
let cache: DirectoryCache | null = null;
export function invalidateDirectoryCache() {
cache = null;
async function loadFiles() {
const files = await getLibrary("files");
return {
documents: sortDocuments(files.documents),
folders: files.folders,
};
}
export function useDirectoryData(enabled: boolean) {
const [loading, setLoading] = useState(true);
async function loadTemplates() {
const templates = await getLibrary("templates");
return {
documents: sortDocuments(templates.documents),
folders: templates.folders,
};
}
async function loadProjects() {
const projects = await listProjects();
const fullProjects = await Promise.all(
projects.map((project) => getProject(project.id)),
);
const projectCounts = new Map(
projects.map((project) => [project.id, project.document_count ?? 0]),
);
return fullProjects.map((project) => ({
...project,
document_count:
project.documents?.length ?? projectCounts.get(project.id) ?? 0,
}));
}
export function useDirectoryData(
enabled: boolean,
initialTab: DirectoryTab = "files",
) {
const [standaloneDocuments, setStandaloneDocuments] = useState<Document[]>([]);
const [templateDocuments, setTemplateDocuments] = useState<Document[]>([]);
const [fileFolders, setFileFolders] = useState<LibraryFolder[]>([]);
const [templateFolders, setTemplateFolders] = useState<LibraryFolder[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loadingTabs, setLoadingTabs] =
useState<Record<DirectoryTab, boolean>>(EMPTY_LOADING);
const loadingTabsRef = useRef<Record<DirectoryTab, boolean>>({
...EMPTY_LOADING,
});
const loadedTabsRef = useRef<Record<DirectoryTab, boolean>>({
...EMPTY_LOADED,
});
const loadTab = useCallback(
async (tab: DirectoryTab) => {
if (
!enabled ||
loadingTabsRef.current[tab] ||
loadedTabsRef.current[tab]
) {
return;
}
loadingTabsRef.current = {
...loadingTabsRef.current,
[tab]: true,
};
setLoadingTabs((prev) => ({ ...prev, [tab]: true }));
try {
if (tab === "files") {
const files = await loadFiles();
setStandaloneDocuments(files.documents);
setFileFolders(files.folders);
} else if (tab === "templates") {
const templates = await loadTemplates();
setTemplateDocuments(templates.documents);
setTemplateFolders(templates.folders);
} else {
setProjects(await loadProjects());
}
loadedTabsRef.current = {
...loadedTabsRef.current,
[tab]: true,
};
} catch {
if (tab === "files") {
setStandaloneDocuments([]);
setFileFolders([]);
} else if (tab === "templates") {
setTemplateDocuments([]);
setTemplateFolders([]);
} else {
setProjects([]);
}
} finally {
loadingTabsRef.current = {
...loadingTabsRef.current,
[tab]: false,
};
setLoadingTabs((prev) => ({ ...prev, [tab]: false }));
}
},
[enabled],
);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
void loadTab(initialTab);
});
const now = Date.now();
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
setStandaloneDocuments(cache.standaloneDocuments);
setProjects(cache.projects);
setLoading(false);
return;
}
return () => {
cancelled = true;
};
}, [enabled, initialTab, loadTab]);
setLoading(true);
Promise.all([listProjects(), listStandaloneDocuments()])
.then(([ps, ds]) => {
const sorted = [...ds].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
);
return Promise.all(ps.map((p) => getProject(p.id))).then(
(fullProjects) => {
const projectCounts = new Map(
ps.map((p) => [p.id, p.document_count ?? 0]),
);
const projectsWithCounts = fullProjects.map((project) => ({
...project,
document_count:
project.documents?.length ??
projectCounts.get(project.id) ??
0,
}));
cache = {
standaloneDocuments: sorted,
projects: projectsWithCounts,
fetchedAt: Date.now(),
};
setStandaloneDocuments(sorted);
setProjects(projectsWithCounts);
},
);
})
.catch(() => {
setStandaloneDocuments([]);
setProjects([]);
})
.finally(() => setLoading(false));
}, [enabled]);
return { loading, standaloneDocuments, projects };
return {
loading: loadingTabs[initialTab],
loadingTabs,
standaloneDocuments,
templateDocuments,
fileFolders,
templateFolders,
projects,
loadTab,
};
}

View file

@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
import { DocxView } from "./DocxView";
import type { CitationQuote } from "../types";
import {
clearHighlights,
@ -79,11 +78,6 @@ export function PdfView({
doc?.version_id ?? null,
);
// /display returned DOCX bytes — the active version has no PDF
// rendition, so fall back to docx-preview (still applies citation
// highlighting via the same `quotes` API).
const fallbackToDocx = result?.type === "docx";
// Track container width via ResizeObserver so re-renders fire on resize
useEffect(() => {
const el = scrollContainerRef.current;
@ -173,6 +167,44 @@ export function PdfView({
[],
);
// Scroll so the first highlight on `pageNum` lands at the vertical center
// of the viewer. We compute the scroll position explicitly on the scroll
// container — calling `scrollIntoView` on a child of the absolutely-
// positioned text layer can scroll just the overlay while leaving the
// canvas untouched, which is why we don't use it here.
const scrollToHighlightOnPage = useCallback((pageNum: number) => {
const pageEntry = renderedPagesRef.current[pageNum - 1];
const scrollEl = scrollContainerRef.current;
if (!pageEntry || !scrollEl) return;
const highlightEl = pageEntry.wrapper.querySelector<HTMLElement>(
".pdf-text-highlight",
);
if (highlightEl) {
const containerRect = scrollEl.getBoundingClientRect();
const highlightRect = highlightEl.getBoundingClientRect();
const offsetWithinContainer = highlightRect.top - containerRect.top;
const targetTop =
scrollEl.scrollTop +
offsetWithinContainer -
scrollEl.clientHeight / 2 +
highlightRect.height / 2;
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
} else {
const wrapperRect = pageEntry.wrapper.getBoundingClientRect();
const containerRect = scrollEl.getBoundingClientRect();
const targetTop =
scrollEl.scrollTop + (wrapperRect.top - containerRect.top);
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
}
}, []);
const renderPDF = useCallback(
async (
doc: import("pdfjs-dist").PDFDocumentProxy,
@ -292,47 +324,9 @@ export function PdfView({
reveal();
},
[applyHighlights],
[applyHighlights, scrollToHighlightOnPage],
);
// Scroll so the first highlight on `pageNum` lands at the vertical center
// of the viewer. We compute the scroll position explicitly on the scroll
// container — calling `scrollIntoView` on a child of the absolutely-
// positioned text layer can scroll just the overlay while leaving the
// canvas untouched, which is why we don't use it here.
function scrollToHighlightOnPage(pageNum: number) {
const pageEntry = renderedPagesRef.current[pageNum - 1];
const scrollEl = scrollContainerRef.current;
if (!pageEntry || !scrollEl) return;
const highlightEl = pageEntry.wrapper.querySelector<HTMLElement>(
".pdf-text-highlight",
);
if (highlightEl) {
const containerRect = scrollEl.getBoundingClientRect();
const highlightRect = highlightEl.getBoundingClientRect();
const offsetWithinContainer = highlightRect.top - containerRect.top;
const targetTop =
scrollEl.scrollTop +
offsetWithinContainer -
scrollEl.clientHeight / 2 +
highlightRect.height / 2;
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
} else {
const wrapperRect = pageEntry.wrapper.getBoundingClientRect();
const containerRect = scrollEl.getBoundingClientRect();
const targetTop =
scrollEl.scrollTop + (wrapperRect.top - containerRect.top);
scrollEl.scrollTo({
top: Math.max(0, targetTop),
behavior: "instant" as ScrollBehavior,
});
}
}
const rehighlightQuotes = useCallback(
async (list: QuoteEntry[]) => {
const targetPage = await applyHighlights(list);
@ -342,7 +336,7 @@ export function PdfView({
scrollToHighlightOnPage(scrollPage);
}
},
[applyHighlights],
[applyHighlights, scrollToHighlightOnPage],
);
// Trackpad pinch-to-zoom (wheel + ctrlKey)
@ -459,11 +453,15 @@ export function PdfView({
renderedPagesRef.current = [];
quoteListRef.current = quoteList;
zoomRef.current = 1.0;
setZoom(1.0);
setNumPages(0);
const list = quoteList;
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setZoom(1.0);
setNumPages(0);
});
(async () => {
const lib = await getPdfJs();
if (cancelled) return;
@ -489,7 +487,7 @@ export function PdfView({
}
}, 150);
return () => clearTimeout(timer);
}, [containerWidth, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
}, [containerWidth, renderPDF]);
// Re-highlight when quotes change without full re-render
useEffect(() => {
@ -530,18 +528,6 @@ export function PdfView({
}
}
if (fallbackToDocx && doc?.document_id) {
return (
<DocxView
documentId={doc.document_id}
versionId={doc.version_id ?? null}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
rounded={rounded}
/>
);
}
return (
<div
className={`relative flex flex-col bg-gray-100 flex-1 overflow-hidden ${rounded ? "rounded-lg" : ""}`}

View file

@ -5,8 +5,6 @@ import { Loader2, Upload } from "lucide-react";
import type { Document, Project, Workflow } from "../shared/types";
import {
getProject,
listProjects,
listStandaloneDocuments,
listWorkflows,
uploadProjectDocument,
uploadStandaloneDocument,
@ -57,16 +55,10 @@ export function NewTRModal({
const [projectDocs, setProjectDocs] = useState<Document[]>([]);
const [loadingDocs, setLoadingDocs] = useState(false);
// Full directory (when underProject is false)
const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]);
const [directoryProjects, setDirectoryProjects] = useState<Project[]>(
const [extraStandaloneDocs, setExtraStandaloneDocs] = useState<Document[]>(
[],
);
const [loadingDirectory, setLoadingDirectory] = useState(false);
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
new Set(),
);
const [selectedDocuments, setSelectedDocuments] = useState<Document[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -109,33 +101,8 @@ export function NewTRModal({
.finally(() => setLoadingWorkflows(false));
if (isProjectMode) {
setSelectedDocIds(
new Set((fixedProjectDocs ?? []).map((d) => d.id)),
);
return;
setSelectedDocuments(fixedProjectDocs ?? []);
}
setLoadingDirectory(true);
// /projects only returns counts, not the documents array — fetch
// each project in parallel so FileDirectory can render the docs
// when the user expands a folder.
Promise.all([listStandaloneDocuments(), listProjects()])
.then(async ([docs, projs]) => {
setStandaloneDocs(
[...docs].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
);
const fullProjects = await Promise.all(
projs.map((p) => getProject(p.id)),
);
setDirectoryProjects(fullProjects);
})
.catch(() => {
setStandaloneDocs([]);
setDirectoryProjects([]);
})
.finally(() => setLoadingDirectory(false));
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null;
@ -146,9 +113,8 @@ export function NewTRModal({
setUnderProject(false);
setSelectedProjectId("");
setProjectDocs([]);
setStandaloneDocs([]);
setDirectoryProjects([]);
setSelectedDocIds(new Set());
setExtraStandaloneDocs([]);
setSelectedDocuments([]);
setSelectedWorkflowId(null);
onClose();
}
@ -175,7 +141,9 @@ export function NewTRModal({
onAdd(
title.trim(),
underProject ? selectedProjectId : undefined,
selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
selectedDocuments.length > 0
? selectedDocuments.map((document) => document.id)
: undefined,
selectedWorkflow?.columns_config ?? undefined,
);
handleClose();
@ -184,7 +152,7 @@ export function NewTRModal({
async function handleSelectProject(projectId: string) {
setSelectedProjectId(projectId);
setProjectDocs([]);
setSelectedDocIds(new Set());
setSelectedDocuments([]);
setLoadingDocs(true);
try {
const proj = await getProject(projectId);
@ -192,7 +160,7 @@ export function NewTRModal({
(d) => d.status === "ready",
);
setProjectDocs(docs);
setSelectedDocIds(new Set(docs.map((d) => d.id)));
setSelectedDocuments(docs);
} finally {
setLoadingDocs(false);
}
@ -213,11 +181,15 @@ export function NewTRModal({
if (underProject && selectedProjectId) {
setProjectDocs((prev) => [...uploaded, ...prev]);
} else {
setStandaloneDocs((prev) => [...uploaded, ...prev]);
setExtraStandaloneDocs((prev) => [...uploaded, ...prev]);
}
uploaded.forEach((d) =>
setSelectedDocIds((prev) => new Set([...prev, d.id])),
);
setSelectedDocuments((prev) => [
...prev,
...uploaded.filter(
(document) =>
!prev.some((selected) => selected.id === document.id),
),
]);
} catch (err) {
console.error("Upload failed:", err);
} finally {
@ -248,23 +220,16 @@ export function NewTRModal({
: [{ value: "", label: "No projects found" }];
// What to show in the directory depends on mode and toggle state
const directoryStandalone = isProjectMode
const directoryDocuments = isProjectMode
? (fixedProjectDocs ?? [])
: underProject
? []
: standaloneDocs;
const directoryFolders = isProjectMode
? []
: underProject
? []
: directoryProjects;
const flatProjectDocs: Document[] =
!isProjectMode && underProject ? projectDocs : [];
? projectDocs
: extraStandaloneDocs;
const directoryLoading = isProjectMode
? false
: underProject
? loadingDocs
: loadingDirectory;
: false;
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
const breadcrumbs =
isProjectMode && projectName
@ -392,7 +357,7 @@ export function NewTRModal({
if (!next) {
setSelectedProjectId("");
setProjectDocs([]);
setSelectedDocIds(new Set());
setSelectedDocuments([]);
}
}}
className="flex w-fit items-center gap-2.5"
@ -430,31 +395,11 @@ export function NewTRModal({
<div className="flex min-h-0 flex-1 flex-col">
{showDirectory && (
<FileDirectory
standaloneDocs={
isProjectMode
? directoryStandalone
: underProject
? flatProjectDocs
: directoryStandalone
}
directoryProjects={
isProjectMode
? []
: underProject
? []
: directoryFolders
}
documents={directoryDocuments}
loading={directoryLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
emptyMessage={
isProjectMode || underProject
? "No ready documents in this project"
: "No documents yet"
}
searchable
searchAutoFocus
showProjectTabs={!isProjectMode && !underProject}
selectedDocuments={selectedDocuments}
onChange={setSelectedDocuments}
showTabs={!isProjectMode && !underProject}
/>
)}
</div>

View file

@ -1,17 +1,19 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
Clock,
MessageSquarePlus,
MoreHorizontal,
Pencil,
Plus,
Search,
Square,
ArrowRight,
ChevronDown,
ChevronLeft,
Trash2,
X,
} from "lucide-react";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import {
@ -19,6 +21,7 @@ import {
getTabularChats,
getTabularChatMessages,
deleteTabularChat,
renameTabularChat,
mapTRMessages,
type TRChat,
type TRCitationAnnotation,
@ -27,6 +30,11 @@ import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
import { ModelToggle } from "../assistant/ModelToggle";
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
import { PreResponseWrapper } from "../assistant/PreResponseWrapper";
import {
DocReadBlock,
EventBlock,
ReasoningBlock,
} from "../assistant/message/EventBlocks";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
getModelProvider,
@ -34,6 +42,11 @@ import {
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
import {
LiquidDropdownButton,
LiquidDropdownSurface,
} from "@/app/components/ui/liquid-dropdown";
import { cn } from "@/app/lib/utils";
// ---------------------------------------------------------------------------
@ -112,165 +125,6 @@ interface Props {
onChatIdChange?: (chatId: string | null) => void;
}
// ---------------------------------------------------------------------------
// Reasoning block
// ---------------------------------------------------------------------------
const THINKING_PHRASES = [
"Thinking...",
"Pondering...",
"Analyzing...",
"Reasoning...",
];
const REASONING_COLLAPSED_MAX_LINES = 6;
const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9;
function ReasoningBlock({
text,
isStreaming,
}: {
text: string;
isStreaming: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const [userToggled, setUserToggled] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const [hasMeasured, setHasMeasured] = useState(false);
const [phraseIdx, setPhraseIdx] = useState(0);
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isStreaming) return;
const interval = setInterval(
() => setPhraseIdx((i) => (i + 1) % THINKING_PHRASES.length),
2000,
);
return () => clearInterval(interval);
}, [isStreaming]);
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24;
const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES;
const nextOverflowing = el.scrollHeight > maxHeight + 2;
setIsOverflowing(nextOverflowing);
setHasMeasured(true);
if (nextOverflowing && !userToggled) setIsOpen(false);
}, [text, userToggled]);
const showContent = isOpen || isStreaming || isOverflowing || !hasMeasured;
const isCollapsed = isOverflowing && !isOpen;
return (
<div className="ml-1">
<button
onClick={() => {
if (isStreaming) return;
setUserToggled(true);
setIsOpen((v) => !v);
}}
className="flex items-center text-sm text-gray-400 hover:text-gray-500 transition-colors"
>
{isStreaming ? (
<div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" />
) : (
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 shrink-0" />
)}
<span className="font-medium ml-2">
{isStreaming
? THINKING_PHRASES[phraseIdx]
: "Thought process"}
</span>
{!isStreaming && (
<ChevronDown
size={10}
className={`ml-1.5 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
/>
)}
</button>
{showContent && (
<div className="mt-1.5 ml-[14px]">
<div
className={`relative ${isCollapsed ? "overflow-hidden" : ""}`}
style={
isCollapsed
? {
maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`,
}
: undefined
}
>
<div
ref={contentRef}
className="text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text}
</ReactMarkdown>
</div>
{isCollapsed && (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" />
<button
type="button"
onClick={() => {
setUserToggled(true);
setIsOpen(true);
}}
className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label="Expand thought process"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
{isOverflowing && isOpen && (
<button
type="button"
onClick={() => {
setUserToggled(true);
setIsOpen(false);
}}
className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600"
aria-label="Minimise thought process"
>
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// DocRead block
// ---------------------------------------------------------------------------
function DocReadBlock({
label,
isStreaming,
}: {
label: string;
isStreaming?: boolean;
}) {
return (
<div className="flex items-center text-sm text-gray-400 ml-1">
{isStreaming ? (
<div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" />
) : (
<div className="w-1.5 h-1.5 rounded-full bg-green-400 shrink-0" />
)}
<span className="font-medium ml-2">
{isStreaming ? "Reading" : "Read"}
</span>
<span className="ml-1 text-gray-500">{label}</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Citation preprocessing (matches AssistantMessage.tsx pattern)
// ---------------------------------------------------------------------------
@ -392,13 +246,23 @@ function TRAssistantMessage({
return false;
};
const renderPreEvent = (event: AssistantEvent, key: number) => {
const renderPreEvent = (
event: AssistantEvent,
index: number,
allEvents: AssistantEvent[],
key: number,
) => {
const nextEvent = allEvents[index + 1];
const showConnector =
nextEvent !== undefined && nextEvent.type !== "content";
if (event.type === "reasoning") {
return (
<ReasoningBlock
key={key}
text={event.text}
isStreaming={!!event.isStreaming && !!msg.isStreaming}
showConnector={showConnector}
/>
);
}
@ -406,20 +270,22 @@ function TRAssistantMessage({
return (
<DocReadBlock
key={key}
label={event.filename}
filename={event.filename}
isStreaming={event.isStreaming}
showConnector={showConnector}
showFileIcon={false}
/>
);
}
if (event.type === "thinking") {
return (
<div
<EventBlock
key={key}
className="flex items-center text-sm text-gray-400 ml-1"
showConnector={showConnector}
isStreaming
>
<div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" />
<span className="ml-2">Thinking...</span>
</div>
<span>Thinking...</span>
</EventBlock>
);
}
return null;
@ -521,7 +387,12 @@ function TRAssistantMessage({
compact
>
{g.events.map((event, i) =>
renderPreEvent(event, g.indices[i]),
renderPreEvent(
event,
i,
g.events,
g.indices[i],
),
)}
</PreResponseWrapper>
);
@ -626,20 +497,20 @@ function TRChatInput({
<div
ref={rootRef}
className={cn(
"absolute bottom-0 left-0 right-0 px-4 pb-3",
"absolute bottom-0 left-0 right-0 z-10 px-3 pb-3",
"bg-transparent",
)}
>
<div
className={cn(
"pt-2 pb-1.5 flex flex-col gap-1",
"rounded-[18px] border border-white/65 bg-white/60 shadow-[0_6px_18px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl",
"rounded-xl border border-white/65 bg-white/60 shadow-[0_4px_10px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl",
)}
>
<textarea
ref={textareaRef}
rows={1}
placeholder="Ask a question about your documents..."
placeholder="How can I help?"
value={value}
onChange={(e) => {
setValue(e.target.value);
@ -653,7 +524,7 @@ function TRChatInput({
}}
className="w-full resize-none text-sm bg-transparent outline-none placeholder:text-gray-400 leading-6 max-h-48 overflow-hidden border-0 p-0 pl-3 pr-2 pt-0.5"
/>
<div className="flex items-center justify-between pl-1 pr-2">
<div className="flex items-center justify-end gap-1.5 pl-1 pr-2">
<ModelToggle
value={model}
onChange={onModelChange}
@ -692,12 +563,23 @@ function HistoryDropdown({
chats,
currentChatId,
onLoad,
onRename,
onDelete,
}: {
chats: TRChat[];
currentChatId: string | null;
onLoad: (chatId: string) => void;
onRename: (chatId: string, title: string) => void;
onDelete: (chatId: string) => void;
}) {
const [query, setQuery] = useState("");
const [menu, setMenu] = useState<{
chatId: string;
top: number;
left: number;
} | null>(null);
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const filtered = chats
.filter((c) => c.id !== currentChatId)
.filter((c) => {
@ -705,9 +587,15 @@ function HistoryDropdown({
return label.toLowerCase().includes(query.toLowerCase());
});
function commitRename(chatId: string) {
const trimmed = renameValue.trim();
setRenamingChatId(null);
if (trimmed) onRename(chatId, trimmed);
}
return (
<>
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-gray-100">
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-white/40">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
autoFocus
@ -718,9 +606,12 @@ function HistoryDropdown({
className="flex-1 text-xs bg-transparent outline-none placeholder:text-gray-400 text-gray-700"
/>
</div>
<div className="max-h-48 overflow-y-auto">
<div
className="max-h-48 overflow-y-auto p-1"
onScroll={() => setMenu(null)}
>
{filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-gray-400">
<p className="px-2 py-1.5 text-xs text-gray-400">
{chats.filter((c) => c.id !== currentChatId).length ===
0
? "No previous chats."
@ -729,14 +620,102 @@ function HistoryDropdown({
) : (
filtered.map((chat) => {
const label = chat.title ?? "Chat";
if (renamingChatId === chat.id) {
return (
<input
key={chat.id}
autoFocus
type="text"
value={renameValue}
onChange={(e) =>
setRenameValue(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter")
commitRename(chat.id);
if (e.key === "Escape")
setRenamingChatId(null);
}}
onBlur={() => commitRename(chat.id)}
className="w-full rounded-lg bg-app-surface-active px-2 py-1.5 text-xs text-gray-700 outline-none"
/>
);
}
return (
<button
<div
key={chat.id}
onClick={() => onLoad(chat.id)}
className="w-full px-3 py-2 text-left text-xs text-gray-700 hover:bg-gray-50 transition-colors truncate"
className="group relative flex items-center"
>
{label}
</button>
<LiquidDropdownButton
onClick={() => onLoad(chat.id)}
className="w-full min-w-0 rounded-lg px-2 py-1.5 pr-7 text-left truncate"
>
{label}
</LiquidDropdownButton>
<button
onClick={(e) => {
e.stopPropagation();
const rect =
e.currentTarget.getBoundingClientRect();
setMenu((v) =>
v?.chatId === chat.id
? null
: {
chatId: chat.id,
top: rect.bottom + 4,
left: rect.right - 112,
},
);
}}
title="Chat options"
className={cn(
"absolute right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-app-surface-hover hover:text-gray-700",
menu?.chatId === chat.id
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{menu?.chatId === chat.id &&
createPortal(
<LiquidDropdownSurface
onMouseDown={(e) =>
e.stopPropagation()
}
className="fixed z-[130] w-28 p-1"
style={{
top: menu.top,
left: menu.left,
}}
>
<LiquidDropdownButton
onClick={() => {
setMenu(null);
setRenameValue(
chat.title ?? "",
);
setRenamingChatId(chat.id);
}}
className="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left"
>
<Pencil className="h-3 w-3" />
Rename
</LiquidDropdownButton>
<LiquidDropdownButton
onClick={() => {
setMenu(null);
onDelete(chat.id);
}}
className="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-red-600 hover:text-red-600 focus:text-red-600"
>
<Trash2 className="h-3 w-3" />
Delete
</LiquidDropdownButton>
</LiquidDropdownSurface>,
document.body,
)}
</div>
);
})
)}
@ -756,6 +735,15 @@ function findLastContentIndex(events: AssistantEvent[]): number {
return -1;
}
// ---------------------------------------------------------------------------
// Header pills (matches PageHeader action group styling)
// ---------------------------------------------------------------------------
const HEADER_PILL_CLASS =
"flex shrink-0 items-center gap-1 rounded-full border border-white/70 bg-app-surface px-1 py-0.5 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl";
const HEADER_PILL_BUTTON_CLASS =
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-app-surface-hover hover:text-gray-900";
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
@ -793,12 +781,20 @@ export function TRChatPanel({
const [isResizing, setIsResizing] = useState(false);
const [inputHeight, setInputHeight] = useState(96);
const resizeStartRef = useRef({ x: 0, width: 380 });
useEffect(() => {
if (!isResizing) return;
const MIN_WIDTH = 280;
const MAX_WIDTH = 800;
function onMove(e: MouseEvent) {
setPanelWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, e.clientX)));
const delta = resizeStartRef.current.x - e.clientX;
setPanelWidth(
Math.min(
MAX_WIDTH,
Math.max(MIN_WIDTH, resizeStartRef.current.width + delta),
),
);
}
function onUp() {
setIsResizing(false);
@ -1084,15 +1080,27 @@ export function TRChatPanel({
setHistoryOpen(false);
}
async function handleDeleteChat() {
if (!currentChatId) return;
const chatIdToDelete = currentChatId;
setChats((prev) => prev.filter((c) => c.id !== chatIdToDelete));
setCurrentChatId(null);
setCurrentChatTitle(null);
setMessages([]);
async function handleDeleteChat(chatId: string) {
setChats((prev) => prev.filter((c) => c.id !== chatId));
if (chatId === currentChatId) {
setCurrentChatId(null);
setCurrentChatTitle(null);
setMessages([]);
}
try {
await deleteTabularChat(reviewId, chatIdToDelete);
await deleteTabularChat(reviewId, chatId);
} catch {
/* ignore */
}
}
async function handleRenameChat(chatId: string, title: string) {
setChats((prev) =>
prev.map((c) => (c.id === chatId ? { ...c, title } : c)),
);
if (chatId === currentChatId) setCurrentChatTitle(title);
try {
await renameTabularChat(reviewId, chatId, title);
} catch {
/* ignore */
}
@ -1751,109 +1759,99 @@ export function TRChatPanel({
return (
<div
style={{ width: panelWidth }}
style={
{
"--tr-chat-panel-width": `${panelWidth}px`,
} as CSSProperties
}
className={cn(
"shrink-0 flex flex-col border-r border-gray-200 h-full relative",
"bg-transparent",
"flex flex-col relative",
// Mobile: replaces the table, filling the row minus margins.
// md+: fixed width beside the table, top-aligned with it
// (below the toolbar).
"flex-1 min-w-0 mx-3 mb-3 md:flex-none md:w-[var(--tr-chat-panel-width)] md:mt-12 md:-ml-4 md:mr-6",
LIQUID_PANEL_SURFACE_CLASS,
"overflow-hidden",
)}
>
{/* Resize handle */}
<div
onMouseDown={(e) => {
e.preventDefault();
resizeStartRef.current = { x: e.clientX, width: panelWidth };
setIsResizing(true);
}}
className={`absolute top-0 right-0 h-full w-1 cursor-col-resize z-20 transition-colors ${
className={`absolute top-0 left-0 h-full w-1 cursor-col-resize z-20 transition-colors hidden md:block ${
isResizing
? "bg-blue-500"
: "bg-transparent hover:bg-blue-500"
? "bg-blue-400/70"
: "bg-transparent hover:bg-blue-400/70"
}`}
/>
{/* Header */}
<div className="flex items-center justify-between h-8 pr-2 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-1 pl-2 pr-2 min-w-0">
<button
onClick={onClose}
title="Close"
className="flex items-center justify-center h-7 w-7 shrink-0 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<div
onMouseEnter={(e) => {
const el = e.currentTarget;
const overflow = el.scrollWidth - el.clientWidth;
if (overflow > 0)
el.scrollTo({
left: overflow,
behavior: "smooth",
});
}}
onMouseLeave={(e) => {
e.currentTarget.scrollTo({
left: 0,
behavior: "smooth",
});
}}
className="min-w-0 overflow-x-hidden whitespace-nowrap scrollbar-none"
>
<span className="text-xs font-medium text-gray-700">
{currentChatTitle ?? "New chat"}
</span>
</div>
</div>
<div className="flex items-center">
<div ref={historyRef} className="relative">
{/* Header — fixed, overlaid on top of the messages */}
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between gap-2 px-2 py-2">
{/* Title pill — opens chat history */}
<div ref={historyRef} className="relative shrink min-w-0">
<div className={cn(HEADER_PILL_CLASS, "min-w-0")}>
<button
onClick={() => setHistoryOpen((v) => !v)}
title="Chat history"
className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-600 hover:text-gray-900"}`}
className="flex h-5 min-w-0 items-center gap-1 rounded-full px-1.5 text-gray-700 transition-colors hover:bg-app-surface-hover"
>
<Clock className="h-3.5 w-3.5" />
<span className="min-w-0 truncate text-xs font-medium">
{currentChatTitle ?? "New chat"}
</span>
<ChevronDown
className={cn(
"h-3 w-3 shrink-0 text-gray-400 transition-transform duration-200",
historyOpen && "rotate-180",
)}
/>
</button>
{historyOpen && (
<div className="absolute top-full right-0 mt-1 w-64 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<HistoryDropdown
chats={chats}
currentChatId={currentChatId}
onLoad={handleLoadChat}
/>
</div>
)}
</div>
<button
onClick={handleNewChat}
title="New chat"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
{currentChatId && (
<button
onClick={handleDeleteChat}
title="Delete chat"
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-red-600 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{historyOpen && (
<LiquidDropdownSurface className="absolute top-full left-0 z-50 mt-2 w-64 overflow-hidden">
<HistoryDropdown
chats={chats}
currentChatId={currentChatId}
onLoad={handleLoadChat}
onRename={handleRenameChat}
onDelete={handleDeleteChat}
/>
</LiquidDropdownSurface>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{/* New chat circle — only once a chat has started */}
{messages.length > 0 && (
<div className={cn(HEADER_PILL_CLASS, "px-0.5")}>
<button
onClick={handleNewChat}
title="New chat"
className={HEADER_PILL_BUTTON_CLASS}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Close circle */}
<div className={cn(HEADER_PILL_CLASS, "px-0.5")}>
<button
onClick={onClose}
title="Close"
className={HEADER_PILL_BUTTON_CLASS}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
{/* Messages */}
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-4 pt-4 flex flex-col"
className="flex-1 overflow-y-auto px-4 pt-12 flex flex-col"
style={{ paddingBottom: Math.ceil(inputHeight + 16) }}
>
{messages.length === 0 && !isLoadingMessages && (
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<MikeIcon size={24} />
<p className="text-gray-400 font-serif text-center">
Ask a question about this tabular review.
</p>
</div>
)}
{isLoadingMessages && (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
@ -1900,6 +1898,12 @@ export function TRChatPanel({
)}
</div>
{/* Top blur overlay — messages fade out under the header */}
<div className="pointer-events-none absolute top-0 left-0 right-2 z-[5] h-10 backdrop-blur-2xl bg-gradient-to-b from-white/80 to-transparent [mask-image:linear-gradient(to_bottom,black_65%,transparent)]" />
{/* Bottom blur overlay — messages fade out under the input */}
<div className="pointer-events-none absolute bottom-0 left-0 right-2 z-[5] h-32 backdrop-blur-2xl bg-gradient-to-t from-white/80 to-transparent [mask-image:linear-gradient(to_top,black_65%,transparent)]" />
{/* Input */}
<TRChatInput
isLoading={isLoading}

View file

@ -9,11 +9,13 @@ import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
import { TAG_COLORS } from "./pillUtils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import {
LiquidDropdownContent,
LiquidDropdownRadioItem,
} from "@/app/components/ui/liquid-dropdown";
import { PillButton } from "@/app/components/ui/pill-button";
// Liquid-glass field styling shared by the menu's inputs/controls, matching the
@ -287,9 +289,9 @@ export function TREditColumnMenu({
<ChevronDown className="h-3 w-3 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
<LiquidDropdownContent
align="start"
className="z-[50] border-white/70 bg-white/75 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"
className="z-[50]"
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
}}
@ -303,17 +305,17 @@ export function TREditColumnMenu({
}}
>
{FORMAT_OPTIONS.map((o) => (
<DropdownMenuRadioItem
<LiquidDropdownRadioItem
key={o.value}
value={o.value}
className="text-xs"
>
<o.icon className="h-3 w-3 text-gray-400" />
{o.label}
</DropdownMenuRadioItem>
</LiquidDropdownRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</LiquidDropdownContent>
</DropdownMenu>
</div>
@ -397,8 +399,9 @@ export function TREditColumnMenu({
>
Delete
</PillButton>
<button
type="button"
<PillButton
tone="black"
size="sm"
onClick={handleSave}
disabled={
saving ||
@ -407,10 +410,10 @@ export function TREditColumnMenu({
!name.trim() ||
!prompt.trim()
}
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"
className="px-3"
>
{saving ? "Saving…" : "Save"}
</button>
</PillButton>
</div>
</div>,
document.body,

View file

@ -24,6 +24,7 @@ import { PdfView } from "../shared/views/PdfView";
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
import { DocxView } from "../shared/views/DocxView";
import { cn } from "@/app/lib/utils";
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
function isDocxDocument(d: {
file_type?: string | null;
@ -120,7 +121,8 @@ export function TRSidePanel({
<div
className={cn(
"fixed z-100 flex flex-row",
"right-3 top-3 bottom-3 overflow-hidden rounded-2xl border border-white/70 bg-white/20 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl",
LIQUID_PANEL_SURFACE_CLASS,
"right-3 top-3 bottom-3 overflow-hidden",
)}
>
{/* Document panel — left, 600px */}

View file

@ -1,7 +1,12 @@
"use client";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { Loader2, Plus, Table2, Upload } from "lucide-react";
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
} from "react";
import { Loader2, Plus, Upload } from "lucide-react";
import type {
ColumnConfig,
Document,
@ -13,13 +18,19 @@ import {
TABLE_CHECKBOX_CLASS,
SkeletonDot,
SkeletonLine,
TableScrollArea,
} from "../shared/TablePrimitive";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
const SKELETON_COLS = 4;
const SKELETON_ROWS = 5;
const COL_W = "w-[300px] shrink-0";
const DOC_COL_W = "w-[332px] shrink-0";
const TR_STICKY_CELL_BG = "bg-app-surface";
const TR_HEADER_BG = "bg-app-surface";
const TR_ACTIVE_ROW_BG = "bg-app-surface-active";
// Pixel widths matching the CSS constants above
const DOC_COL_W_PX = 332;
@ -72,20 +83,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
},
ref,
) {
const stickyCellBg = "bg-[#fafbfc]";
const scrollContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const lastScrollLeftRef = useRef(0);
const [scrollCloseSignal, setScrollCloseSignal] = useState(0);
function syncHeader() {
if (headerRef.current && scrollContainerRef.current) {
headerRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
}
}
function handleRowsScroll() {
syncHeader();
const container = scrollContainerRef.current;
if (!container) return;
@ -157,17 +159,16 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
if (loading) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="shrink-0 overflow-hidden">
<TableScrollArea
header={
<div
className={`flex h-8 ${stickyCellBg}`}
className={`flex h-10 shrink-0 ${TR_HEADER_BG}`}
style={{ minWidth: skeletonContentWidth }}
>
<div
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`}
className={`sticky left-0 z-[80] ${DOC_COL_W} ${TR_STICKY_CELL_BG} flex items-center border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`}
>
<SkeletonDot />
<SkeletonDot className="mr-4" />
<span>Document</span>
</div>
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
@ -180,17 +181,16 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
))}
<div className="flex-1 border-b border-gray-200 min-w-8" />
</div>
</div>
{/* Rows */}
<div className="flex flex-1 flex-col overflow-auto min-h-0">
}
>
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
<div
key={row}
className={`flex h-10 ${row % 2 === 0 ? stickyCellBg : "bg-gray-50"}`}
className="flex h-10"
style={{ minWidth: skeletonContentWidth }}
>
<div className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2`}>
<SkeletonDot />
<div className={`sticky left-0 z-[60] ${DOC_COL_W} ${TR_STICKY_CELL_BG} flex items-center border-b border-r border-gray-200 py-2 pl-4 pr-2`}>
<SkeletonDot className="mr-4" />
<SkeletonLine className="h-4 w-32" />
</div>
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
@ -204,8 +204,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
<div className="flex-1 border-b border-gray-200 min-w-8" />
</div>
))}
</div>
</div>
</TableScrollArea>
);
}
@ -215,21 +214,24 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
uploadingFilenames.length === 0
) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className={`shrink-0 flex items-center border-b border-gray-200 ${stickyCellBg}`}>
<div
className={`${DOC_COL_W} border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`}
>
Document
<TableScrollArea
header={
<div className={`shrink-0 flex h-10 items-center border-b border-gray-200 ${TR_HEADER_BG}`}>
<div
className={`${DOC_COL_W} ${TR_STICKY_CELL_BG} flex items-center border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`}
>
Document
</div>
<div className="flex-1" />
</div>
<div className="flex-1" />
</div>
}
>
<div className="relative flex min-h-0 flex-1">
{dragOverFiles && (
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
)}
<div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto">
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
<TabularReviewSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Tabular Review
</p>
@ -237,36 +239,42 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
Add columns and documents to get started.
</p>
<div className="mt-4 flex items-center gap-2">
<button
<PillButton
tone="black"
size="sm"
onClick={onAddColumn}
className="inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 shadow-md"
className="px-3"
>
+ Add Columns
</button>
<button
<Plus className="h-3.5 w-3.5" />
Add Columns
</PillButton>
<PillButton
tone="white"
size="sm"
onClick={onAddDocuments}
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors shadow-sm"
className="px-3"
>
<Upload className="h-3.5 w-3.5" />
Add Documents
</button>
</PillButton>
</div>
</div>
</div>
</div>
</TableScrollArea>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div ref={headerRef} className="shrink-0 overflow-hidden">
<TableScrollArea
scrollRef={scrollContainerRef}
onScroll={handleRowsScroll}
header={
<div
className={`flex h-8 ${stickyCellBg}`}
className={`z-[70] flex h-10 shrink-0 ${TR_HEADER_BG}`}
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[80] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`}
className={`sticky left-0 z-[80] ${DOC_COL_W} ${TR_STICKY_CELL_BG} border-b border-r border-gray-200 flex items-center py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`}
>
<input
type="checkbox"
@ -283,9 +291,9 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
<div
key={col.index}
data-tr-col-header
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="truncate">{col.name}</span>
<TREditColumnMenu
column={col}
@ -307,33 +315,27 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
</button>
</div>
</div>
</div>
{/* Rows */}
<div
className="flex flex-1 flex-col overflow-auto min-h-0"
ref={scrollContainerRef}
onScroll={handleRowsScroll}
>
<div className="relative min-h-0 flex-1">
{dragOverFiles && (
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
)}
{uploadingFilenames.map((filename) => (
}
>
<div className="relative min-h-0 flex-1">
{dragOverFiles && (
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
)}
{uploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="flex h-10"
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[60] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-400 flex items-center gap-4`}
className={`sticky left-0 z-[60] ${DOC_COL_W} ${TR_STICKY_CELL_BG} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-400 flex items-center`}
>
<input
type="checkbox"
disabled
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
className="mr-4 h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
/>
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin shrink-0" />
<span className="line-clamp-1" title={filename}>
{filename}
</span>
@ -348,13 +350,14 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
))}
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
</div>
))}
{documents.map((doc, docIdx) => {
const baseRowBg =
docIdx % 2 === 0 ? stickyCellBg : "bg-gray-50";
))}
{documents.map((doc, docIdx) => {
const rowBg = selectedDocIds.includes(doc.id)
? "bg-gray-100"
: baseRowBg;
? TR_ACTIVE_ROW_BG
: "bg-transparent";
const stickyRowBg = selectedDocIds.includes(doc.id)
? TR_ACTIVE_ROW_BG
: TR_STICKY_CELL_BG;
return (
<div
key={doc.id}
@ -362,7 +365,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center gap-4 ${rowBg}`}
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center ${stickyRowBg}`}
>
<input
type="checkbox"
@ -414,9 +417,8 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
</div>
);
})}
</div>
</div>
</div>
})}
</div>
</TableScrollArea>
);
});

View file

@ -26,7 +26,7 @@ const FLAG_STYLES = {
function TabularCellSkeleton() {
return (
<div className="flex h-10 items-center px-2">
<div className="flex h-8 items-center px-2">
<SkeletonLine className="h-3.5 w-full" />
</div>
);
@ -193,14 +193,14 @@ export function TabularCell({
if (cell.status === "error") {
return (
<div className="h-10 flex items-center justify-center text-gray-300">
<div className="h-8 flex items-center justify-center text-gray-300">
<AlertCircle className="h-4 w-4 text-red-300" />
</div>
);
}
if (!cell.content?.summary) {
return <div className="h-10" />;
return <div className="h-8" />;
}
const { processed, citations, pills } = preprocessCellMarkdown(
@ -224,7 +224,7 @@ export function TabularCell({
<div ref={containerRef} className="relative">
{/* Normal cell row — always visible, preserves table layout */}
<div
className="group relative h-10 px-2 flex items-center text-xs text-gray-800 leading-relaxed cursor-pointer hover:bg-gray-50 transition-colors"
className="group relative h-8 px-2 flex items-center text-xs text-gray-800 leading-relaxed cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setInlineExpanded((v) => !v)}
>
{cell.content.flag && (

View file

@ -8,6 +8,7 @@ import {
Play,
ChevronDown,
MessageSquare,
MessageSquareX,
Download,
Users,
Upload,
@ -62,6 +63,7 @@ import { exportTabularReviewToExcel } from "./exportToExcel";
import { useSidebar } from "@/app/contexts/SidebarContext";
import { PageHeader } from "../shared/PageHeader";
import { TableToolbar } from "../shared/TableToolbar";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
interface Props {
reviewId: string;
@ -105,16 +107,17 @@ export function TRView({ reviewId, projectId }: Props) {
string[]
>([]);
const searchParams = useSearchParams();
const initialChatParamRef = useRef<string | null>(
searchParams.get("chat"),
);
const initialChatParamRef = useRef<string | null>(searchParams.get("chat"));
const [chatOpen, setChatOpen] = useState(!!initialChatParamRef.current);
const [selectedChatId, setSelectedChatId] = useState<string | null>(
initialChatParamRef.current && initialChatParamRef.current !== "new"
? initialChatParamRef.current
: null,
);
const [highlightedCell, setHighlightedCell] = useState<{ colIdx: number; rowIdx: number } | null>(null);
const [highlightedCell, setHighlightedCell] = useState<{
colIdx: number;
rowIdx: number;
} | null>(null);
const [apiKeyModalProvider, setApiKeyModalProvider] =
useState<ModelProvider | null>(null);
const actionsRef = useRef<HTMLDivElement>(null);
@ -512,9 +515,7 @@ export function TRView({ reviewId, projectId }: Props) {
if (idsToDelete.length === 0) return;
const previousDocuments = documents;
const previousCells = cells;
const remaining = documents.filter(
(d) => !idsToDelete.includes(d.id),
);
const remaining = documents.filter((d) => !idsToDelete.includes(d.id));
setDocuments(remaining);
setCells((prev) =>
prev.filter((c) => !idsToDelete.includes(c.document_id)),
@ -553,7 +554,9 @@ export function TRView({ reviewId, projectId }: Props) {
}
async function handleClearAllResults() {
await clearResultsForDocuments(documents.map((document) => document.id));
await clearResultsForDocuments(
documents.map((document) => document.id),
);
}
function requestReviewDetails() {
@ -672,7 +675,6 @@ export function TRView({ reviewId, projectId }: Props) {
{/* Header */}
<PageHeader
shrink
className="gap-4"
breadcrumbs={[
...(projectId
? [
@ -702,7 +704,8 @@ export function TRView({ reviewId, projectId }: Props) {
: [
{
label: "Tabular Reviews",
onClick: () => router.push("/tabular-reviews"),
onClick: () =>
router.push("/tabular-reviews"),
title: "Back to Tabular Reviews",
},
]),
@ -784,29 +787,20 @@ export function TRView({ reviewId, projectId }: Props) {
{
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" />
),
onClick: () => setAddDocsOpen(true),
disabled: loading || savingColumnsConfig,
title: "Add documents",
icon: <Upload className="h-4 w-4" />,
label: (
<span className="hidden sm:inline">
Assistant
Documents
</span>
),
},
],
},
{
actions: [
{
onClick: handleGenerate,
disabled:
@ -827,87 +821,177 @@ export function TRView({ reviewId, projectId }: Props) {
},
],
},
{
actions: [
{
onClick: () => {
if (!chatOpen) setSidebarOpen(false);
if (chatOpen) setSelectedChatId(null);
setChatOpen((v) => !v);
},
disabled:
loading ||
columns.length === 0 ||
documents.length === 0,
title: chatOpen
? "Close chat"
: "Open chat",
icon: chatOpen ? (
<MessageSquareX className="h-4 w-4" />
) : (
<MessageSquare className="h-4 w-4" />
),
label: (
<span className="hidden sm:inline">
Chat
</span>
),
},
],
},
]}
/>
{/* Toolbar */}
<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
{/* Toolbar + table column, chat panel beside it */}
<div className="flex flex-1 overflow-hidden">
{/* On mobile the chat panel replaces the table entirely */}
<div
className={`flex flex-1 flex-col overflow-hidden ${
chatOpen ? "max-md:hidden" : ""
}`}
>
<TableToolbar
items={[]}
active="table"
onChange={() => undefined}
actions={
<div className="flex items-center gap-1.5">
{loading ? (
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
) : null}
{!loading && selectedDocIds.length > 0 && (
<>
{/* Desktop: compact Actions menu */}
<div
ref={actionsRef}
className="relative max-md:hidden"
>
<TabPillButton
onClick={() =>
setActionsOpen(
(v) => !v,
)
}
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</TabPillButton>
{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>
{/* Mobile (toolbar dropdown): flattened entries */}
<TabPillButton
onClick={handleClearResults}
className="w-full px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-gray-50 transition-colors"
className="md:hidden"
>
Clear results
</button>
<button
</TabPillButton>
<TabPillButton
onClick={handleDeleteDocuments}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
className="md:hidden text-red-600"
>
Delete
</button>
</div>
</TabPillButton>
</>
)}
{!loading && (
<TabPillButton
onClick={() => setAddColOpen(true)}
disabled={
savingColumn ||
savingColumnsConfig
}
>
<Plus className="h-3.5 w-3.5" />
Add Columns
</TabPillButton>
)}
</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
className="relative flex flex-1 overflow-hidden"
onDragOver={(e) => {
if (!hasFilePayload(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDragOverReviewFiles(true);
}}
onDragLeave={(e) => {
if (
!e.currentTarget.contains(
e.relatedTarget as Node,
)
) {
setDragOverReviewFiles(false);
}
}}
onDrop={(e) => {
if (!hasFilePayload(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
setDragOverReviewFiles(false);
void handleDropReviewFiles(
Array.from(e.dataTransfer.files),
);
}}
>
<TRTable
ref={tableRef}
loading={loading}
columns={columns}
documents={filteredDocuments}
cells={cells}
highlightedCell={highlightedCell}
savingColumn={savingColumn}
savingColumnsConfig={savingColumnsConfig}
selectedDocIds={selectedDocIds}
uploadingFilenames={uploadingDroppedFilenames}
dragOverFiles={dragOverReviewFiles}
onSelectionChange={setSelectedDocIds}
onExpand={(cell) => {
setExpandedCell(cell);
setExpandedCellCitation(undefined);
}}
onCitationClick={(cell, page, quote) => {
setExpandedCell(cell);
setExpandedCellCitation({ quote, page });
}}
onUpdateColumn={handleUpdateColumn}
onDeleteColumn={handleDeleteColumn}
onAddColumn={() => setAddColOpen(true)}
onAddDocuments={() => setAddDocsOpen(true)}
/>
</div>
}
/>
{/* Table area */}
<div className="flex flex-1 overflow-hidden">
</div>
{chatOpen && (
<TRChatPanel
reviewId={reviewId}
@ -924,60 +1008,6 @@ export function TRView({ reviewId, projectId }: Props) {
onChatIdChange={setSelectedChatId}
/>
)}
<div
className="relative flex flex-1 overflow-hidden"
onDragOver={(e) => {
if (!hasFilePayload(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDragOverReviewFiles(true);
}}
onDragLeave={(e) => {
if (
!e.currentTarget.contains(
e.relatedTarget as Node,
)
) {
setDragOverReviewFiles(false);
}
}}
onDrop={(e) => {
if (!hasFilePayload(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
setDragOverReviewFiles(false);
void handleDropReviewFiles(
Array.from(e.dataTransfer.files),
);
}}
>
<TRTable
ref={tableRef}
loading={loading}
columns={columns}
documents={filteredDocuments}
cells={cells}
highlightedCell={highlightedCell}
savingColumn={savingColumn}
savingColumnsConfig={savingColumnsConfig}
selectedDocIds={selectedDocIds}
uploadingFilenames={uploadingDroppedFilenames}
dragOverFiles={dragOverReviewFiles}
onSelectionChange={setSelectedDocIds}
onExpand={(cell) => {
setExpandedCell(cell);
setExpandedCellCitation(undefined);
}}
onCitationClick={(cell, page, quote) => {
setExpandedCell(cell);
setExpandedCellCitation({ quote, page });
}}
onUpdateColumn={handleUpdateColumn}
onDeleteColumn={handleDeleteColumn}
onAddColumn={() => setAddColOpen(true)}
onAddDocuments={() => setAddDocsOpen(true)}
/>
</div>
</div>
</div>
@ -1037,9 +1067,7 @@ export function TRView({ reviewId, projectId }: Props) {
<AddProjectDocsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={(docs: Document[]) =>
handleAddDocuments(docs)
}
onSelect={(docs: Document[]) => handleAddDocuments(docs)}
breadcrumb={[
"Projects",
project.name +
@ -1057,9 +1085,7 @@ export function TRView({ reviewId, projectId }: Props) {
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={(docs: Document[]) =>
handleAddDocuments(docs)
}
onSelect={(docs: Document[]) => handleAddDocuments(docs)}
breadcrumb={[
"Tabular Reviews",
...(review ? [review.title || "Untitled Review"] : []),
@ -1097,7 +1123,9 @@ export function TRView({ reviewId, projectId }: Props) {
: async (next) => {
const updated = await updateTabularReview(
reviewId,
{ shared_with: next },
{
shared_with: next,
},
);
setReview((prev) =>
prev

View file

@ -0,0 +1,78 @@
"use client";
import * as React from "react";
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioItem,
} from "@/app/components/ui/dropdown-menu";
import { cn } from "@/app/lib/utils";
const LIQUID_DROPDOWN_CLASS =
"rounded-2xl border border-white/70 bg-app-surface 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";
const LIQUID_DROPDOWN_ITEM_CLASS =
"cursor-pointer text-xs text-gray-600 transition-colors hover:bg-app-surface-hover focus:bg-app-surface-hover focus:text-gray-800";
export function LiquidDropdownContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
className={cn(LIQUID_DROPDOWN_CLASS, className)}
{...props}
/>
);
}
export const LiquidDropdownSurface = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>(function LiquidDropdownSurface({ className, ...props }, ref) {
return (
<div
ref={ref}
className={cn(LIQUID_DROPDOWN_CLASS, className)}
{...props}
/>
);
});
export function LiquidDropdownItem({
className,
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
className={cn(LIQUID_DROPDOWN_ITEM_CLASS, className)}
{...props}
/>
);
}
export const LiquidDropdownButton = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<"button">
>(function LiquidDropdownButton({ className, type = "button", ...props }, ref) {
return (
<button
ref={ref}
type={type}
className={cn(LIQUID_DROPDOWN_ITEM_CLASS, className)}
{...props}
/>
);
});
export function LiquidDropdownRadioItem({
className,
...props
}: React.ComponentProps<typeof DropdownMenuRadioItem>) {
return (
<DropdownMenuRadioItem
className={cn(LIQUID_DROPDOWN_ITEM_CLASS, className)}
{...props}
/>
);
}

View file

@ -0,0 +1,5 @@
export const LIQUID_TABLE_SURFACE_CLASS =
"rounded-2xl border-y border-white/70 bg-app-surface shadow-[0_10px_30px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
export const LIQUID_PANEL_SURFACE_CLASS =
"rounded-2xl border border-white/70 bg-white/20 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl";

View file

@ -14,10 +14,10 @@ type PillButtonProps = React.ComponentProps<"button"> & {
};
const toneClasses: Record<PillButtonTone, string> = {
black: "border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.12),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 disabled:hover:bg-gray-950/88",
white: "border-transparent bg-transparent text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.10),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(255,255,255,0.7)] hover:bg-gray-100 disabled:hover:bg-transparent",
blue: "border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 disabled:hover:bg-blue-600/90",
danger: "border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.14),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 disabled:hover:bg-red-600/90",
black: "border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.10),inset_1px_1px_0_rgba(255,255,255,0.22),inset_-1px_-1px_0_rgba(255,255,255,0.10),inset_-4px_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 disabled:hover:bg-gray-950/88",
white: "border-transparent bg-white text-gray-700 shadow-[0_2px_5px_rgba(0,0,0,0.14),inset_1px_1px_0_rgba(255,255,255,0.9),inset_-1px_-1px_0_rgba(255,255,255,0.68)] hover:bg-gray-100 disabled:hover:bg-white",
blue: "border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_1px_1px_0_rgba(255,255,255,0.28),inset_-1px_-1px_0_rgba(255,255,255,0.14),inset_-4px_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 disabled:hover:bg-blue-600/90",
danger: "border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.10),inset_1px_1px_0_rgba(255,255,255,0.22),inset_-1px_-1px_0_rgba(255,255,255,0.12),inset_-4px_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 disabled:hover:bg-red-600/90",
};
const sizeClasses: Record<PillButtonSize, string> = {

View file

@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { cn } from "@/app/lib/utils";
type TabPillButtonProps = React.ComponentProps<"button"> & {
active?: boolean;
};
export function TabPillButton({
active,
type = "button",
className,
...props
}: TabPillButtonProps) {
const stateClass =
active === true
? "border-white/80 bg-white text-gray-900"
: active === false
? "border-white/60 bg-white/45 text-gray-400 hover:bg-white/65 hover:text-gray-700"
: "border-white/70 bg-white/65 text-gray-700 hover:bg-white hover:text-gray-900";
return (
<button
type={type}
aria-pressed={active}
className={cn(
"inline-flex h-7 items-center justify-center gap-1.5 rounded-full border px-3 text-xs font-medium shadow-[0_3px_9px_rgba(15,23,42,0.05),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-all active:scale-[0.98] disabled:cursor-default disabled:opacity-40 disabled:active:scale-100",
stateClass,
className,
)}
{...props}
/>
);
}

View file

@ -48,19 +48,16 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
new Set(),
);
const [selectedDocuments, setSelectedDocuments] = useState<Document[]>([]);
const [assistantPrompt, setAssistantPrompt] = useState("");
const [saving, setSaving] = useState(false);
const router = useRouter();
const { saveChat, setNewChatMessages } = useChatHistoryContext();
const {
loading: dirLoading,
projects,
standaloneDocuments,
} = useDirectoryData(screen !== "select");
const { loading: dirLoading, projects } = useDirectoryData(
screen === "details",
"projects",
);
useEffect(() => {
if (workflow) {
@ -83,7 +80,7 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
function resetConfigureState() {
setInProject(false);
setSelectedProjectId(null);
setSelectedDocIds(new Set());
setSelectedDocuments([]);
setAssistantPrompt("");
}
@ -106,16 +103,10 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
const projectId = inProject ? selectedProjectId! : undefined;
const chatId = await saveChat(projectId);
if (!chatId) return;
const allDocs: Document[] = [
...standaloneDocuments,
...projects.flatMap((p) => p.documents || []),
];
const files = allDocs
.filter((d) => selectedDocIds.has(d.id))
.map((d) => ({
filename: d.filename,
document_id: d.id,
}));
const files = selectedDocuments.map((document) => ({
filename: document.filename,
document_id: document.id,
}));
const content = assistantPrompt.trim()
? `implement workflow\n${assistantPrompt.trim()}`
: "implement workflow";
@ -139,13 +130,7 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
}
async function handleCreateReview() {
const allDocs: Document[] = [
...standaloneDocuments,
...projects.flatMap((p) => p.documents || []),
];
const docIds = allDocs
.filter((d) => selectedDocIds.has(d.id))
.map((d) => d.id);
const docIds = selectedDocuments.map((document) => document.id);
const projectId = inProject ? selectedProjectId! : undefined;
setSaving(true);
@ -240,13 +225,6 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
disabled: saving,
}
}
footerStatus={
screen === "documents" && selectedDocIds.size > 0 ? (
<span className="text-xs text-gray-400">
{selectedDocIds.size} selected
</span>
) : null
}
primaryAction={
screen === "select"
? {
@ -272,7 +250,7 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
onClick: handleCreateReview,
disabled:
saving ||
selectedDocIds.size === 0 ||
selectedDocuments.length === 0 ||
(inProject && !selectedProjectId),
}
}
@ -308,7 +286,7 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
onChange={(value) => {
setInProject(value === "project");
setSelectedProjectId(null);
setSelectedDocIds(new Set());
setSelectedDocuments([]);
}}
options={locationOptions}
/>
@ -325,7 +303,7 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
options={projectOptions}
onChange={(value) => {
setSelectedProjectId(value || null);
setSelectedDocIds(new Set());
setSelectedDocuments([]);
}}
placeholder={
dirLoading
@ -364,25 +342,10 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">
<FileDirectory
standaloneDocs={
inProject ? projectDocs : standaloneDocuments
}
directoryProjects={
inProject ? [] : projects
}
loading={dirLoading}
selectedIds={selectedDocIds}
onChange={setSelectedDocIds}
allowMultiple
forceExpanded={inProject}
emptyMessage={
inProject
? "No documents in this project"
: "No documents yet"
}
searchable
searchAutoFocus
showProjectTabs={!inProject}
documents={inProject ? projectDocs : undefined}
selectedDocuments={selectedDocuments}
onChange={setSelectedDocuments}
showTabs={!inProject}
/>
</div>
</div>

View file

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import dynamic from "next/dynamic";
import {
Check,
@ -46,7 +46,9 @@ import {
import { PeopleModal } from "@/app/components/modals/PeopleModal";
import { OpenSourceWorkflowModal } from "@/app/components/workflows/OpenSourceWorkflowModal";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { PillButton } from "@/app/components/ui/pill-button";
import { NewWorkflowModal } from "@/app/components/workflows/NewWorkflowModal";
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { downloadWorkflowZip } from "./workflowZipExport";
@ -98,6 +100,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
// Editor state
const [promptMd, setPromptMd] = useState("");
const [columns, setColumns] = useState<ColumnConfig[]>([]);
const searchParams = useSearchParams();
const previewEmptyStates = searchParams.get("emptyStates") === "1";
const visibleColumns = previewEmptyStates ? [] : columns;
// Save status
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
@ -550,7 +555,8 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{/* Toolbar */}
{!readOnly && (
<div className="flex items-center justify-between h-10 shrink-0 border-b border-gray-200 px-4 md:px-10">
{selectedColIndices.length > 0 && (
{visibleColumns.length > 0 &&
selectedColIndices.length > 0 && (
<div ref={colActionsRef} className="relative">
<button
onClick={() => setColActionsOpen((v) => !v)}
@ -579,7 +585,8 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
)}
</div>
)}
{selectedColIndices.length === 0 && (
{(visibleColumns.length === 0 ||
selectedColIndices.length === 0) && (
<span aria-hidden="true" />
)}
<button
@ -604,12 +611,12 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{/* Table header */}
<div className={`flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none ${readOnly ? "border-t" : ""}`}>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
{columns.length > 0 ? (
{visibleColumns.length > 0 ? (
<input
type="checkbox"
checked={columns.length > 0 && selectedColIndices.length === columns.length}
ref={(el) => { if (el) el.indeterminate = selectedColIndices.length > 0 && selectedColIndices.length < columns.length; }}
onChange={() => setSelectedColIndices(selectedColIndices.length === columns.length ? [] : columns.map((c) => c.index))}
checked={selectedColIndices.length === visibleColumns.length}
ref={(el) => { if (el) el.indeterminate = selectedColIndices.length > 0 && selectedColIndices.length < visibleColumns.length; }}
onChange={() => setSelectedColIndices(selectedColIndices.length === visibleColumns.length ? [] : visibleColumns.map((c) => c.index))}
className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`}
/>
) : (
@ -627,9 +634,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{/* Rows */}
<div className="flex-1">
{columns.length === 0 ? (
{visibleColumns.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
<Plus className="h-8 w-8 text-gray-300 mb-4" />
<TabularReviewSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Columns
</p>
@ -637,16 +644,19 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
Add columns to define what this tabular review workflow extracts from each document.
</p>
{!readOnly && (
<button
<PillButton
tone="black"
size="sm"
onClick={() => setAddColumnOpen(true)}
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"
className="mt-4 px-3"
>
+ Add Column
</button>
<Plus className="h-3.5 w-3.5" />
Add Column
</PillButton>
)}
</div>
) : (
columns.map((col) => {
visibleColumns.map((col) => {
const FormatIcon = formatIcon(col.format ?? "text");
const isChecked = selectedColIndices.includes(col.index);
return (

View file

@ -1,11 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import {
Library,
Table2,
MessageSquare,
Plus,
User,
ChevronDown,
} from "lucide-react";
@ -23,12 +21,18 @@ import { TableToolbar } from "../shared/TableToolbar";
import { RowActionMenuItems, RowActions } from "../shared/RowActions";
import { MikeIcon } from "@/app/components/chat/mike-icon";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { workflowDetailPath } from "./workflowRoutes";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import {
GLASS_DROPDOWN,
GLASS_MENU_ITEM,
HeaderFilterDropdown,
} from "../shared/HeaderFilterDropdown";
LiquidDropdownButton,
LiquidDropdownSurface,
} from "@/app/components/ui/liquid-dropdown";
import {
ChatSkeuoIcon,
TabularReviewSkeuoIcon,
WorkflowSkeuoIcon,
} from "@/app/components/shared/AppSidebarSkeuoIcons";
import { workflowDetailPath } from "./workflowRoutes";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
@ -39,20 +43,29 @@ import {
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TableFilters,
type TableFilterOption,
TablePrimaryCell,
TableRow,
TableScrollArea,
type TableSortDirection,
TableStickyCell,
} from "../shared/TablePrimitive";
type WorkflowScope = "all" | "system" | "user" | "shared";
type WorkflowSourceFilter = "system" | "user" | "shared";
type WorkflowListTab = "all" | "assistant" | "tabular" | "system";
type WorkflowSortKey = "name" | "type";
const WORKFLOW_SCOPES: { id: WorkflowScope; label: string }[] = [
const WORKFLOW_TABS: { id: WorkflowListTab; label: string }[] = [
{ id: "all", label: "All" },
{ id: "user", label: "User" },
{ id: "shared", label: "Shared with me" },
{ id: "assistant", label: "Assistant" },
{ id: "tabular", label: "Tabular" },
{ id: "system", label: "System" },
];
const SORT_OPTIONS: TableFilterOption<TableSortDirection>[] = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
@ -61,10 +74,10 @@ const devLog = (...args: Parameters<typeof console.log>) => {
export function WorkflowList() {
const router = useRouter();
const searchParams = useSearchParams();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Workflow | null>(null);
const [activeScope, setActiveScope] = useState<WorkflowScope>("all");
const [newModalOpen, setNewModalOpen] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(
null,
@ -72,12 +85,23 @@ export function WorkflowList() {
const [hiddenSystemIds, setHiddenSystemIds] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<WorkflowListTab>("all");
const [practiceFilter, setPracticeFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<Workflow["metadata"]["type"] | null>(
const [jurisdictionFilter, setJurisdictionFilter] = useState<string | null>(
null,
);
const [languageFilter, setLanguageFilter] = useState<string | null>(null);
const [sourceFilter, setSourceFilter] =
useState<WorkflowSourceFilter | null>(null);
const [sort, setSort] = useState<{
key: WorkflowSortKey;
direction: TableSortDirection;
} | null>(null);
const [search, setSearch] = useState("");
const actionsRef = useRef<HTMLDivElement>(null);
const previewEmptyStates = searchParams.get("emptyStates") === "1";
const effectiveLoading = loading && !previewEmptyStates;
const visibleWorkflows = previewEmptyStates ? [] : workflows;
useEffect(() => {
Promise.all([
@ -130,11 +154,11 @@ export function WorkflowList() {
return () => document.removeEventListener("mousedown", handleClick);
}, [actionsOpen]);
const systemWorkflows = workflows.filter((wf) => wf.is_system);
const userWorkflows = workflows.filter(
const systemWorkflows = visibleWorkflows.filter((wf) => wf.is_system);
const userWorkflows = visibleWorkflows.filter(
(wf) => !wf.is_system && wf.is_owner !== false,
);
const sharedWorkflows = workflows.filter(
const sharedWorkflows = visibleWorkflows.filter(
(wf) => !wf.is_system && wf.is_owner === false,
);
const hiddenSystem = systemWorkflows.filter((wf) =>
@ -146,24 +170,48 @@ export function WorkflowList() {
const systemRows = [...visibleSystem, ...hiddenSystem];
const activeRows = [...userWorkflows, ...sharedWorkflows, ...visibleSystem];
const allRows = [...userWorkflows, ...sharedWorkflows, ...systemRows];
const byScope =
activeScope === "all"
const tabRows =
activeTab === "all"
? activeRows
: activeScope === "system"
? systemRows
: activeScope === "user"
? userWorkflows
: sharedWorkflows;
: activeTab === "system"
? systemRows
: activeRows.filter((workflow) => workflow.metadata.type === activeTab);
const sourceRows =
sourceFilter === null
? tabRows
: tabRows.filter(
(workflow) => getWorkflowSource(workflow) === sourceFilter,
);
const practices = Array.from(
new Set(
byScope.map((wf) => wf.metadata.practice).filter((p): p is string => !!p),
sourceRows.map((wf) => wf.metadata.practice).filter((p): p is string => !!p),
),
).sort();
const jurisdictions = Array.from(
new Set(
allRows
.flatMap((wf) => wf.metadata.jurisdictions ?? [])
.filter((jurisdiction): jurisdiction is string => !!jurisdiction),
),
).sort();
const languages = Array.from(
new Set(
allRows
.map((wf) => wf.metadata.language)
.filter((language): language is string => !!language),
),
).sort();
const q = search.toLowerCase();
const filtered = byScope
const filtered = sourceRows
.filter((wf) => !practiceFilter || wf.metadata.practice === practiceFilter)
.filter((wf) => !typeFilter || wf.metadata.type === typeFilter)
.filter((wf) => !q || wf.metadata.title.toLowerCase().includes(q));
.filter(
(wf) =>
!jurisdictionFilter ||
wf.metadata.jurisdictions?.includes(jurisdictionFilter),
)
.filter((wf) => !languageFilter || wf.metadata.language === languageFilter)
.filter((wf) => !q || wf.metadata.title.toLowerCase().includes(q))
.sort((a, b) => compareWorkflows(a, b, sort));
const allSelected =
filtered.length > 0 &&
@ -187,13 +235,8 @@ export function WorkflowList() {
setActionsOpen(false);
}
function handleScopeChange(scope: WorkflowScope) {
setActiveScope(scope);
clearSelection();
}
function handleTypeFilterChange(value: Workflow["metadata"]["type"] | null) {
setTypeFilter(value);
function handleTabChange(tab: WorkflowListTab) {
setActiveTab(tab);
clearSelection();
}
@ -202,6 +245,29 @@ export function WorkflowList() {
clearSelection();
}
function handleJurisdictionFilterChange(value: string | null) {
setJurisdictionFilter(value);
clearSelection();
}
function handleLanguageFilterChange(value: string | null) {
setLanguageFilter(value);
clearSelection();
}
function handleSourceFilterChange(value: WorkflowSourceFilter | null) {
setSourceFilter(value);
clearSelection();
}
function handleSortChange(
key: WorkflowSortKey,
direction: TableSortDirection | null,
) {
setSort(direction ? { key, direction } : null);
clearSelection();
}
async function handleHideWorkflow(id: string) {
setHiddenSystemIds((prev) => [...prev, id]);
await hideWorkflow(id).catch(() => {
@ -253,34 +319,39 @@ export function WorkflowList() {
const getTypeMeta = (type: Workflow["metadata"]["type"]) =>
type === "tabular"
? { label: "Tabular", Icon: Table2, className: "text-violet-700" }
? { label: "Tabular", Icon: TabularReviewSkeuoIcon }
: {
label: "Assistant",
Icon: MessageSquare,
className: "text-blue-700",
Icon: ChatSkeuoIcon,
};
const typeFilterButton = (
<HeaderFilterDropdown
label="Filter by type"
value={typeFilter}
allLabel="All Types"
const nameSortDirection =
sort?.key === "name" ? sort.direction : null;
const typeSortDirection =
sort?.key === "type" ? sort.direction : null;
const nameFilterButton = (
<TableFilters
label="Sort by name"
value={nameSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={(["assistant", "tabular"] as const).map((type) => {
const { label, Icon, className } = getTypeMeta(type);
return {
value: type,
label,
icon: Icon,
className,
};
})}
onChange={handleTypeFilterChange}
align="right"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("name", direction)}
/>
);
const typeFilterButton = (
<TableFilters
label="Sort by type"
value={typeSortDirection}
allLabel="Default Order"
widthClassName="w-40"
options={SORT_OPTIONS}
onChange={(direction) => handleSortChange("type", direction)}
/>
);
const practiceFilterButton = (
<HeaderFilterDropdown
<TableFilters
label="Filter by practice"
value={practiceFilter}
allLabel="All Practices"
@ -292,6 +363,50 @@ export function WorkflowList() {
/>
);
const jurisdictionFilterButton = (
<TableFilters
label="Filter by jurisdiction"
value={jurisdictionFilter}
allLabel="All Jurisdictions"
widthClassName="w-48"
options={jurisdictions.map((jurisdiction) => ({
value: jurisdiction,
label: jurisdiction,
}))}
onChange={handleJurisdictionFilterChange}
/>
);
const languageFilterButton = (
<TableFilters
label="Filter by language"
value={languageFilter}
allLabel="All Languages"
widthClassName="w-44"
options={languages.map((language) => ({
value: language,
label: language,
}))}
onChange={handleLanguageFilterChange}
/>
);
const sourceOptions: TableFilterOption<WorkflowSourceFilter>[] = [
{ value: "system", label: "System" },
{ value: "user", label: "User" },
{ value: "shared", label: "Shared with me" },
];
const sourceFilterButton = (
<TableFilters
label="Filter by source"
value={sourceFilter}
allLabel="All Sources"
widthClassName="w-44"
options={sourceOptions}
onChange={handleSourceFilterChange}
/>
);
const selectedHiddenSystemIds = selectedIds.filter((id) =>
hiddenSystemIds.includes(id),
);
@ -307,22 +422,21 @@ export function WorkflowList() {
const toolbarActions =
selectedIds.length > 0 ? (
<div ref={actionsRef} className="relative">
<button
<TabPillButton
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>
</TabPillButton>
{actionsOpen && (
<div className={`absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden ${GLASS_DROPDOWN}`}>
<LiquidDropdownSurface className="absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden">
{selectedOnlyHiddenSystem ? (
<button
<LiquidDropdownButton
onClick={handleBulkUnhide}
className={`w-full px-3 py-1.5 text-left text-xs text-gray-700 ${GLASS_MENU_ITEM}`}
className="w-full px-3 py-1.5 text-left text-gray-700"
>
Activate
</button>
</LiquidDropdownButton>
) : (
<button
onClick={handleBulkRemove}
@ -331,7 +445,7 @@ export function WorkflowList() {
{selectedOnlySystem ? "Deactivate" : "Delete"}
</button>
)}
</div>
</LiquidDropdownSurface>
)}
</div>
) : undefined;
@ -362,9 +476,9 @@ export function WorkflowList() {
</PageHeader>
<TableToolbar
items={WORKFLOW_SCOPES}
active={activeScope}
onChange={handleScopeChange}
items={WORKFLOW_TABS}
active={activeTab}
onChange={handleTabChange}
actions={toolbarActions}
/>
@ -373,8 +487,8 @@ export function WorkflowList() {
header={
<TableHeaderRow>
<TableStickyCell header>
{loading ? (
<SkeletonDot />
{effectiveLoading ? (
<SkeletonDot className="mr-4" />
) : (
<input
type="checkbox"
@ -386,29 +500,45 @@ export function WorkflowList() {
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
<span className="mr-1">Name</span>
{!loading && nameFilterButton}
</TableStickyCell>
<TableHeaderCell className="ml-auto w-28">
<div className="flex items-center gap-1">
<span>Type</span>
{typeFilterButton}
{!loading && typeFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-40">
<div className="flex items-center gap-1">
<span>Practice</span>
{practiceFilterButton}
{!loading && practiceFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-40">
<div className="flex items-center gap-1">
<span>Jurisdiction</span>
{!loading && jurisdictionFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-28">
<div className="flex items-center gap-1">
<span>Language</span>
{!loading && languageFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-44">
<div className="flex items-center gap-1">
<span>Source</span>
{!loading && sourceFilterButton}
</div>
</TableHeaderCell>
<TableHeaderCell className="w-40">Jurisdiction</TableHeaderCell>
<TableHeaderCell className="w-28">Language</TableHeaderCell>
<TableHeaderCell className="w-44">Source</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
}
>
{loading ? (
{effectiveLoading ? (
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow
@ -418,8 +548,8 @@ export function WorkflowList() {
<TableStickyCell
hover={false}
>
<div className="flex items-center gap-4">
<SkeletonDot />
<div className="flex items-center">
<SkeletonDot className="mr-4" />
<SkeletonLine className="h-3.5 w-48" />
</div>
</TableStickyCell>
@ -447,9 +577,9 @@ export function WorkflowList() {
</TableBody>
) : filtered.length === 0 ? (
<TableEmptyState>
{activeScope === "user" ? (
{sourceFilter === "user" ? (
<>
<Library className="h-8 w-8 text-gray-300 mb-4" />
<WorkflowSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
User Workflows
</p>
@ -458,16 +588,19 @@ export function WorkflowList() {
review templates tailored to your
practice.
</p>
<button
<PillButton
tone="black"
size="sm"
onClick={() => setNewModalOpen(true)}
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"
className="mt-4 px-3"
>
+ Create New
</button>
<Plus className="h-3.5 w-3.5" />
Create
</PillButton>
</>
) : activeScope === "shared" ? (
) : sourceFilter === "shared" ? (
<>
<Library className="h-8 w-8 text-gray-300 mb-4" />
<WorkflowSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Shared Workflows
</p>
@ -478,7 +611,7 @@ export function WorkflowList() {
</>
) : (
<>
<Library className="h-8 w-8 text-gray-300 mb-4" />
<WorkflowSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Workflows
</p>
@ -503,9 +636,10 @@ export function WorkflowList() {
rightClickDropdown={
wf.is_system
? isHiddenSystem
? (close) => (
? (close, menuProps) => (
<RowActionMenuItems
onClose={close}
surfaceProps={menuProps}
onUnhide={() =>
handleUnhideWorkflow(
wf.id,
@ -513,9 +647,10 @@ export function WorkflowList() {
}
/>
)
: (close) => (
: (close, menuProps) => (
<RowActionMenuItems
onClose={close}
surfaceProps={menuProps}
onHide={() =>
handleHideWorkflow(
wf.id,
@ -525,9 +660,10 @@ export function WorkflowList() {
)
: wf.is_owner === false
? undefined
: (close) => (
: (close, menuProps) => (
<RowActionMenuItems
onClose={close}
surfaceProps={menuProps}
onEditDetails={() =>
setEditingWorkflow(wf)
}
@ -556,13 +692,12 @@ export function WorkflowList() {
/>
<TableCell className="ml-auto w-28">
{(() => {
const { label, Icon, className } =
getTypeMeta(wf.metadata.type);
const { label, Icon } = getTypeMeta(
wf.metadata.type,
);
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-700">
<Icon
className={`h-3.5 w-3.5 ${className}`}
/>
<Icon className="h-4 w-4 shrink-0" />
{label}
</span>
);
@ -708,6 +843,35 @@ function getSharedByLabel(workflow: Workflow) {
return workflow.shared_by_name?.trim() || "Shared";
}
function getWorkflowSource(workflow: Workflow): WorkflowSourceFilter {
if (workflow.is_system) return "system";
return workflow.is_owner === false ? "shared" : "user";
}
function compareWorkflows(
a: Workflow,
b: Workflow,
sort: { key: WorkflowSortKey; direction: TableSortDirection } | null,
) {
if (!sort) return 0;
const direction = sort.direction === "asc" ? 1 : -1;
const aValue =
sort.key === "name"
? a.metadata.title
: a.metadata.type === "tabular"
? "Tabular"
: "Assistant";
const bValue =
sort.key === "name"
? b.metadata.title
: b.metadata.type === "tabular"
? "Tabular"
: "Assistant";
return aValue.localeCompare(bValue) * direction;
}
// Liquid-glass treatment shared by every practice dot: a top inset highlight
// and bottom inset shadow give it depth, plus a slight drop shadow so the bead
// lifts off the row. The color class is appended per practice.

View file

@ -3,9 +3,26 @@
@custom-variant dark (&:is(.dark *));
@layer utilities {
.white-liquid-glass {
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.65);
box-shadow:
0 -1px 6px rgba(15, 23, 42, 0.034),
0 4px 9px rgba(15, 23, 42, 0.074),
inset 0 1px 0 rgba(255, 255, 255, 0.85);
backdrop-filter: blur(40px);
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-app-background: var(--app-background);
--color-app-surface: var(--app-surface);
--color-app-surface-hover: var(--app-surface-hover);
--color-app-surface-active: var(--app-surface-active);
--color-app-floating: var(--app-floating);
--font-sans: var(--font-inter);
--font-serif: var(--font-eb-garamond);
--color-blue: rgb(0, 136, 255);
@ -52,6 +69,11 @@
:root {
--radius: 0.625rem;
--color-azure: 0, 136, 255;
--app-background: #f9fafb;
--app-surface: #fdfdfe;
--app-surface-hover: #f9fafb;
--app-surface-active: #eff0f3;
--app-floating: #fefefe;
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);

View file

@ -79,8 +79,8 @@ export function useFetchSingleDoc(
if (!cancelled) setResult({ type: "spreadsheet", buffer });
} else {
// Drain the body so the connection is reusable, but the
// bytes are useless to the PDF viewer — the caller will
// fall back to DocxView, which fetches `/docx` itself.
// bytes are useless to PDF/spreadsheet viewers. Callers
// should route DOC/DOCX files to DocxView directly.
await response.arrayBuffer().catch(() => {});
if (!cancelled) setResult({ type: "docx" });
}

View file

@ -11,6 +11,7 @@ import type {
Citation,
Document,
Folder,
LibraryFolder,
Message,
OpenSourceWorkflowContributorMode,
OpenSourceWorkflowResponse,
@ -551,6 +552,110 @@ export async function renameProjectDocument(
);
}
export type LibraryKind = "files" | "templates";
export interface LibraryCollection {
documents: Document[];
folders: LibraryFolder[];
}
export async function getLibrary(
kind: LibraryKind,
): Promise<LibraryCollection> {
return apiRequest<LibraryCollection>(`/library/${kind}`);
}
export async function uploadLibraryDocument(
kind: LibraryKind,
file: File,
): Promise<Document> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE}/library/${kind}/documents`, {
method: "POST",
headers: { ...authHeaders },
body: form,
});
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<Document>;
}
export async function createLibraryFolder(
kind: LibraryKind,
name: string,
parentFolderId?: string | null,
): Promise<LibraryFolder> {
return apiRequest<LibraryFolder>(`/library/${kind}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
parent_folder_id: parentFolderId ?? null,
}),
});
}
export async function renameLibraryFolder(
kind: LibraryKind,
folderId: string,
name: string,
): Promise<LibraryFolder> {
return apiRequest<LibraryFolder>(`/library/${kind}/folders/${folderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
}
export async function deleteLibraryFolder(
kind: LibraryKind,
folderId: string,
): Promise<void> {
await apiRequest(`/library/${kind}/folders/${folderId}`, {
method: "DELETE",
});
}
export async function moveLibraryFolder(
kind: LibraryKind,
folderId: string,
parentFolderId: string | null,
): Promise<LibraryFolder> {
return apiRequest<LibraryFolder>(`/library/${kind}/folders/${folderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_folder_id: parentFolderId }),
});
}
export async function moveLibraryDocument(
kind: LibraryKind,
documentId: string,
folderId: string | null,
): Promise<Document> {
return apiRequest<Document>(
`/library/${kind}/documents/${documentId}/folder`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder_id: folderId }),
},
);
}
export async function renameLibraryDocument(
kind: LibraryKind,
documentId: string,
filename: string,
): Promise<Document> {
return apiRequest<Document>(`/library/${kind}/documents/${documentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
}
export async function addDocumentToProject(
projectId: string,
documentId: string,
@ -1142,6 +1247,18 @@ export async function deleteTabularChat(
});
}
export async function renameTabularChat(
reviewId: string,
chatId: string,
title: string,
): Promise<void> {
await apiRequest(`/tabular-review/${reviewId}/chats/${chatId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
}
export async function regenerateTabularCell(
reviewId: string,
documentId: string,

View file

@ -1,4 +1,5 @@
import Link from "next/link";
import { PillButton } from "@/app/components/ui/pill-button";
export default function NotFound() {
return (
@ -12,12 +13,9 @@ export default function NotFound() {
have been moved.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-medium text-white bg-gray-900 hover:bg-gray-700 transition-colors"
>
Go home
</Link>
<PillButton asChild tone="black" size="normal">
<Link href="/">Go home</Link>
</PillButton>
</div>
</div>
);

View file

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
import { SiteLogo } from "@/app/components/site-logo";
import { Button } from "@/app/components/ui/button";
import { PillButton } from "@/app/components/ui/pill-button";
import { useAuth } from "@/app/contexts/AuthContext";
import { supabase } from "@/app/lib/supabase";
import {
@ -187,11 +187,12 @@ export default function VerifyMfaPage() {
>
Cancel
</button>
<Button
<PillButton
tone="black"
size="normal"
type="button"
onClick={() => void verify()}
disabled={!canVerify}
className="inline-flex items-center justify-center gap-1.5 rounded-full border border-gray-700/40 bg-gray-950/88 px-4 py-1.5 text-sm font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-all hover:bg-gray-900/90 hover:text-white active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
>
{verifying ? (
<span className="inline-flex items-center gap-1.5">
@ -201,7 +202,7 @@ export default function VerifyMfaPage() {
) : (
"Verify"
)}
</Button>
</PillButton>
</div>
</div>
</div>