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
- 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
Backend:

View file

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

View file

@ -1,4 +1,3 @@
import { promisify } from "util";
import JSZip from "jszip";
let _convert:
@ -8,7 +7,26 @@ let _convert:
async function getConvert() {
if (!_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;
}

View file

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

View file

@ -141,6 +141,10 @@ async function getAccessibleChat(
chatRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
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
.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}`;
const { data, error } = await db
let query = db
.from("chats")
.select("*")
.or(filter)
.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 });
res.json(data ?? []);
});

View file

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

View file

@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Menu } from "lucide-react";
import { PanelLeft } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
import { SidebarContext } from "@/app/contexts/SidebarContext";
@ -77,7 +77,12 @@ export default function MikeLayout({
return (
<ChatHistoryProvider>
<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="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">
{/* 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
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>
</div>
<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";
}
const ICON_SIZE = 30;
const ICON_SIZE = 28;
const GAP = 14;
const EXPLORER_MIN = 160;
const EXPLORER_DEFAULT = 280;
@ -92,17 +92,18 @@ const CHAT_MIN = 320;
const CHAT_DEFAULT = 420;
function AssistantGreeting({ username }: { username: string }) {
const { profile } = useUserProfile();
const [loaded, setLoaded] = useState(false);
const [iconOffset, setIconOffset] = useState(0);
const [textOffset, setTextOffset] = useState(0);
const textRef = useRef<HTMLHeadingElement>(null);
useLayoutEffect(() => {
if (!textRef.current) return;
if (!profile || !textRef.current) return;
const h1Width = textRef.current.offsetWidth;
setIconOffset((h1Width + GAP) / 2);
setTextOffset((ICON_SIZE + GAP) / 2);
}, [username]);
}, [profile]);
useEffect(() => {
if (!iconOffset) return;
@ -112,7 +113,7 @@ function AssistantGreeting({ username }: { username: string }) {
return (
<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
className="absolute h-[30px]"
style={{
@ -128,7 +129,7 @@ function AssistantGreeting({ username }: { username: string }) {
</div>
<h1
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={{
left: "50%",
transform: loaded
@ -309,9 +310,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
`created=${created.sort().join(",")}`,
`replicated=${replicated.sort().join(",")}`,
`edited=${Object.entries(editedPerDoc)
.map(([k, v]) => `${k}=${v}`)
.sort()
.join(",")}`,
.map(([k, v]) => `${k}=${v}`)
.sort()
.join(",")}`,
].join("|");
}, [messages]);
@ -1007,8 +1008,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
<div
key={tab.documentId}
ref={(el) => {
tabItemRefs.current[tab.documentId] =
el;
tabItemRefs.current[
tab.documentId
] = el;
}}
onClick={() =>
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 TABS: { id: Tab; label: string }[] = [
{ id: "all", label: "All Reviews" },
{ id: "all", label: "All" },
{ id: "in-project", label: "In Project" },
{ id: "standalone", label: "Standalone" },
];
@ -239,7 +239,7 @@ export default function TabularReviewsPage() {
);
const toolbarActions = (
<div className="flex items-center gap-2">
<>
{selectedIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
@ -262,13 +262,13 @@ export default function TabularReviewsPage() {
</div>
)}
{projectFilterButton}
</div>
</>
);
return (
<div className="flex-1 overflow-y-auto bg-white">
{/* 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">
Tabular Reviews
</h1>
@ -298,7 +298,7 @@ export default function TabularReviewsPage() {
{/* Table */}
<div className="w-full overflow-x-auto">
<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`}>
{!loading && (
<input
@ -327,7 +327,7 @@ export default function TabularReviewsPage() {
{[1, 2, 3].map((i) => (
<div
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="flex-1 min-w-0 pl-3 pr-4">
@ -395,7 +395,7 @@ export default function TabularReviewsPage() {
: `/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
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"
/>
</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 ? (
<input
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 && (
<button
type="button"
@ -268,6 +255,19 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
</span>
</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 className="flex items-center gap-1">

View file

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

View file

@ -33,6 +33,7 @@ import {
renameProjectDocument,
listDocumentVersions,
uploadDocumentVersion,
uploadProjectDocument,
renameDocumentVersion,
getProjectPeople,
type MikeDocumentVersion,
@ -50,7 +51,10 @@ import {
RowActionMenuItems,
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 { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
@ -266,6 +270,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
const newFolderInputRef = useRef<HTMLDivElement | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
const [dragOverRoot, setDragOverRoot] = useState(false);
const [dragOverFileRoot, setDragOverFileRoot] = useState(false);
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
string[]
>([]);
// Actions dropdown
const [actionsOpen, setActionsOpen] = useState(false);
@ -332,6 +340,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
function handleDragEnd() {
setDragOverFolderId(null);
setDragOverRoot(false);
setDragOverFileRoot(false);
}
document.addEventListener("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) {
if (!hasMovePayload(dt)) return;
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) {
const childFolders = folders
.filter((f) => f.parent_folder_id === parentId)
@ -786,6 +856,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
return (
<>
{parentId === null && renderUploadingDocumentRows(depth)}
{/* Files first */}
{childDocs.map((doc) => {
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"
/>
</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">
{isProcessing ? (
<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;
const toolbarActions = (
<div className="flex items-center gap-2">
<div className="flex items-center gap-5">
{actionsDropdown}
{tab === "documents" && (
<>
<button
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" />
Add Subfolder
</button>
<button
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" />
Add Documents
@ -1239,13 +1310,42 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
</div>
{/* 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 && (
<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 */}
{docs.length === 0 && folders.length === 0 ? (
{docs.length === 0 &&
folders.length === 0 &&
uploadingDroppedFilenames.length === 0 ? (
<div
onClick={() => setAddDocsOpen(true)}
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 */}
{q ? (
filteredDocs.map((doc) => {
const isProcessing = doc.status === "pending" || doc.status === "processing";
const isError = doc.status === "error";
const isVersionsOpen = expandedVersionDocIds.has(doc.id);
const hasVersions =
typeof doc.latest_version_number === "number" &&
doc.latest_version_number >= 1;
return (
<div key={doc.id}>
<>
{renderUploadingDocumentRows(0)}
{filteredDocs.map((doc) => {
const isProcessing = doc.status === "pending" || doc.status === "processing";
const isError = doc.status === "error";
const isVersionsOpen = expandedVersionDocIds.has(doc.id);
const hasVersions =
typeof doc.latest_version_number === "number" &&
doc.latest_version_number >= 1;
return (
<div key={doc.id}>
<div
onClick={() => {
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"
/>
</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">
{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 ? (
@ -1423,9 +1525,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}
/>
)}
</div>
);
})
</div>
);
})}
</>
) : (
renderLevel(null, 0)
)}

View file

@ -37,9 +37,7 @@ function treeControlWidth(depth: number) {
return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1);
}
export function treeControlCellStyle(
depth: number,
): CSSProperties | undefined {
export function treeControlCellStyle(depth: number): CSSProperties | undefined {
if (depth <= 0) return undefined;
const width = treeControlWidth(depth);
return {
@ -157,7 +155,8 @@ export function DocVersionHistory({
<>
{ordered.map((v) => {
const numberLabel =
typeof v.version_number === "number" && v.version_number >= 1
typeof v.version_number === "number" &&
v.version_number >= 1
? `${v.version_number}`
: v.source === "upload"
? "Original"
@ -182,7 +181,7 @@ export function DocVersionHistory({
if (isEditing) return;
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
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)}
>
<div className="flex items-center gap-2">
<span className="shrink-0 text-gray-400"></span>
<span className="shrink-0 text-gray-400">
</span>
{isEditing ? (
<input
autoFocus
@ -223,7 +224,9 @@ export function DocVersionHistory({
onClick={(e) => {
e.stopPropagation();
setEditingVersionId(v.id);
setEditingValue(v.display_name ?? "");
setEditingValue(
v.display_name ?? "",
);
}}
title="Rename version"
className="shrink-0 rounded p-0.5 text-gray-400 opacity-0 group-hover:opacity-100 hover:text-gray-700 hover:bg-gray-200 transition"
@ -234,7 +237,9 @@ export function DocVersionHistory({
<span className="text-gray-400 truncate">
{dateLabel}
</span>
<span className="text-gray-300 shrink-0">·</span>
<span className="text-gray-300 shrink-0">
·
</span>
<span className="text-gray-400 truncate">
{v.source}
</span>
@ -265,23 +270,29 @@ export function DocVersionHistory({
export function ProjectPageSkeleton() {
return (
<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">
<span className="text-gray-400">Projects</span>
<span className="text-gray-300"></span>
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
</div>
<div className="flex items-center gap-2">
<div className="h-8 w-16 rounded bg-gray-100 animate-pulse" />
<div className="flex items-center gap-4">
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-11 rounded bg-gray-100 animate-pulse" />
<div className="h-8 w-28 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="flex 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-10 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 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="flex-1 min-w-0 pl-3 pr-4">
<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) => (
<div
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="flex-1 min-w-0 pl-3 pr-4">
@ -346,7 +357,7 @@ export function ProjectPageHeader({
onNewReview: () => void;
}) {
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 className="flex items-center gap-1.5 text-2xl font-medium font-serif">
<button
@ -393,7 +404,7 @@ export function ProjectPageHeader({
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-4">
<HeaderSearchBtn
value={search}
onChange={onSearchChange}
@ -410,7 +421,7 @@ export function ProjectPageHeader({
<div className="relative group">
<button
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
? "text-gray-500 hover:text-gray-900 cursor-pointer"
: "text-gray-300 cursor-default"
@ -429,7 +440,7 @@ export function ProjectPageHeader({
onClick={() =>
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
? "text-gray-500 hover:text-gray-900 cursor-pointer"
: "text-gray-300 cursor-default"

View file

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

View file

@ -177,7 +177,7 @@ export function ProjectsOverview() {
}
const toolbarActions = (
<div className="flex items-center gap-2">
<>
{selectedIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
@ -199,13 +199,13 @@ export function ProjectsOverview() {
)}
</div>
)}
</div>
</>
);
return (
<div className="flex-1 overflow-y-auto bg-white">
{/* 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">
Projects
</h1>
@ -235,7 +235,7 @@ export function ProjectsOverview() {
<div className="w-full overflow-x-auto">
<div className="min-w-max">
{/* 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`}>
{!loading && (
<input
@ -267,7 +267,7 @@ export function ProjectsOverview() {
{[1, 2, 3].map((i) => (
<div
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="flex-1 min-w-0 pl-3 pr-4">
@ -341,7 +341,7 @@ export function ProjectsOverview() {
if (renamingId === project.id) return;
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
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>
{/* 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 ? (
<input
autoFocus

View file

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

View file

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

View file

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

View file

@ -47,7 +47,7 @@ export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }:
) : (
<button
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" />
</button>

View file

@ -20,7 +20,7 @@ export function ToolbarTabs<T extends string>({
actions,
}: Props<T>) {
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">
{tabs.map((tab) => (
<button
@ -37,7 +37,7 @@ export function ToolbarTabs<T extends string>({
))}
</div>
{actions && (
<div className="flex items-center gap-1">{actions}</div>
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
);

View file

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

View file

@ -1,7 +1,7 @@
"use client";
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 { TabularCell as TabularCellComponent } from "./TabularCell";
import { TREditColumnMenu } from "./TREditColumnMenu";
@ -30,6 +30,8 @@ interface Props {
savingColumn: boolean;
savingColumnsConfig: boolean;
selectedDocIds: string[];
uploadingFilenames?: string[];
dragOverFiles?: boolean;
highlightedCell?: { colIdx: number; rowIdx: number } | null;
onSelectionChange: (ids: string[]) => void;
onExpand: (cell: TabularCell) => void;
@ -49,6 +51,8 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
savingColumn,
savingColumnsConfig,
selectedDocIds,
uploadingFilenames = [],
dragOverFiles = false,
highlightedCell,
onSelectionChange,
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 (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center border-b border-gray-200">
@ -177,28 +185,33 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
</div>
<div className="flex-1" />
</div>
<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" />
<p className="text-2xl font-medium font-serif text-gray-900">
Tabular Review
</p>
<p className="mt-1 text-xs text-gray-400 text-left">
Add columns and documents to get started.
</p>
<div className="mt-4 flex items-center gap-2">
<button
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"
>
+ Add Columns
</button>
<button
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"
>
<Plus className="h-3.5 w-3.5" />
Add Documents
</button>
<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" />
<p className="text-2xl font-medium font-serif text-gray-900">
Tabular Review
</p>
<p className="mt-1 text-xs text-gray-400 text-left">
Add columns and documents to get started.
</p>
<div className="mt-4 flex items-center gap-2">
<button
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"
>
+ Add Columns
</button>
<button
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"
>
<Upload className="h-3.5 w-3.5" />
Add Documents
</button>
</div>
</div>
</div>
</div>
@ -206,7 +219,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
}
return (
<div className="flex-1 overflow-auto" ref={scrollContainerRef}>
<div
className="flex flex-1 flex-col overflow-auto"
ref={scrollContainerRef}
>
{/* Header */}
<div
className="sticky top-0 z-20 flex bg-white h-8"
@ -258,69 +274,114 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
</div>
{/* Rows */}
{documents.map((doc, docIdx) => {
const rowBg = selectedDocIds.includes(doc.id)
? "bg-gray-100"
: docIdx % 2 === 0
? "bg-white"
: "bg-gray-50";
return (
<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={doc.id}
className={`flex ${rowBg}`}
key={`uploading-${filename}`}
className="flex bg-white"
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`}
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center bg-white`}
>
<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"
disabled
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
/>
</div>
<div
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-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}>
{doc.filename}
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
<span className="line-clamp-1" title={filename}>
{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>
);
})}
{sortedColumns.map((col) => (
<div
key={col.index}
className={`${COL_W} border-b border-r border-gray-200 p-2`}
>
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse" />
</div>
))}
<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 ? "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>
);
});

View file

@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
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 {
@ -13,6 +13,7 @@ import {
regenerateTabularCell,
streamTabularGeneration,
updateTabularReview,
uploadReviewDocument,
} from "@/app/lib/mikeApi";
import type {
ColumnConfig,
@ -70,6 +71,10 @@ export function TRView({ reviewId, projectId }: Props) {
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [search, setSearch] = useState("");
const [dragOverReviewFiles, setDragOverReviewFiles] = useState(false);
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
string[]
>([]);
const searchParams = useSearchParams();
const initialChatParamRef = useRef<string | null>(
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) {
if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) {
setApiKeyModalProvider(getModelProvider(tabularModel));
@ -441,19 +473,30 @@ export function TRView({ reviewId, projectId }: Props) {
}
async function handleDeleteDocuments() {
const idsToDelete = [...selectedDocIds];
if (idsToDelete.length === 0) return;
const previousDocuments = documents;
const previousCells = cells;
const remaining = documents.filter(
(d) => !selectedDocIds.includes(d.id),
(d) => !idsToDelete.includes(d.id),
);
setDocuments(remaining);
setCells((prev) =>
prev.filter((c) => !selectedDocIds.includes(c.document_id)),
prev.filter((c) => !idsToDelete.includes(c.document_id)),
);
setSelectedDocIds([]);
setActionsOpen(false);
await updateTabularReview(reviewId, {
document_ids: remaining.map((d) => d.id),
columns_config: columns,
});
try {
await updateTabularReview(reviewId, {
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() {
@ -486,7 +529,7 @@ export function TRView({ reviewId, projectId }: Props) {
<div className="flex h-full overflow-hidden bg-white">
<div className="flex flex-1 flex-col overflow-hidden">
{/* 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">
{projectId && (
<>
@ -614,7 +657,7 @@ export function TRView({ reviewId, projectId }: Props) {
</div>
{/* 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
onClick={() => {
if (!chatOpen) setSidebarOpen(false);
@ -631,8 +674,14 @@ export function TRView({ reviewId, projectId }: Props) {
<MessageSquare className="h-3.5 w-3.5" />
Assistant in Tabular Review
</button>
<div className="ml-auto flex items-center gap-4">
{selectedDocIds.length > 0 && (
<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)}
@ -659,32 +708,34 @@ export function TRView({ reviewId, projectId }: Props) {
)}
</div>
)}
<button
onClick={() => setAddDocsOpen(true)}
disabled={loading || savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
loading || savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Plus className="h-3.5 w-3.5" />
Add Documents
</button>
<button
onClick={() => setAddColOpen(true)}
disabled={
loading || savingColumn || savingColumnsConfig
}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
loading || 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>
{!loading && (
<>
<button
onClick={() => setAddDocsOpen(true)}
disabled={savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Upload className="h-3.5 w-3.5" />
Add Documents
</button>
<button
onClick={() => setAddColOpen(true)}
disabled={savingColumn || savingColumnsConfig}
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
savingColumn || savingColumnsConfig
? "text-gray-300 cursor-default"
: "text-gray-700 hover:text-gray-900"
}`}
>
<Plus className="h-3.5 w-3.5" />
Add Columns
</button>
</>
)}
</div>
</div>
@ -706,30 +757,60 @@ export function TRView({ reviewId, projectId }: Props) {
onChatIdChange={setSelectedChatId}
/>
)}
<TRTable
ref={tableRef}
loading={loading}
columns={columns}
documents={filteredDocuments}
cells={cells}
highlightedCell={highlightedCell}
savingColumn={savingColumn}
savingColumnsConfig={savingColumnsConfig}
selectedDocIds={selectedDocIds}
onSelectionChange={setSelectedDocIds}
onExpand={(cell) => {
setExpandedCell(cell);
setExpandedCellCitation(undefined);
<div
className="relative flex flex-1 overflow-hidden"
onDragOver={(e) => {
if (!hasFilePayload(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDragOverReviewFiles(true);
}}
onCitationClick={(cell, page, quote) => {
setExpandedCell(cell);
setExpandedCellCitation({ quote, page });
onDragLeave={(e) => {
if (
!e.currentTarget.contains(
e.relatedTarget as Node,
)
) {
setDragOverReviewFiles(false);
}
}}
onUpdateColumn={handleUpdateColumn}
onDeleteColumn={handleDeleteColumn}
onAddColumn={() => setAddColOpen(true)}
onAddDocuments={() => setAddDocsOpen(true)}
/>
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>

View file

@ -34,7 +34,7 @@ const CHECK_W = "w-8 shrink-0";
const NAME_COL_W = "w-[300px] shrink-0";
const TABS: { id: Tab; label: string }[] = [
{ id: "all", label: "All Workflows" },
{ id: "all", label: "All" },
{ id: "builtin", label: "Built-in" },
{ id: "custom", label: "Custom" },
{ id: "hidden", label: "Hidden" },
@ -319,7 +319,7 @@ export function WorkflowList() {
);
const toolbarActions = (
<div className="flex items-center gap-2">
<>
{selectedIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
@ -350,15 +350,17 @@ export function WorkflowList() {
)}
</div>
)}
{typeFilterButton}
{practiceFilterButton}
</div>
<div className="flex items-center gap-5">
{typeFilterButton}
{practiceFilterButton}
</div>
</>
);
return (
<div className="flex flex-col flex-1 overflow-hidden bg-white">
{/* 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">
Workflows
</h1>
@ -388,7 +390,7 @@ export function WorkflowList() {
<div className="flex-1 overflow-auto">
<div className="min-w-max">
{/* 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`}>
{!loading && (
<input
@ -416,7 +418,7 @@ export function WorkflowList() {
{[1, 2, 3].map((i) => (
<div
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="flex-1 min-w-0 pl-3 pr-4">
@ -489,7 +491,7 @@ export function WorkflowList() {
<div
key={wf.id}
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
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 {
chats: MikeChat[] | null;
hasMoreChats: boolean;
currentChatId: string | null;
setCurrentChatId: (chatId: string | null) => void;
loadChats: () => Promise<void>;
loadMoreChats: () => void;
saveChat: (projectId?: string) => Promise<string | null>;
renameChat: (chatId: string, title: string) => Promise<void>;
newChatMessages: MikeMessage[] | null;
@ -39,9 +41,14 @@ const ChatHistoryContext = createContext<ChatHistoryContextType | undefined>(
undefined,
);
const INITIAL_CHAT_LIMIT = 20;
const CHAT_LIMIT_INCREMENT = 10;
export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
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 [newChatMessages, setNewChatMessages] = useState<
MikeMessage[] | null
@ -50,20 +57,25 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const loadChats = useCallback(async () => {
if (!user) {
setChats([]);
setHasMoreChats(false);
return;
}
try {
const data = await listChats();
setChats(data);
const data = await listChats({ limit: chatLimit + 1 });
setChats(data.slice(0, chatLimit));
setHasMoreChats(data.length > chatLimit);
} catch {
setChats([]);
setHasMoreChats(false);
}
}, [user]);
}, [chatLimit, user]);
useEffect(() => {
if (!user) {
setChats([]);
setChatLimit(INITIAL_CHAT_LIMIT);
setHasMoreChats(false);
setCurrentChatId(null);
return;
}
@ -71,6 +83,10 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
void loadChats();
}, [user, loadChats]);
const loadMoreChats = useCallback(() => {
setChatLimit((prev) => prev + CHAT_LIMIT_INCREMENT);
}, []);
const replaceChatId = useCallback(
(oldChatId: string, newChatId: string, title?: string) => {
if (!oldChatId || !newChatId || oldChatId === newChatId) {
@ -154,9 +170,11 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const value = useMemo(
() => ({
chats,
hasMoreChats,
currentChatId,
setCurrentChatId,
loadChats,
loadMoreChats,
saveChat,
renameChat: renameChatFn,
newChatMessages,
@ -166,8 +184,10 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
}),
[
chats,
hasMoreChats,
currentChatId,
loadChats,
loadMoreChats,
saveChat,
renameChatFn,
newChatMessages,

View file

@ -428,8 +428,11 @@ export async function createChat(payload?: {
});
}
export async function listChats(): Promise<MikeChat[]> {
return apiRequest<MikeChat[]>("/chat");
export async function listChats(options?: { limit?: number }): Promise<MikeChat[]> {
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[]> {