Update document UI, tabular reviews, and storage caching

This commit is contained in:
willchen96 2026-05-18 00:21:40 +08:00
parent 2bbb628891
commit 4f3384334a
26 changed files with 856 additions and 341 deletions

View file

@ -20,6 +20,12 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
- why - why
- testing - testing
## Security
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
We will aim to respond promptly and coordinate a disclosure timeline with you.
## Local Development ## Local Development
Backend: Backend:

View file

@ -283,6 +283,7 @@ create table if not exists public.tabular_reviews (
user_id text not null, user_id text not null,
title text, title text,
columns_config jsonb, columns_config jsonb,
document_ids jsonb,
workflow_id uuid references public.workflows(id) on delete set null, workflow_id uuid references public.workflows(id) on delete set null,
practice text, practice text,
shared_with jsonb not null default '[]'::jsonb, shared_with jsonb not null default '[]'::jsonb,

View file

@ -1,4 +1,3 @@
import { promisify } from "util";
import JSZip from "jszip"; import JSZip from "jszip";
let _convert: let _convert:
@ -8,7 +7,26 @@ let _convert:
async function getConvert() { async function getConvert() {
if (!_convert) { if (!_convert) {
const libre = await import("libreoffice-convert"); const libre = await import("libreoffice-convert");
_convert = promisify(libre.default.convert.bind(libre.default)); const convert = libre.default.convert.bind(libre.default) as (
buf: Buffer,
ext: string,
filter: undefined,
callback?: (err: Error | null, result: Buffer) => void,
) => Promise<Buffer> | void;
_convert = (buf, ext, filter) =>
new Promise<Buffer>((resolve, reject) => {
try {
const maybePromise = convert(buf, ext, filter, (err, result) => {
if (err) reject(err);
else resolve(result);
});
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.then(resolve, reject);
}
} catch (err) {
reject(err);
}
});
} }
return _convert; return _convert;
} }

View file

@ -17,16 +17,21 @@ import {
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
let cachedClient: S3Client | undefined;
function getClient(): S3Client { function getClient(): S3Client {
return new S3Client({ if (!cachedClient) {
region: "auto", cachedClient = new S3Client({
endpoint: process.env.R2_ENDPOINT_URL!, region: "auto",
forcePathStyle: true, endpoint: process.env.R2_ENDPOINT_URL!,
credentials: { forcePathStyle: true,
accessKeyId: process.env.R2_ACCESS_KEY_ID!, credentials: {
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, accessKeyId: process.env.R2_ACCESS_KEY_ID!,
}, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
}); },
});
}
return cachedClient;
} }
const BUCKET = process.env.R2_BUCKET_NAME ?? "mike"; const BUCKET = process.env.R2_BUCKET_NAME ?? "mike";
@ -37,6 +42,14 @@ export const storageEnabled = Boolean(
process.env.R2_SECRET_ACCESS_KEY, process.env.R2_SECRET_ACCESS_KEY,
); );
function requireStorageConfig(): void {
if (!storageEnabled) {
throw new Error(
"R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set",
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Upload // Upload
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -46,6 +59,7 @@ export async function uploadFile(
content: ArrayBuffer, content: ArrayBuffer,
contentType: string, contentType: string,
): Promise<void> { ): Promise<void> {
requireStorageConfig();
const client = getClient(); const client = getClient();
await client.send( await client.send(
new PutObjectCommand({ new PutObjectCommand({

View file

@ -141,6 +141,10 @@ async function getAccessibleChat(
chatRouter.get("/", requireAuth, async (req, res) => { chatRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string; const userId = res.locals.userId as string;
const db = createServerSupabase(); const db = createServerSupabase();
const requestedLimit = Number.parseInt(String(req.query.limit ?? ""), 10);
const limit = Number.isFinite(requestedLimit)
? Math.min(Math.max(requestedLimit, 1), 100)
: null;
const { data: ownProjects, error: projErr } = await db const { data: ownProjects, error: projErr } = await db
.from("projects") .from("projects")
@ -156,11 +160,15 @@ chatRouter.get("/", requireAuth, async (req, res) => {
? `user_id.eq.${userId},project_id.in.(${ownProjectIds.join(",")})` ? `user_id.eq.${userId},project_id.in.(${ownProjectIds.join(",")})`
: `user_id.eq.${userId}`; : `user_id.eq.${userId}`;
const { data, error } = await db let query = db
.from("chats") .from("chats")
.select("*") .select("*")
.or(filter) .or(filter)
.order("created_at", { ascending: false }); .order("created_at", { ascending: false });
if (limit) query = query.limit(limit);
const { data, error } = await query;
if (error) return void res.status(500).json({ detail: error.message }); if (error) return void res.status(500).json({ detail: error.message });
res.json(data ?? []); res.json(data ?? []);
}); });

View file

@ -165,6 +165,15 @@ tabularRouter.get("/", requireAuth, async (req, res) => {
// Fetch distinct document counts per review // Fetch distinct document counts per review
const reviewIds = reviews.map((r) => (r as { id: string }).id); const reviewIds = reviews.map((r) => (r as { id: string }).id);
let docCounts: Record<string, number> = {}; let docCounts: Record<string, number> = {};
const reviewsWithExplicitDocs = new Set<string>();
for (const review of reviews) {
const id = (review as { id: string }).id;
if (Array.isArray(review.document_ids)) {
const explicitDocIds = review.document_ids;
reviewsWithExplicitDocs.add(id);
docCounts[id] = new Set(explicitDocIds).size;
}
}
if (reviewIds.length > 0) { if (reviewIds.length > 0) {
const { data: cells } = await db const { data: cells } = await db
.from("tabular_cells") .from("tabular_cells")
@ -176,8 +185,10 @@ tabularRouter.get("/", requireAuth, async (req, res) => {
const key = `${cell.review_id}:${cell.document_id}`; const key = `${cell.review_id}:${cell.document_id}`;
if (!seen.has(key)) { if (!seen.has(key)) {
seen.add(key); seen.add(key);
docCounts[cell.review_id] = if (!reviewsWithExplicitDocs.has(cell.review_id)) {
(docCounts[cell.review_id] ?? 0) + 1; docCounts[cell.review_id] =
(docCounts[cell.review_id] ?? 0) + 1;
}
} }
} }
} }
@ -229,6 +240,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => {
user_id: userId, user_id: userId,
title: title ?? null, title: title ?? null,
columns_config, columns_config,
document_ids: allowedDocumentIds,
project_id: project_id ?? null, project_id: project_id ?? null,
workflow_id: workflow_id ?? null, workflow_id: workflow_id ?? null,
}) })
@ -345,17 +357,19 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => {
.from("tabular_cells") .from("tabular_cells")
.select("*") .select("*")
.eq("review_id", reviewId); .eq("review_id", reviewId);
const docIds = [...new Set((cells ?? []).map((c) => c.document_id))]; const cellDocIds = [...new Set((cells ?? []).map((c) => c.document_id))];
const hasExplicitDocIds = Array.isArray(review.document_ids);
const explicitDocIds = hasExplicitDocIds
? (review.document_ids as string[])
: [];
const docIds =
hasExplicitDocIds
? explicitDocIds
: cellDocIds;
const docsResult = const docsResult =
docIds.length > 0 docIds.length > 0
? await db.from("documents").select("*").in("id", docIds) ? await db.from("documents").select("*").in("id", docIds)
: review.project_id : { data: [] as Record<string, unknown>[] };
? await db
.from("documents")
.select("*")
.eq("project_id", review.project_id)
.order("created_at", { ascending: true })
: { data: [] as Record<string, unknown>[] };
res.json({ res.json({
review: { ...review, is_owner: access.isOwner }, review: { ...review, is_owner: access.isOwner },
@ -517,6 +531,7 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
detail: updateError?.message ?? "Failed to update review", detail: updateError?.message ?? "Failed to update review",
}); });
let persistedDocumentIds: string[] | undefined;
if ( if (
Array.isArray(req.body.columns_config) || Array.isArray(req.body.columns_config) ||
Array.isArray(req.body.document_ids) Array.isArray(req.body.document_ids)
@ -577,13 +592,21 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
(existingCells ?? []).map((cell) => cell.document_id), (existingCells ?? []).map((cell) => cell.document_id),
), ),
]; ];
if (documentIds.length === 0 && existingReview.project_id) { }
const { data: projectDocs } = await db
.from("documents") if (Array.isArray(req.body.document_ids)) {
.select("id") persistedDocumentIds = documentIds;
.eq("project_id", existingReview.project_id); const { error: documentIdsError } = await db
documentIds = (projectDocs ?? []).map((doc) => doc.id); .from("tabular_reviews")
} .update({
document_ids: documentIds,
updated_at: new Date().toISOString(),
})
.eq("id", reviewId);
if (documentIdsError)
return void res.status(500).json({
detail: documentIdsError.message,
});
} }
const activeColumns = Array.isArray(req.body.columns_config) const activeColumns = Array.isArray(req.body.columns_config)
@ -614,7 +637,10 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
} }
} }
res.json(updatedReview); res.json({
...updatedReview,
...(persistedDocumentIds ? { document_ids: persistedDocumentIds } : {}),
});
}); });
// DELETE /tabular-review/:reviewId // DELETE /tabular-review/:reviewId

View file

@ -2,7 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Menu } from "lucide-react"; import { PanelLeft } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext"; import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
import { SidebarContext } from "@/app/contexts/SidebarContext"; import { SidebarContext } from "@/app/contexts/SidebarContext";
@ -77,7 +77,12 @@ export default function MikeLayout({
return ( return (
<ChatHistoryProvider> <ChatHistoryProvider>
<SidebarContext.Provider <SidebarContext.Provider
value={{ setSidebarOpen: (open) => { setIsSidebarOpen(open); setIsSidebarOpenDesktop(open); } }} value={{
setSidebarOpen: (open) => {
setIsSidebarOpen(open);
setIsSidebarOpenDesktop(open);
},
}}
> >
<div className="h-dvh bg-white flex flex-col"> <div className="h-dvh bg-white flex flex-col">
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
@ -87,12 +92,14 @@ export default function MikeLayout({
/> />
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full"> <div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
{/* Mobile header */} {/* Mobile header */}
<div className="flex md:hidden items-center gap-3 px-4 py-3 border-b border-gray-100 shrink-0"> <div className="flex md:hidden items-center gap-3 px-4 pt-3 pb-1 shrink-0">
<button <button
onClick={handleSidebarToggle} onClick={handleSidebarToggle}
className="flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100 text-gray-500 transition-colors" className="flex h-8 w-8 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"
title="Open sidebar"
aria-label="Open sidebar"
> >
<Menu className="h-5 w-5" /> <PanelLeft className="h-4 w-4" />
</button> </button>
</div> </div>
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full"> <main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">

View file

@ -84,7 +84,7 @@ function isDocxTab(filename: string) {
return ext === "docx" || ext === "doc"; return ext === "docx" || ext === "doc";
} }
const ICON_SIZE = 30; const ICON_SIZE = 28;
const GAP = 14; const GAP = 14;
const EXPLORER_MIN = 160; const EXPLORER_MIN = 160;
const EXPLORER_DEFAULT = 280; const EXPLORER_DEFAULT = 280;
@ -92,17 +92,18 @@ const CHAT_MIN = 320;
const CHAT_DEFAULT = 420; const CHAT_DEFAULT = 420;
function AssistantGreeting({ username }: { username: string }) { function AssistantGreeting({ username }: { username: string }) {
const { profile } = useUserProfile();
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [iconOffset, setIconOffset] = useState(0); const [iconOffset, setIconOffset] = useState(0);
const [textOffset, setTextOffset] = useState(0); const [textOffset, setTextOffset] = useState(0);
const textRef = useRef<HTMLHeadingElement>(null); const textRef = useRef<HTMLHeadingElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!textRef.current) return; if (!profile || !textRef.current) return;
const h1Width = textRef.current.offsetWidth; const h1Width = textRef.current.offsetWidth;
setIconOffset((h1Width + GAP) / 2); setIconOffset((h1Width + GAP) / 2);
setTextOffset((ICON_SIZE + GAP) / 2); setTextOffset((ICON_SIZE + GAP) / 2);
}, [username]); }, [profile]);
useEffect(() => { useEffect(() => {
if (!iconOffset) return; if (!iconOffset) return;
@ -112,7 +113,7 @@ function AssistantGreeting({ username }: { username: string }) {
return ( return (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="relative flex items-center justify-center h-[30px]"> <div className="relative flex items-center justify-center h-[28px]">
<div <div
className="absolute h-[30px]" className="absolute h-[30px]"
style={{ style={{
@ -128,7 +129,7 @@ function AssistantGreeting({ username }: { username: string }) {
</div> </div>
<h1 <h1
ref={textRef} ref={textRef}
className="absolute text-2xl font-serif font-light text-gray-900 whitespace-nowrap" className="absolute text-3xl font-serif font-light text-gray-900 whitespace-nowrap"
style={{ style={{
left: "50%", left: "50%",
transform: loaded transform: loaded
@ -309,9 +310,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
`created=${created.sort().join(",")}`, `created=${created.sort().join(",")}`,
`replicated=${replicated.sort().join(",")}`, `replicated=${replicated.sort().join(",")}`,
`edited=${Object.entries(editedPerDoc) `edited=${Object.entries(editedPerDoc)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.sort() .sort()
.join(",")}`, .join(",")}`,
].join("|"); ].join("|");
}, [messages]); }, [messages]);
@ -1007,8 +1008,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
<div <div
key={tab.documentId} key={tab.documentId}
ref={(el) => { ref={(el) => {
tabItemRefs.current[tab.documentId] = tabItemRefs.current[
el; tab.documentId
] = el;
}} }}
onClick={() => onClick={() =>
switchTab(tab.documentId) switchTab(tab.documentId)

View file

@ -24,7 +24,7 @@ const CHECK_W = "w-8 shrink-0";
const NAME_COL_W = "w-[300px] shrink-0"; const NAME_COL_W = "w-[300px] shrink-0";
const TABS: { id: Tab; label: string }[] = [ const TABS: { id: Tab; label: string }[] = [
{ id: "all", label: "All Reviews" }, { id: "all", label: "All" },
{ id: "in-project", label: "In Project" }, { id: "in-project", label: "In Project" },
{ id: "standalone", label: "Standalone" }, { id: "standalone", label: "Standalone" },
]; ];
@ -239,7 +239,7 @@ export default function TabularReviewsPage() {
); );
const toolbarActions = ( const toolbarActions = (
<div className="flex items-center gap-2"> <>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<div ref={actionsRef} className="relative"> <div ref={actionsRef} className="relative">
<button <button
@ -262,13 +262,13 @@ export default function TabularReviewsPage() {
</div> </div>
)} )}
{projectFilterButton} {projectFilterButton}
</div> </>
); );
return ( return (
<div className="flex-1 overflow-y-auto bg-white"> <div className="flex-1 overflow-y-auto bg-white">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between px-8 py-4"> <div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10">
<h1 className="text-2xl font-medium font-serif text-gray-900"> <h1 className="text-2xl font-medium font-serif text-gray-900">
Tabular Reviews Tabular Reviews
</h1> </h1>
@ -298,7 +298,7 @@ export default function TabularReviewsPage() {
{/* Table */} {/* Table */}
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<div className="min-w-max"> <div className="min-w-max">
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none"> <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> <div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
{!loading && ( {!loading && (
<input <input
@ -327,7 +327,7 @@ export default function TabularReviewsPage() {
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50" className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
> >
<div className="w-8 shrink-0" /> <div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4"> <div className="flex-1 min-w-0 pl-3 pr-4">
@ -395,7 +395,7 @@ export default function TabularReviewsPage() {
: `/tabular-reviews/${review.id}`, : `/tabular-reviews/${review.id}`,
); );
}} }}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
> >
<div <div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`} className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
@ -412,7 +412,7 @@ export default function TabularReviewsPage() {
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/> />
</div> </div>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}> <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
{renamingId === review.id ? ( {renamingId === review.id ? (
<input <input
autoFocus autoFocus

View file

@ -238,19 +238,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
)} )}
/> />
)} )}
{onProjectsClick && (
<button
type="button"
onClick={onProjectsClick}
aria-label="Open projects"
className="flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors"
>
<FolderOpen className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
Projects
</span>
</button>
)}
{!hideWorkflowButton && ( {!hideWorkflowButton && (
<button <button
type="button" type="button"
@ -268,6 +255,19 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
</span> </span>
</button> </button>
)} )}
{onProjectsClick && (
<button
type="button"
onClick={onProjectsClick}
aria-label="Open projects"
className="flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors"
>
<FolderOpen className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
Projects
</span>
</button>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View file

@ -118,11 +118,7 @@ export function ProjectAssistantTab({
/> />
</div> </div>
<div <div
className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${ className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
selectedChatIds.includes(chat.id)
? "bg-gray-50"
: "bg-white"
} group-hover:bg-gray-50`}
> >
{renamingChatId === chat.id ? ( {renamingChatId === chat.id ? (
<input <input

View file

@ -33,6 +33,7 @@ import {
renameProjectDocument, renameProjectDocument,
listDocumentVersions, listDocumentVersions,
uploadDocumentVersion, uploadDocumentVersion,
uploadProjectDocument,
renameDocumentVersion, renameDocumentVersion,
getProjectPeople, getProjectPeople,
type MikeDocumentVersion, type MikeDocumentVersion,
@ -50,7 +51,10 @@ import {
RowActionMenuItems, RowActionMenuItems,
RowActions, RowActions,
} from "@/app/components/shared/RowActions"; } from "@/app/components/shared/RowActions";
import { AddDocumentsModal } from "@/app/components/shared/AddDocumentsModal"; import {
AddDocumentsModal,
invalidateDirectoryCache,
} from "@/app/components/shared/AddDocumentsModal";
import { PeopleModal } from "@/app/components/shared/PeopleModal"; import { PeopleModal } from "@/app/components/shared/PeopleModal";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal"; import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
@ -266,6 +270,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const newFolderInputRef = useRef<HTMLDivElement | null>(null); const newFolderInputRef = useRef<HTMLDivElement | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null); const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
const [dragOverRoot, setDragOverRoot] = useState(false); const [dragOverRoot, setDragOverRoot] = useState(false);
const [dragOverFileRoot, setDragOverFileRoot] = useState(false);
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
string[]
>([]);
// Actions dropdown // Actions dropdown
const [actionsOpen, setActionsOpen] = useState(false); const [actionsOpen, setActionsOpen] = useState(false);
@ -332,6 +340,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
function handleDragEnd() { function handleDragEnd() {
setDragOverFolderId(null); setDragOverFolderId(null);
setDragOverRoot(false); setDragOverRoot(false);
setDragOverFileRoot(false);
} }
document.addEventListener("dragend", handleDragEnd); document.addEventListener("dragend", handleDragEnd);
return () => document.removeEventListener("dragend", handleDragEnd); return () => document.removeEventListener("dragend", handleDragEnd);
@ -707,6 +716,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
); );
} }
function hasFilePayload(dt: DataTransfer): boolean {
return Array.from(dt.types).includes("Files");
}
async function handleDropProjectFiles(files: File[]) {
if (files.length === 0) return;
setUploadingDroppedFilenames(files.map((file) => file.name));
try {
const uploaded = await Promise.all(
files.map((file) => uploadProjectDocument(projectId, file)),
);
invalidateDirectoryCache();
handleDocsSelected(uploaded);
} catch (err) {
console.error("Project document drop upload failed", err);
} finally {
setUploadingDroppedFilenames([]);
}
}
async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) { async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) {
if (!hasMovePayload(dt)) return; if (!hasMovePayload(dt)) return;
const docId = dt.getData("application/mike-doc"); const docId = dt.getData("application/mike-doc");
@ -778,6 +807,47 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
); );
} }
function renderUploadingDocumentRows(depth: number) {
return uploadingDroppedFilenames.map((filename) => (
<div
key={`uploading-doc-${filename}`}
className="group flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`}
style={treeControlCellStyle(depth)}
>
<input
type="checkbox"
disabled
className="h-2.5 w-2.5 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
/>
</div>
<div
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
<span className="text-sm text-gray-400 truncate">
{filename}
</span>
</div>
</div>
<div className="ml-auto w-20 shrink-0 text-xs text-gray-300 uppercase truncate">
{filename.includes(".") ? filename.split(".").pop() : "file"}
</div>
<div className="w-24 shrink-0 text-sm text-gray-300">
Uploading
</div>
<div className="w-20 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-8 shrink-0" />
</div>
));
}
function renderLevel(parentId: string | null, depth: number) { function renderLevel(parentId: string | null, depth: number) {
const childFolders = folders const childFolders = folders
.filter((f) => f.parent_folder_id === parentId) .filter((f) => f.parent_folder_id === parentId)
@ -786,6 +856,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
return ( return (
<> <>
{parentId === null && renderUploadingDocumentRows(depth)}
{/* Files first */} {/* Files first */}
{childDocs.map((doc) => { {childDocs.map((doc) => {
const isProcessing = doc.status === "pending" || doc.status === "processing"; const isProcessing = doc.status === "pending" || doc.status === "processing";
@ -848,7 +919,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/> />
</div> </div>
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}> <div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isProcessing ? ( {isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
@ -1150,20 +1221,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
) : null; ) : null;
const toolbarActions = ( const toolbarActions = (
<div className="flex items-center gap-2"> <div className="flex items-center gap-5">
{actionsDropdown} {actionsDropdown}
{tab === "documents" && ( {tab === "documents" && (
<> <>
<button <button
onClick={() => { setCreatingFolderIn(null); setNewFolderName(""); }} onClick={() => { setCreatingFolderIn(null); setNewFolderName(""); }}
className="flex items-center gap-1 text-xs px-3 font-medium text-gray-500 hover:text-gray-700 transition-colors" className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
> >
<FolderPlus className="h-3.5 w-3.5" /> <FolderPlus className="h-3.5 w-3.5" />
Add Subfolder Add Subfolder
</button> </button>
<button <button
onClick={() => setAddDocsOpen(true)} onClick={() => setAddDocsOpen(true)}
className="flex items-center gap-1 text-xs px-3 font-medium text-gray-500 hover:text-gray-700 transition-colors" className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
> >
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
Add Documents Add Documents
@ -1239,13 +1310,42 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
</div> </div>
{/* Blue ring wraps everything below the header when root-dropping */} {/* Blue ring wraps everything below the header when root-dropping */}
<div className="flex-1 flex flex-col min-h-0 relative"> <div
className="flex-1 flex flex-col min-h-0 relative"
onDragOver={(e) => {
if (!hasFilePayload(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDragOverFileRoot(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragOverFileRoot(false);
}
}}
onDrop={(e) => {
if (!hasFilePayload(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
setDragOverFileRoot(false);
setDragOverRoot(false);
setDragOverFolderId(null);
void handleDropProjectFiles(
Array.from(e.dataTransfer.files),
);
}}
>
{dragOverRoot && dragOverFolderId === null && ( {dragOverRoot && dragOverFolderId === null && (
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-[80]" /> <div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-[80]" />
)} )}
{dragOverFileRoot && (
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
)}
{/* Empty state */} {/* Empty state */}
{docs.length === 0 && folders.length === 0 ? ( {docs.length === 0 &&
folders.length === 0 &&
uploadingDroppedFilenames.length === 0 ? (
<div <div
onClick={() => setAddDocsOpen(true)} onClick={() => setAddDocsOpen(true)}
className="flex-1 flex cursor-pointer flex-col items-center justify-center py-24 text-center" className="flex-1 flex cursor-pointer flex-col items-center justify-center py-24 text-center"
@ -1282,15 +1382,17 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
> >
{/* Search: flat list; no search: folder tree */} {/* Search: flat list; no search: folder tree */}
{q ? ( {q ? (
filteredDocs.map((doc) => { <>
const isProcessing = doc.status === "pending" || doc.status === "processing"; {renderUploadingDocumentRows(0)}
const isError = doc.status === "error"; {filteredDocs.map((doc) => {
const isVersionsOpen = expandedVersionDocIds.has(doc.id); const isProcessing = doc.status === "pending" || doc.status === "processing";
const hasVersions = const isError = doc.status === "error";
typeof doc.latest_version_number === "number" && const isVersionsOpen = expandedVersionDocIds.has(doc.id);
doc.latest_version_number >= 1; const hasVersions =
return ( typeof doc.latest_version_number === "number" &&
<div key={doc.id}> doc.latest_version_number >= 1;
return (
<div key={doc.id}>
<div <div
onClick={() => { onClick={() => {
setViewingDocVersion(null); setViewingDocVersion(null);
@ -1318,7 +1420,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/> />
</div> </div>
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} p-2 ${selectedDocIds.includes(doc.id) ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`}> <div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isProcessing ? <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> : isError ? <AlertCircle className="h-4 w-4 text-red-500 shrink-0" /> : <DocIcon fileType={doc.file_type} />} {isProcessing ? <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> : isError ? <AlertCircle className="h-4 w-4 text-red-500 shrink-0" /> : <DocIcon fileType={doc.file_type} />}
{renamingDocumentId === doc.id ? ( {renamingDocumentId === doc.id ? (
@ -1423,9 +1525,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
} }
/> />
)} )}
</div> </div>
); );
}) })}
</>
) : ( ) : (
renderLevel(null, 0) renderLevel(null, 0)
)} )}

View file

@ -37,9 +37,7 @@ function treeControlWidth(depth: number) {
return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1); return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1);
} }
export function treeControlCellStyle( export function treeControlCellStyle(depth: number): CSSProperties | undefined {
depth: number,
): CSSProperties | undefined {
if (depth <= 0) return undefined; if (depth <= 0) return undefined;
const width = treeControlWidth(depth); const width = treeControlWidth(depth);
return { return {
@ -157,7 +155,8 @@ export function DocVersionHistory({
<> <>
{ordered.map((v) => { {ordered.map((v) => {
const numberLabel = const numberLabel =
typeof v.version_number === "number" && v.version_number >= 1 typeof v.version_number === "number" &&
v.version_number >= 1
? `${v.version_number}` ? `${v.version_number}`
: v.source === "upload" : v.source === "upload"
? "Original" ? "Original"
@ -182,7 +181,7 @@ export function DocVersionHistory({
if (isEditing) return; if (isEditing) return;
onOpenVersion?.(v.id, displayLabel); onOpenVersion?.(v.id, displayLabel);
}} }}
className="group flex items-center h-9 pr-8 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors" className="group flex items-center h-9 pr-3 md:pr-10 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors"
> >
<div <div
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`} className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`}
@ -193,7 +192,9 @@ export function DocVersionHistory({
style={treeNameCellStyle(depth)} style={treeNameCellStyle(depth)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="shrink-0 text-gray-400"></span> <span className="shrink-0 text-gray-400">
</span>
{isEditing ? ( {isEditing ? (
<input <input
autoFocus autoFocus
@ -223,7 +224,9 @@ export function DocVersionHistory({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setEditingVersionId(v.id); setEditingVersionId(v.id);
setEditingValue(v.display_name ?? ""); setEditingValue(
v.display_name ?? "",
);
}} }}
title="Rename version" title="Rename version"
className="shrink-0 rounded p-0.5 text-gray-400 opacity-0 group-hover:opacity-100 hover:text-gray-700 hover:bg-gray-200 transition" className="shrink-0 rounded p-0.5 text-gray-400 opacity-0 group-hover:opacity-100 hover:text-gray-700 hover:bg-gray-200 transition"
@ -234,7 +237,9 @@ export function DocVersionHistory({
<span className="text-gray-400 truncate"> <span className="text-gray-400 truncate">
{dateLabel} {dateLabel}
</span> </span>
<span className="text-gray-300 shrink-0">·</span> <span className="text-gray-300 shrink-0">
·
</span>
<span className="text-gray-400 truncate"> <span className="text-gray-400 truncate">
{v.source} {v.source}
</span> </span>
@ -265,23 +270,29 @@ export function DocVersionHistory({
export function ProjectPageSkeleton() { export function ProjectPageSkeleton() {
return ( return (
<div className="flex-1 overflow-y-auto bg-white"> <div className="flex-1 overflow-y-auto bg-white">
<div className="flex items-start justify-between px-8 py-4"> <div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> <div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
<span className="text-gray-400">Projects</span> <span className="text-gray-400">Projects</span>
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" /> <div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<div className="h-8 w-16 rounded bg-gray-100 animate-pulse" /> <div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-11 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-28 rounded bg-gray-100 animate-pulse" /> <div className="h-8 w-28 rounded bg-gray-100 animate-pulse" />
</div> </div>
</div> </div>
<div className="flex items-center h-10 px-8 border-b border-gray-200 gap-5"> <div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-5">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" /> <div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse" /> <div className="h-3 w-10 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" /> <div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="ml-auto flex items-center gap-5">
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
</div>
</div> </div>
<div className="flex items-center h-8 pr-8 border-b border-gray-200"> <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200">
<div className="w-8 shrink-0" /> <div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4"> <div className="flex-1 min-w-0 pl-3 pr-4">
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" /> <div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
@ -297,7 +308,7 @@ export function ProjectPageSkeleton() {
{[1, 2, 3, 4, 5].map((i) => ( {[1, 2, 3, 4, 5].map((i) => (
<div <div
key={i} key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50" className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
> >
<div className="w-8 shrink-0" /> <div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4"> <div className="flex-1 min-w-0 pl-3 pr-4">
@ -346,7 +357,7 @@ export function ProjectPageHeader({
onNewReview: () => void; onNewReview: () => void;
}) { }) {
return ( return (
<div className="flex items-start justify-between px-8 py-4"> <div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
<div> <div>
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> <div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
<button <button
@ -393,7 +404,7 @@ export function ProjectPageHeader({
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<HeaderSearchBtn <HeaderSearchBtn
value={search} value={search}
onChange={onSearchChange} onChange={onSearchChange}
@ -410,7 +421,7 @@ export function ProjectPageHeader({
<div className="relative group"> <div className="relative group">
<button <button
onClick={() => !creatingChat && onNewChat()} onClick={() => !creatingChat && onNewChat()}
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${ className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
!creatingChat !creatingChat
? "text-gray-500 hover:text-gray-900 cursor-pointer" ? "text-gray-500 hover:text-gray-900 cursor-pointer"
: "text-gray-300 cursor-default" : "text-gray-300 cursor-default"
@ -429,7 +440,7 @@ export function ProjectPageHeader({
onClick={() => onClick={() =>
docsCount > 0 && !creatingReview && onNewReview() docsCount > 0 && !creatingReview && onNewReview()
} }
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${ className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
docsCount > 0 docsCount > 0
? "text-gray-500 hover:text-gray-900 cursor-pointer" ? "text-gray-500 hover:text-gray-900 cursor-pointer"
: "text-gray-300 cursor-default" : "text-gray-300 cursor-default"

View file

@ -129,11 +129,7 @@ export function ProjectReviewsTab({
/> />
</div> </div>
<div <div
className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${ className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
selectedReviewIds.includes(review.id)
? "bg-gray-50"
: "bg-white"
} group-hover:bg-gray-50`}
> >
{renamingReviewId === review.id ? ( {renamingReviewId === review.id ? (
<input <input

View file

@ -177,7 +177,7 @@ export function ProjectsOverview() {
} }
const toolbarActions = ( const toolbarActions = (
<div className="flex items-center gap-2"> <>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<div ref={actionsRef} className="relative"> <div ref={actionsRef} className="relative">
<button <button
@ -199,13 +199,13 @@ export function ProjectsOverview() {
)} )}
</div> </div>
)} )}
</div> </>
); );
return ( return (
<div className="flex-1 overflow-y-auto bg-white"> <div className="flex-1 overflow-y-auto bg-white">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between px-8 py-4"> <div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10">
<h1 className="text-2xl font-medium font-serif text-gray-900"> <h1 className="text-2xl font-medium font-serif text-gray-900">
Projects Projects
</h1> </h1>
@ -235,7 +235,7 @@ export function ProjectsOverview() {
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<div className="min-w-max"> <div className="min-w-max">
{/* Column headers */} {/* Column headers */}
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none"> <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> <div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
{!loading && ( {!loading && (
<input <input
@ -267,7 +267,7 @@ export function ProjectsOverview() {
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50" className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
> >
<div className="w-8 shrink-0" /> <div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4"> <div className="flex-1 min-w-0 pl-3 pr-4">
@ -341,7 +341,7 @@ export function ProjectsOverview() {
if (renamingId === project.id) return; if (renamingId === project.id) return;
router.push(`/projects/${project.id}`); router.push(`/projects/${project.id}`);
}} }}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
> >
<div <div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`} className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
@ -358,7 +358,7 @@ export function ProjectsOverview() {
</div> </div>
{/* Project Name */} {/* Project Name */}
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}> <div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
{renamingId === project.id ? ( {renamingId === project.id ? (
<input <input
autoFocus autoFocus

View file

@ -38,6 +38,7 @@ export function AddDocumentsModal({
const { user } = useAuth(); const { user } = useAuth();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [extraUploadedDocs, setExtraUploadedDocs] = useState<MikeDocument[]>([]); const [extraUploadedDocs, setExtraUploadedDocs] = useState<MikeDocument[]>([]);
// IDs deleted in this session — hidden locally since `useDirectoryData`'s // IDs deleted in this session — hidden locally since `useDirectoryData`'s
@ -52,6 +53,7 @@ export function AddDocumentsModal({
setSelectedIds(new Set()); setSelectedIds(new Set());
setExtraUploadedDocs([]); setExtraUploadedDocs([]);
setDeletedIds(new Set()); setDeletedIds(new Set());
setUploadingFilenames([]);
}, [open]); }, [open]);
if (!open) return null; if (!open) return null;
@ -175,6 +177,7 @@ export function AddDocumentsModal({
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (!files.length) return; if (!files.length) return;
setUploadingFilenames(files.map((file) => file.name));
setUploading(true); setUploading(true);
try { try {
const uploaded = await Promise.all( const uploaded = await Promise.all(
@ -193,6 +196,7 @@ export function AddDocumentsModal({
console.error("Upload failed:", err); console.error("Upload failed:", err);
} finally { } finally {
setUploading(false); setUploading(false);
setUploadingFilenames([]);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
} }
} }
@ -255,6 +259,7 @@ export function AddDocumentsModal({
q ? "No matches found" : "No documents yet" q ? "No matches found" : "No documents yet"
} }
onDelete={handleDelete} onDelete={handleDelete}
uploadingFilenames={uploadingFilenames}
/> />
</div> </div>

View file

@ -19,6 +19,7 @@ import Link from "next/link";
import { MikeIcon } from "@/components/chat/mike-icon"; import { MikeIcon } from "@/components/chat/mike-icon";
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem"; import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
import { listProjects } from "@/app/lib/mikeApi"; import { listProjects } from "@/app/lib/mikeApi";
import type { MikeProject } from "@/app/components/shared/types";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/assistant", label: "Assistant", icon: MessageSquare }, { href: "/assistant", label: "Assistant", icon: MessageSquare },
@ -35,15 +36,25 @@ interface AppSidebarProps {
export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
const { user } = useAuth(); const { user } = useAuth();
const { profile } = useUserProfile(); const { profile } = useUserProfile();
const { chats, currentChatId, setCurrentChatId } = useChatHistoryContext(); const {
chats,
currentChatId,
hasMoreChats,
loadMoreChats,
setCurrentChatId,
} = useChatHistoryContext();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [shouldAnimate, setShouldAnimate] = useState(false); const [shouldAnimate, setShouldAnimate] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [projectsCollapsed, setProjectsCollapsed] = useState(false);
const [historyCollapsed, setHistoryCollapsed] = useState(false); const [historyCollapsed, setHistoryCollapsed] = useState(false);
const [projectNames, setProjectNames] = useState<Record<string, string>>( const [projectNames, setProjectNames] = useState<Record<string, string>>(
{}, {},
); );
const [recentProjects, setRecentProjects] = useState<MikeProject[] | null>(
null,
);
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
@ -52,8 +63,20 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
for (const p of projects) map[p.id] = p.name; for (const p of projects) map[p.id] = p.name;
setProjectNames(map); setProjectNames(map);
setRecentProjects(
[...projects]
.sort(
(a, b) =>
Date.parse(b.updated_at || b.created_at) -
Date.parse(a.updated_at || a.created_at),
)
.slice(0, 5),
);
}) })
.catch(() => {}); .catch(() => {
setProjectNames({});
setRecentProjects([]);
});
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
@ -112,12 +135,12 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
className={`${ className={`${
isOpen isOpen
? "w-64 h-dvh bg-gray-50 border-r" ? "w-64 h-dvh bg-gray-50 border-r"
: "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent" : "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent pointer-events-none md:pointer-events-auto"
} border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-99 overflow-visible`} } border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-[99] overflow-visible`}
> >
{/* Toggle + Logo */} {/* Toggle + Logo */}
<div <div
className={`mb-3 items-center justify-between px-2.5 py-2 ${ className={`items-center justify-between px-2.5 py-3 ${
!isOpen ? "hidden md:flex" : "flex" !isOpen ? "hidden md:flex" : "flex"
}`} }`}
> >
@ -152,7 +175,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
const isActive = const isActive =
pathname === href || pathname.startsWith(href + "/"); pathname === href || pathname.startsWith(href + "/");
return ( return (
<div key={href} className="py-1 px-2.5"> <div key={href} className="py-0.5 px-2.5">
<button <button
onClick={() => router.push(href)} onClick={() => router.push(href)}
title={!isOpen ? label : ""} title={!isOpen ? label : ""}
@ -181,74 +204,182 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
); );
})} })}
{/* Assistant History */} {isOpen && (
{isOpen && pathname.startsWith("/assistant") && ( <div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
<div className="mt-4 flex-1 min-h-0 flex flex-col"> {/* Recent Projects */}
<button <div>
onClick={() => setHistoryCollapsed((v) => !v)} <button
className={`mb-2 px-5 flex items-center justify-between text-xs font-semibold text-gray-500 hover:text-gray-700 transition-colors ${ onClick={() => setProjectsCollapsed((v) => !v)}
shouldAnimate ? "sidebar-fade-in" : "" className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
}`} shouldAnimate ? "sidebar-fade-in" : ""
> }`}
<span>Assistant History</span> >
<ChevronDown <span>Recent Projects</span>
className={`h-3.5 w-3.5 transition-transform ${historyCollapsed ? "-rotate-90" : ""}`} <ChevronDown
/> className={`h-3.5 w-3.5 transition-transform ${
</button> projectsCollapsed ? "-rotate-90" : ""
<div }`}
className={`overflow-y-auto flex-1 ${historyCollapsed ? "hidden" : ""}`} />
> </button>
{!chats ? ( {!projectsCollapsed && (
<div className="space-y-1 px-2.5"> <>
{[40, 60, 50, 70, 45].map((w, i) => ( {!recentProjects ? (
<div <div className="space-y-1 px-2.5">
key={i} {[50, 65, 45].map((w, i) => (
className="h-9 flex items-center px-3 rounded-md" <div
> key={i}
<div className="h-9 flex items-center px-3 rounded-md"
className="h-3 bg-gray-200 rounded animate-pulse" >
style={{ width: `${w}%` }} <div
/> className="h-3 bg-gray-200 rounded animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
</div> </div>
))} ) : recentProjects.length === 0 ? (
</div> <div
) : chats.length === 0 ? ( className={`px-5 py-2 text-xs text-gray-500 ${
<div shouldAnimate
className={`text-xs text-gray-500 py-2 px-5 ${ ? "sidebar-fade-in-2"
shouldAnimate ? "sidebar-fade-in-2" : "" : ""
}`} }`}
> >
No chats yet No projects yet
</div> </div>
) : ( ) : (
<div <div
className={`space-y-1 px-2.5 ${ className={`space-y-1 px-2.5 ${
shouldAnimate ? "sidebar-fade-in-2" : "" shouldAnimate
}`} ? "sidebar-fade-in-2"
> : ""
{chats.map((chat) => ( }`}
<SidebarChatItem >
key={chat.id} {recentProjects.map((project) => {
chat={chat} const isActive =
isActive={currentChatId === chat.id} pathname ===
projectName={ `/projects/${project.id}` ||
chat.project_id pathname.startsWith(
? projectNames[chat.project_id] `/projects/${project.id}/`,
: undefined );
} return (
onSelect={() => { <button
setCurrentChatId(chat.id); key={project.id}
router.push( onClick={() =>
chat.project_id router.push(
? `/projects/${chat.project_id}/assistant/chat/${chat.id}` `/projects/${project.id}`,
: `/assistant/chat/${chat.id}`, )
}
title={project.name}
className={`flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors ${
isActive
? "bg-gray-100 text-gray-900"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-500" />
<span className="min-w-0 flex-1 truncate">
{project.name}
</span>
</button>
); );
}} })}
/> </div>
))} )}
</div> </>
)} )}
</div> </div>
{/* Assistant History */}
<div className="flex min-h-0 flex-1 flex-col">
<button
onClick={() => setHistoryCollapsed((v) => !v)}
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
shouldAnimate ? "sidebar-fade-in" : ""
}`}
>
<span>Assistant History</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
historyCollapsed ? "-rotate-90" : ""
}`}
/>
</button>
<div
className={`overflow-y-auto flex-1 ${
historyCollapsed ? "hidden" : ""
}`}
>
{!chats ? (
<div className="space-y-1 px-2.5">
{[40, 60, 50, 70, 45].map((w, i) => (
<div
key={i}
className="h-9 flex items-center px-3 rounded-md"
>
<div
className="h-3 bg-gray-200 rounded animate-pulse"
style={{ width: `${w}%` }}
/>
</div>
))}
</div>
) : chats.length === 0 ? (
<div
className={`text-xs text-gray-500 py-2 px-5 ${
shouldAnimate ? "sidebar-fade-in-2" : ""
}`}
>
No chats yet
</div>
) : (
<>
<div
className={`space-y-1 px-2.5 ${
shouldAnimate
? "sidebar-fade-in-2"
: ""
}`}
>
{chats.map((chat) => (
<SidebarChatItem
key={chat.id}
chat={chat}
isActive={
currentChatId === chat.id
}
projectName={
chat.project_id
? projectNames[
chat.project_id
]
: undefined
}
onSelect={() => {
setCurrentChatId(chat.id);
router.push(
chat.project_id
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
: `/assistant/chat/${chat.id}`,
);
}}
/>
))}
</div>
{hasMoreChats && (
<div className="px-2.5 pt-1">
<button
onClick={loadMoreChats}
className="flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
>
Load more
</button>
</div>
)}
</>
)}
</div>
</div>
</div> </div>
)} )}

View file

@ -9,6 +9,7 @@ import {
FileText, FileText,
Folder, Folder,
Trash2, Trash2,
Loader2,
} from "lucide-react"; } from "lucide-react";
import type { MikeDocument, MikeProject } from "./types"; import type { MikeDocument, MikeProject } from "./types";
import { VersionChip } from "./VersionChip"; import { VersionChip } from "./VersionChip";
@ -39,6 +40,7 @@ interface FileDirectoryProps {
emptyMessage?: string; emptyMessage?: string;
heading?: string; heading?: string;
onDelete?: (ids: string[]) => void | Promise<void>; onDelete?: (ids: string[]) => void | Promise<void>;
uploadingFilenames?: string[];
} }
export function FileDirectory({ export function FileDirectory({
@ -52,6 +54,7 @@ export function FileDirectory({
emptyMessage = "No documents yet", emptyMessage = "No documents yet",
heading = "Documents", heading = "Documents",
onDelete, onDelete,
uploadingFilenames = [],
}: FileDirectoryProps) { }: FileDirectoryProps) {
const [expandedProjects, setExpandedProjects] = useState<Set<string>>( const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
new Set(), new Set(),
@ -142,7 +145,11 @@ export function FileDirectory({
); );
} }
if (allDocs.length === 0 && directoryProjects.length === 0) { if (
allDocs.length === 0 &&
directoryProjects.length === 0 &&
uploadingFilenames.length === 0
) {
return ( return (
<p className="text-center text-sm text-gray-400 py-8"> <p className="text-center text-sm text-gray-400 py-8">
{emptyMessage} {emptyMessage}
@ -154,6 +161,7 @@ export function FileDirectory({
<div className="rounded-sm border border-gray-100 overflow-hidden"> <div className="rounded-sm border border-gray-100 overflow-hidden">
<div> <div>
{(standaloneDocs.length > 0 || {(standaloneDocs.length > 0 ||
uploadingFilenames.length > 0 ||
(onDelete && selectedCount > 0)) && ( (onDelete && selectedCount > 0)) && (
<div className="flex items-center justify-between px-2 py-2"> <div className="flex items-center justify-between px-2 py-2">
<p className="text-xs font-medium text-gray-400"> <p className="text-xs font-medium text-gray-400">
@ -185,6 +193,21 @@ export function FileDirectory({
</div> </div>
</div> </div>
)} )}
{uploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="w-full flex items-center gap-2 px-2 py-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>
</div>
))}
{standaloneDocs.map((doc) => { {standaloneDocs.map((doc) => {
const selected = selectedIds.has(doc.id); const selected = selectedIds.has(doc.id);
return ( return (

View file

@ -47,7 +47,7 @@ export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }:
) : ( ) : (
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors" className="flex h-8 w-8 items-center justify-center text-gray-500 hover:text-gray-900 transition-colors"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
</button> </button>

View file

@ -20,7 +20,7 @@ export function ToolbarTabs<T extends string>({
actions, actions,
}: Props<T>) { }: Props<T>) {
return ( return (
<div className="flex items-center h-10 px-8 border-b border-gray-200"> <div className="flex items-center h-10 px-4 border-b border-gray-200 md:px-10">
<div className="flex-1 flex items-center gap-5"> <div className="flex-1 flex items-center gap-5">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
@ -37,7 +37,7 @@ export function ToolbarTabs<T extends string>({
))} ))}
</div> </div>
{actions && ( {actions && (
<div className="flex items-center gap-1">{actions}</div> <div className="flex items-center gap-2">{actions}</div>
)} )}
</div> </div>
); );

View file

@ -245,6 +245,7 @@ export interface TabularReview {
user_id: string; user_id: string;
title: string | null; title: string | null;
columns_config: ColumnConfig[] | null; columns_config: ColumnConfig[] | null;
document_ids?: string[] | null;
workflow_id: string | null; workflow_id: string | null;
practice?: string | null; practice?: string | null;
/** Per-review email list. Used so standalone (project_id null) reviews can be shared directly. */ /** Per-review email list. Used so standalone (project_id null) reviews can be shared directly. */

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { forwardRef, useImperativeHandle, useRef } from "react"; import { forwardRef, useImperativeHandle, useRef } from "react";
import { Plus, Table2 } from "lucide-react"; import { Loader2, Plus, Table2, Upload } from "lucide-react";
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types"; import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
import { TabularCell as TabularCellComponent } from "./TabularCell"; import { TabularCell as TabularCellComponent } from "./TabularCell";
import { TREditColumnMenu } from "./TREditColumnMenu"; import { TREditColumnMenu } from "./TREditColumnMenu";
@ -30,6 +30,8 @@ interface Props {
savingColumn: boolean; savingColumn: boolean;
savingColumnsConfig: boolean; savingColumnsConfig: boolean;
selectedDocIds: string[]; selectedDocIds: string[];
uploadingFilenames?: string[];
dragOverFiles?: boolean;
highlightedCell?: { colIdx: number; rowIdx: number } | null; highlightedCell?: { colIdx: number; rowIdx: number } | null;
onSelectionChange: (ids: string[]) => void; onSelectionChange: (ids: string[]) => void;
onExpand: (cell: TabularCell) => void; onExpand: (cell: TabularCell) => void;
@ -49,6 +51,8 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
savingColumn, savingColumn,
savingColumnsConfig, savingColumnsConfig,
selectedDocIds, selectedDocIds,
uploadingFilenames = [],
dragOverFiles = false,
highlightedCell, highlightedCell,
onSelectionChange, onSelectionChange,
onExpand, onExpand,
@ -165,7 +169,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
); );
} }
if (columns.length === 0 && documents.length === 0) { if (
columns.length === 0 &&
documents.length === 0 &&
uploadingFilenames.length === 0
) {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center border-b border-gray-200"> <div className="flex items-center border-b border-gray-200">
@ -177,28 +185,33 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
</div> </div>
<div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto"> <div className="relative flex min-h-0 flex-1">
<Table2 className="h-8 w-8 text-gray-300 mb-4" /> {dragOverFiles && (
<p className="text-2xl font-medium font-serif text-gray-900"> <div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
Tabular Review )}
</p> <div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto">
<p className="mt-1 text-xs text-gray-400 text-left"> <Table2 className="h-8 w-8 text-gray-300 mb-4" />
Add columns and documents to get started. <p className="text-2xl font-medium font-serif text-gray-900">
</p> Tabular Review
<div className="mt-4 flex items-center gap-2"> </p>
<button <p className="mt-1 text-xs text-gray-400 text-left">
onClick={onAddColumn} Add columns and documents to get started.
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" </p>
> <div className="mt-4 flex items-center gap-2">
+ Add Columns <button
</button> onClick={onAddColumn}
<button 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"
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" + Add Columns
> </button>
<Plus className="h-3.5 w-3.5" /> <button
Add Documents onClick={onAddDocuments}
</button> 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"
>
<Upload className="h-3.5 w-3.5" />
Add Documents
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -206,7 +219,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
} }
return ( return (
<div className="flex-1 overflow-auto" ref={scrollContainerRef}> <div
className="flex flex-1 flex-col overflow-auto"
ref={scrollContainerRef}
>
{/* Header */} {/* Header */}
<div <div
className="sticky top-0 z-20 flex bg-white h-8" className="sticky top-0 z-20 flex bg-white h-8"
@ -258,69 +274,114 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
</div> </div>
{/* Rows */} {/* Rows */}
{documents.map((doc, docIdx) => { <div className="relative min-h-0 flex-1">
const rowBg = selectedDocIds.includes(doc.id) {dragOverFiles && (
? "bg-gray-100" <div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
: docIdx % 2 === 0 )}
? "bg-white" {uploadingFilenames.map((filename) => (
: "bg-gray-50";
return (
<div <div
key={doc.id} key={`uploading-${filename}`}
className={`flex ${rowBg}`} className="flex bg-white"
style={{ minWidth: totalContentWidth }} style={{ minWidth: totalContentWidth }}
> >
<div <div
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`} className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center bg-white`}
> >
<input <input
type="checkbox" type="checkbox"
checked={selectedDocIds.includes(doc.id)} disabled
onChange={() => toggleDoc(doc.id)} className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/> />
</div> </div>
<div <div
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${rowBg}`} className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-400 flex items-center gap-2 bg-white`}
> >
<span className="line-clamp-1" title={doc.filename}> <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
{doc.filename} <span className="line-clamp-1" title={filename}>
{filename}
</span> </span>
</div> </div>
{columns.map((col) => { {sortedColumns.map((col) => (
const cell = getCell(doc.id, col.index); <div
const colPos = sortedColumns.findIndex( key={col.index}
(c) => c.index === col.index, className={`${COL_W} border-b border-r border-gray-200 p-2`}
); >
const isHighlighted = <div className="h-4 w-20 rounded bg-gray-100 animate-pulse" />
highlightedCell?.colIdx === colPos && </div>
highlightedCell?.rowIdx === docIdx; ))}
return (
<div
key={col.index}
className={`${COL_W} border-b border-r border-gray-200 transition-colors ${isHighlighted ? "bg-blue-200" : ""}`}
>
{cell && (
<TabularCellComponent
cell={cell}
column={col}
onExpand={() => onExpand(cell)}
onCitationClick={(page, quote) =>
onCitationClick(
cell,
page,
quote,
)
}
/>
)}
</div>
);
})}
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" /> <div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
</div> </div>
); ))}
})} {documents.map((doc, docIdx) => {
const baseRowBg =
docIdx % 2 === 0 ? "bg-white" : "bg-gray-50";
const rowBg = selectedDocIds.includes(doc.id)
? "bg-gray-100"
: baseRowBg;
return (
<div
key={doc.id}
className={`flex ${rowBg}`}
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`}
>
<input
type="checkbox"
checked={selectedDocIds.includes(doc.id)}
onChange={() => toggleDoc(doc.id)}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${baseRowBg}`}
>
<span
className="line-clamp-1"
title={doc.filename}
>
{doc.filename}
</span>
</div>
{columns.map((col) => {
const cell = getCell(doc.id, col.index);
const colPos = sortedColumns.findIndex(
(c) => c.index === col.index,
);
const isHighlighted =
highlightedCell?.colIdx === colPos &&
highlightedCell?.rowIdx === docIdx;
return (
<div
key={col.index}
className={`${COL_W} border-b border-r border-gray-200 transition-colors ${isHighlighted ? "bg-blue-200" : ""}`}
>
{cell && (
<TabularCellComponent
cell={cell}
column={col}
onExpand={() => onExpand(cell)}
onCitationClick={(
page,
quote,
) =>
onCitationClick(
cell,
page,
quote,
)
}
/>
)}
</div>
);
})}
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
</div>
);
})}
</div>
</div> </div>
); );
}); });

View file

@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users } from "lucide-react"; import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload } from "lucide-react";
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn"; import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
import { import {
@ -13,6 +13,7 @@ import {
regenerateTabularCell, regenerateTabularCell,
streamTabularGeneration, streamTabularGeneration,
updateTabularReview, updateTabularReview,
uploadReviewDocument,
} from "@/app/lib/mikeApi"; } from "@/app/lib/mikeApi";
import type { import type {
ColumnConfig, ColumnConfig,
@ -70,6 +71,10 @@ export function TRView({ reviewId, projectId }: Props) {
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]); const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false); const [actionsOpen, setActionsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [dragOverReviewFiles, setDragOverReviewFiles] = useState(false);
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
string[]
>([]);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const initialChatParamRef = useRef<string | null>( const initialChatParamRef = useRef<string | null>(
searchParams.get("chat"), searchParams.get("chat"),
@ -188,6 +193,33 @@ export function TRView({ reviewId, projectId }: Props) {
} }
} }
function hasFilePayload(dt: DataTransfer): boolean {
return Array.from(dt.types).includes("Files");
}
async function handleDropReviewFiles(files: File[]) {
if (files.length === 0) return;
setUploadingDroppedFilenames(files.map((file) => file.name));
try {
const uploaded: MikeDocument[] = [];
const documentIds = documents.map((document) => document.id);
for (const file of files) {
const document = await uploadReviewDocument(reviewId, file, {
projectId,
documentIds,
columnsConfig: columns,
});
uploaded.push(document);
documentIds.push(document.id);
}
await handleAddDocuments(uploaded);
} catch (err) {
console.error("Tabular review document drop upload failed", err);
} finally {
setUploadingDroppedFilenames([]);
}
}
async function handleRegenerateCell(docId: string, colIndex: number) { async function handleRegenerateCell(docId: string, colIndex: number) {
if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) { if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) {
setApiKeyModalProvider(getModelProvider(tabularModel)); setApiKeyModalProvider(getModelProvider(tabularModel));
@ -441,19 +473,30 @@ export function TRView({ reviewId, projectId }: Props) {
} }
async function handleDeleteDocuments() { async function handleDeleteDocuments() {
const idsToDelete = [...selectedDocIds];
if (idsToDelete.length === 0) return;
const previousDocuments = documents;
const previousCells = cells;
const remaining = documents.filter( const remaining = documents.filter(
(d) => !selectedDocIds.includes(d.id), (d) => !idsToDelete.includes(d.id),
); );
setDocuments(remaining); setDocuments(remaining);
setCells((prev) => setCells((prev) =>
prev.filter((c) => !selectedDocIds.includes(c.document_id)), prev.filter((c) => !idsToDelete.includes(c.document_id)),
); );
setSelectedDocIds([]); setSelectedDocIds([]);
setActionsOpen(false); setActionsOpen(false);
await updateTabularReview(reviewId, { try {
document_ids: remaining.map((d) => d.id), await updateTabularReview(reviewId, {
columns_config: columns, document_ids: remaining.map((d) => d.id),
}); columns_config: columns,
});
} catch (err) {
setDocuments(previousDocuments);
setCells(previousCells);
setSelectedDocIds(idsToDelete);
console.error("Failed to delete tabular review documents", err);
}
} }
async function handleClearResults() { async function handleClearResults() {
@ -486,7 +529,7 @@ export function TRView({ reviewId, projectId }: Props) {
<div className="flex h-full overflow-hidden bg-white"> <div className="flex h-full overflow-hidden bg-white">
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Header */} {/* Header */}
<div className="bg-white px-8 py-4 flex items-start justify-between shrink-0 gap-4"> <div className="mb-1 bg-white px-4 py-3 md:px-10 flex items-start justify-between shrink-0 gap-4">
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif"> <div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
{projectId && ( {projectId && (
<> <>
@ -614,7 +657,7 @@ export function TRView({ reviewId, projectId }: Props) {
</div> </div>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center h-10 px-8 border-b border-gray-200 gap-4"> <div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-4">
<button <button
onClick={() => { onClick={() => {
if (!chatOpen) setSidebarOpen(false); if (!chatOpen) setSidebarOpen(false);
@ -631,8 +674,14 @@ export function TRView({ reviewId, projectId }: Props) {
<MessageSquare className="h-3.5 w-3.5" /> <MessageSquare className="h-3.5 w-3.5" />
Assistant in Tabular Review Assistant in Tabular Review
</button> </button>
<div className="ml-auto flex items-center gap-4"> <div className="ml-auto flex items-center gap-5">
{selectedDocIds.length > 0 && ( {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"> <div ref={actionsRef} className="relative">
<button <button
onClick={() => setActionsOpen((v) => !v)} onClick={() => setActionsOpen((v) => !v)}
@ -659,32 +708,34 @@ export function TRView({ reviewId, projectId }: Props) {
)} )}
</div> </div>
)} )}
<button {!loading && (
onClick={() => setAddDocsOpen(true)} <>
disabled={loading || savingColumnsConfig} <button
className={`flex items-center gap-1 text-xs font-medium transition-colors ${ onClick={() => setAddDocsOpen(true)}
loading || savingColumnsConfig disabled={savingColumnsConfig}
? "text-gray-300 cursor-default" className={`flex items-center gap-1 text-xs font-medium transition-colors ${
: "text-gray-700 hover:text-gray-900" savingColumnsConfig
}`} ? "text-gray-300 cursor-default"
> : "text-gray-700 hover:text-gray-900"
<Plus className="h-3.5 w-3.5" /> }`}
Add Documents >
</button> <Upload className="h-3.5 w-3.5" />
<button Add Documents
onClick={() => setAddColOpen(true)} </button>
disabled={ <button
loading || savingColumn || savingColumnsConfig onClick={() => setAddColOpen(true)}
} disabled={savingColumn || savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${ className={`flex items-center gap-1 text-xs font-medium transition-colors ${
loading || savingColumn || savingColumnsConfig savingColumn || savingColumnsConfig
? "text-gray-300 cursor-default" ? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900" : "text-gray-700 hover:text-gray-900"
}`} }`}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add Columns Add Columns
</button> </button>
</>
)}
</div> </div>
</div> </div>
@ -706,30 +757,60 @@ export function TRView({ reviewId, projectId }: Props) {
onChatIdChange={setSelectedChatId} onChatIdChange={setSelectedChatId}
/> />
)} )}
<TRTable <div
ref={tableRef} className="relative flex flex-1 overflow-hidden"
loading={loading} onDragOver={(e) => {
columns={columns} if (!hasFilePayload(e.dataTransfer)) return;
documents={filteredDocuments} e.preventDefault();
cells={cells} e.dataTransfer.dropEffect = "copy";
highlightedCell={highlightedCell} setDragOverReviewFiles(true);
savingColumn={savingColumn}
savingColumnsConfig={savingColumnsConfig}
selectedDocIds={selectedDocIds}
onSelectionChange={setSelectedDocIds}
onExpand={(cell) => {
setExpandedCell(cell);
setExpandedCellCitation(undefined);
}} }}
onCitationClick={(cell, page, quote) => { onDragLeave={(e) => {
setExpandedCell(cell); if (
setExpandedCellCitation({ quote, page }); !e.currentTarget.contains(
e.relatedTarget as Node,
)
) {
setDragOverReviewFiles(false);
}
}} }}
onUpdateColumn={handleUpdateColumn} onDrop={(e) => {
onDeleteColumn={handleDeleteColumn} if (!hasFilePayload(e.dataTransfer)) return;
onAddColumn={() => setAddColOpen(true)} e.preventDefault();
onAddDocuments={() => setAddDocsOpen(true)} 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>
</div> </div>

View file

@ -34,7 +34,7 @@ const CHECK_W = "w-8 shrink-0";
const NAME_COL_W = "w-[300px] shrink-0"; const NAME_COL_W = "w-[300px] shrink-0";
const TABS: { id: Tab; label: string }[] = [ const TABS: { id: Tab; label: string }[] = [
{ id: "all", label: "All Workflows" }, { id: "all", label: "All" },
{ id: "builtin", label: "Built-in" }, { id: "builtin", label: "Built-in" },
{ id: "custom", label: "Custom" }, { id: "custom", label: "Custom" },
{ id: "hidden", label: "Hidden" }, { id: "hidden", label: "Hidden" },
@ -319,7 +319,7 @@ export function WorkflowList() {
); );
const toolbarActions = ( const toolbarActions = (
<div className="flex items-center gap-2"> <>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<div ref={actionsRef} className="relative"> <div ref={actionsRef} className="relative">
<button <button
@ -350,15 +350,17 @@ export function WorkflowList() {
)} )}
</div> </div>
)} )}
{typeFilterButton} <div className="flex items-center gap-5">
{practiceFilterButton} {typeFilterButton}
</div> {practiceFilterButton}
</div>
</>
); );
return ( return (
<div className="flex flex-col flex-1 overflow-hidden bg-white"> <div className="flex flex-col flex-1 overflow-hidden bg-white">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between px-8 py-4 shrink-0"> <div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10 shrink-0">
<h1 className="text-2xl font-medium font-serif text-gray-900"> <h1 className="text-2xl font-medium font-serif text-gray-900">
Workflows Workflows
</h1> </h1>
@ -388,7 +390,7 @@ export function WorkflowList() {
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="min-w-max"> <div className="min-w-max">
{/* Column headers */} {/* Column headers */}
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none"> <div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}> <div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
{!loading && ( {!loading && (
<input <input
@ -416,7 +418,7 @@ export function WorkflowList() {
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className="flex items-center h-10 pr-8 border-b border-gray-50" className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
> >
<div className="w-8 shrink-0" /> <div className="w-8 shrink-0" />
<div className="flex-1 min-w-0 pl-3 pr-4"> <div className="flex-1 min-w-0 pl-3 pr-4">
@ -489,7 +491,7 @@ export function WorkflowList() {
<div <div
key={wf.id} key={wf.id}
onClick={() => setSelected(wf)} onClick={() => setSelected(wf)}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
> >
<div <div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`} className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}

View file

@ -20,9 +20,11 @@ import type { MikeChat, MikeMessage } from "@/app/components/shared/types";
interface ChatHistoryContextType { interface ChatHistoryContextType {
chats: MikeChat[] | null; chats: MikeChat[] | null;
hasMoreChats: boolean;
currentChatId: string | null; currentChatId: string | null;
setCurrentChatId: (chatId: string | null) => void; setCurrentChatId: (chatId: string | null) => void;
loadChats: () => Promise<void>; loadChats: () => Promise<void>;
loadMoreChats: () => void;
saveChat: (projectId?: string) => Promise<string | null>; saveChat: (projectId?: string) => Promise<string | null>;
renameChat: (chatId: string, title: string) => Promise<void>; renameChat: (chatId: string, title: string) => Promise<void>;
newChatMessages: MikeMessage[] | null; newChatMessages: MikeMessage[] | null;
@ -39,9 +41,14 @@ const ChatHistoryContext = createContext<ChatHistoryContextType | undefined>(
undefined, undefined,
); );
const INITIAL_CHAT_LIMIT = 20;
const CHAT_LIMIT_INCREMENT = 10;
export function ChatHistoryProvider({ children }: { children: ReactNode }) { export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const { user } = useAuth(); const { user } = useAuth();
const [chats, setChats] = useState<MikeChat[] | null>(null); const [chats, setChats] = useState<MikeChat[] | null>(null);
const [chatLimit, setChatLimit] = useState(INITIAL_CHAT_LIMIT);
const [hasMoreChats, setHasMoreChats] = useState(false);
const [currentChatId, setCurrentChatId] = useState<string | null>(null); const [currentChatId, setCurrentChatId] = useState<string | null>(null);
const [newChatMessages, setNewChatMessages] = useState< const [newChatMessages, setNewChatMessages] = useState<
MikeMessage[] | null MikeMessage[] | null
@ -50,20 +57,25 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const loadChats = useCallback(async () => { const loadChats = useCallback(async () => {
if (!user) { if (!user) {
setChats([]); setChats([]);
setHasMoreChats(false);
return; return;
} }
try { try {
const data = await listChats(); const data = await listChats({ limit: chatLimit + 1 });
setChats(data); setChats(data.slice(0, chatLimit));
setHasMoreChats(data.length > chatLimit);
} catch { } catch {
setChats([]); setChats([]);
setHasMoreChats(false);
} }
}, [user]); }, [chatLimit, user]);
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
setChats([]); setChats([]);
setChatLimit(INITIAL_CHAT_LIMIT);
setHasMoreChats(false);
setCurrentChatId(null); setCurrentChatId(null);
return; return;
} }
@ -71,6 +83,10 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
void loadChats(); void loadChats();
}, [user, loadChats]); }, [user, loadChats]);
const loadMoreChats = useCallback(() => {
setChatLimit((prev) => prev + CHAT_LIMIT_INCREMENT);
}, []);
const replaceChatId = useCallback( const replaceChatId = useCallback(
(oldChatId: string, newChatId: string, title?: string) => { (oldChatId: string, newChatId: string, title?: string) => {
if (!oldChatId || !newChatId || oldChatId === newChatId) { if (!oldChatId || !newChatId || oldChatId === newChatId) {
@ -154,9 +170,11 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const value = useMemo( const value = useMemo(
() => ({ () => ({
chats, chats,
hasMoreChats,
currentChatId, currentChatId,
setCurrentChatId, setCurrentChatId,
loadChats, loadChats,
loadMoreChats,
saveChat, saveChat,
renameChat: renameChatFn, renameChat: renameChatFn,
newChatMessages, newChatMessages,
@ -166,8 +184,10 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
}), }),
[ [
chats, chats,
hasMoreChats,
currentChatId, currentChatId,
loadChats, loadChats,
loadMoreChats,
saveChat, saveChat,
renameChatFn, renameChatFn,
newChatMessages, newChatMessages,

View file

@ -428,8 +428,11 @@ export async function createChat(payload?: {
}); });
} }
export async function listChats(): Promise<MikeChat[]> { export async function listChats(options?: { limit?: number }): Promise<MikeChat[]> {
return apiRequest<MikeChat[]>("/chat"); const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
const query = params.toString();
return apiRequest<MikeChat[]>(`/chat${query ? `?${query}` : ""}`);
} }
export async function listProjectChats(projectId: string): Promise<MikeChat[]> { export async function listProjectChats(projectId: string): Promise<MikeChat[]> {