diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04be698..5fbd2ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: diff --git a/backend/schema.sql b/backend/schema.sql index cc9b9ce..b6a4e93 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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, diff --git a/backend/src/lib/convert.ts b/backend/src/lib/convert.ts index 899bbd7..056f6b8 100644 --- a/backend/src/lib/convert.ts +++ b/backend/src/lib/convert.ts @@ -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 | void; + _convert = (buf, ext, filter) => + new Promise((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; } diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts index f5035a3..dc28db2 100644 --- a/backend/src/lib/storage.ts +++ b/backend/src/lib/storage.ts @@ -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 { + requireStorageConfig(); const client = getClient(); await client.send( new PutObjectCommand({ diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index fe272c6..9a39e0a 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -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 ?? []); }); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index e73454d..6cf6495 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -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 = {}; + const reviewsWithExplicitDocs = new Set(); + 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[] }; + : { data: [] as Record[] }; 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 diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index 93f2626..d21c747 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -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 ( { setIsSidebarOpen(open); setIsSidebarOpenDesktop(open); } }} + value={{ + setSidebarOpen: (open) => { + setIsSidebarOpen(open); + setIsSidebarOpenDesktop(open); + }, + }} >
@@ -87,12 +92,14 @@ export default function MikeLayout({ />
{/* Mobile header */} -
+
diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx index 61e47fd..42bd17b 100644 --- a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx @@ -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(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 (
-
+

`${k}=${v}`) - .sort() - .join(",")}`, + .map(([k, v]) => `${k}=${v}`) + .sort() + .join(",")}`, ].join("|"); }, [messages]); @@ -1007,8 +1008,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
{ - tabItemRefs.current[tab.documentId] = - el; + tabItemRefs.current[ + tab.documentId + ] = el; }} onClick={() => switchTab(tab.documentId) diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index c9757ec..9d6786d 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -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 = ( -
+ <> {selectedIds.length > 0 && (
+ ); return (
{/* Page header */} -
+

Tabular Reviews

@@ -298,7 +298,7 @@ export default function TabularReviewsPage() { {/* Table */}
-
+
{!loading && ( (
@@ -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" >
-
+
{renamingId === review.id ? ( (function ChatInput( )} /> )} - {onProjectsClick && ( - - )} {!hideWorkflowButton && ( )} + {onProjectsClick && ( + + )}
diff --git a/frontend/src/app/components/projects/ProjectAssistantTab.tsx b/frontend/src/app/components/projects/ProjectAssistantTab.tsx index 1c2212b..8cf230c 100644 --- a/frontend/src/app/components/projects/ProjectAssistantTab.tsx +++ b/frontend/src/app/components/projects/ProjectAssistantTab.tsx @@ -118,11 +118,7 @@ export function ProjectAssistantTab({ />
{renamingChatId === chat.id ? ( (null); const [dragOverFolderId, setDragOverFolderId] = useState(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) => ( +
+
+ +
+
+
+ + + {filename} + +
+
+
+ {filename.includes(".") ? filename.split(".").pop() : "file"} +
+
+ Uploading +
+
+
+
+
+
+ )); + } + 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" />
-
+
{isProcessing ? ( @@ -1150,20 +1221,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { ) : null; const toolbarActions = ( -
+
{actionsDropdown} {tab === "documents" && ( <>
{/* Blue ring wraps everything below the header when root-dropping */} -
+
{ + 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 && (
)} + {dragOverFileRoot && ( +
+ )} {/* Empty state */} - {docs.length === 0 && folders.length === 0 ? ( + {docs.length === 0 && + folders.length === 0 && + uploadingDroppedFilenames.length === 0 ? (
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 ( -
+ <> + {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 ( +
{ 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" />
-
+
{isProcessing ? : isError ? : } {renamingDocumentId === doc.id ? ( @@ -1423,9 +1525,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) { } /> )} -
- ); - }) +
+ ); + })} + ) : ( renderLevel(null, 0) )} diff --git a/frontend/src/app/components/projects/ProjectPageParts.tsx b/frontend/src/app/components/projects/ProjectPageParts.tsx index 3de9cab..2c30a38 100644 --- a/frontend/src/app/components/projects/ProjectPageParts.tsx +++ b/frontend/src/app/components/projects/ProjectPageParts.tsx @@ -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" >
- + + ↳ + {isEditing ? ( { 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({ {dateLabel} - · + + · + {v.source} @@ -265,23 +270,29 @@ export function DocVersionHistory({ export function ProjectPageSkeleton() { return (
-
+
Projects
-
-
+
+
+
+
-
+
+
+
+
+
-
+
@@ -297,7 +308,7 @@ export function ProjectPageSkeleton() { {[1, 2, 3, 4, 5].map((i) => (
@@ -346,7 +357,7 @@ export function ProjectPageHeader({ onNewReview: () => void; }) { return ( -
+
-
+
{renamingReviewId === review.id ? ( + <> {selectedIds.length > 0 && (
+ ); return (
{/* Page header */} -
+

Projects

@@ -235,7 +235,7 @@ export function ProjectsOverview() {
{/* Column headers */} -
+
{!loading && ( (
@@ -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" >
{/* Project Name */} -
+
{renamingId === project.id ? ( >(new Set()); const [uploading, setUploading] = useState(false); + const [uploadingFilenames, setUploadingFilenames] = useState([]); const [search, setSearch] = useState(""); const [extraUploadedDocs, setExtraUploadedDocs] = useState([]); // 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) { 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} />
diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index 3092810..36a0269 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -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>( {}, ); + const [recentProjects, setRecentProjects] = useState( + null, + ); useEffect(() => { if (!user) return; @@ -52,8 +63,20 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { const map: Record = {}; 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 */}
@@ -152,7 +175,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { const isActive = pathname === href || pathname.startsWith(href + "/"); return ( -
+
-
- {!chats ? ( -
- {[40, 60, 50, 70, 45].map((w, i) => ( -
-
+ {isOpen && ( +
+ {/* Recent Projects */} +
+ + {!projectsCollapsed && ( + <> + {!recentProjects ? ( +
+ {[50, 65, 45].map((w, i) => ( +
+
+
+ ))}
- ))} -
- ) : chats.length === 0 ? ( -
- No chats yet -
- ) : ( -
- {chats.map((chat) => ( - { - setCurrentChatId(chat.id); - router.push( - chat.project_id - ? `/projects/${chat.project_id}/assistant/chat/${chat.id}` - : `/assistant/chat/${chat.id}`, + ) : recentProjects.length === 0 ? ( +
+ No projects yet +
+ ) : ( +
+ {recentProjects.map((project) => { + const isActive = + pathname === + `/projects/${project.id}` || + pathname.startsWith( + `/projects/${project.id}/`, + ); + return ( + ); - }} - /> - ))} -
+ })} +
+ )} + )}
+ + {/* Assistant History */} +
+ +
+ {!chats ? ( +
+ {[40, 60, 50, 70, 45].map((w, i) => ( +
+
+
+ ))} +
+ ) : chats.length === 0 ? ( +
+ No chats yet +
+ ) : ( + <> +
+ {chats.map((chat) => ( + { + setCurrentChatId(chat.id); + router.push( + chat.project_id + ? `/projects/${chat.project_id}/assistant/chat/${chat.id}` + : `/assistant/chat/${chat.id}`, + ); + }} + /> + ))} +
+ {hasMoreChats && ( +
+ +
+ )} + + )} +
+
)} diff --git a/frontend/src/app/components/shared/FileDirectory.tsx b/frontend/src/app/components/shared/FileDirectory.tsx index ee04819..fde1a3d 100644 --- a/frontend/src/app/components/shared/FileDirectory.tsx +++ b/frontend/src/app/components/shared/FileDirectory.tsx @@ -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; + 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>( 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 (

{emptyMessage} @@ -154,6 +161,7 @@ export function FileDirectory({

{(standaloneDocs.length > 0 || + uploadingFilenames.length > 0 || (onDelete && selectedCount > 0)) && (

@@ -185,6 +193,21 @@ export function FileDirectory({

)} + {uploadingFilenames.map((filename) => ( +
+ + + + {filename} + + + Uploading + +
+ ))} {standaloneDocs.map((doc) => { const selected = selectedIds.has(doc.id); return ( diff --git a/frontend/src/app/components/shared/HeaderSearchBtn.tsx b/frontend/src/app/components/shared/HeaderSearchBtn.tsx index a1b00c9..ddc2e5d 100644 --- a/frontend/src/app/components/shared/HeaderSearchBtn.tsx +++ b/frontend/src/app/components/shared/HeaderSearchBtn.tsx @@ -47,7 +47,7 @@ export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }: ) : ( diff --git a/frontend/src/app/components/shared/ToolbarTabs.tsx b/frontend/src/app/components/shared/ToolbarTabs.tsx index e93f9ef..dad69be 100644 --- a/frontend/src/app/components/shared/ToolbarTabs.tsx +++ b/frontend/src/app/components/shared/ToolbarTabs.tsx @@ -20,7 +20,7 @@ export function ToolbarTabs({ actions, }: Props) { return ( -
+
{tabs.map((tab) => (
{actions && ( -
{actions}
+
{actions}
)}
); diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index 2fa4d6d..d7bac4e 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -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. */ diff --git a/frontend/src/app/components/tabular/TRTable.tsx b/frontend/src/app/components/tabular/TRTable.tsx index 6c7c97e..43ab9d1 100644 --- a/frontend/src/app/components/tabular/TRTable.tsx +++ b/frontend/src/app/components/tabular/TRTable.tsx @@ -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(function TRTable( savingColumn, savingColumnsConfig, selectedDocIds, + uploadingFilenames = [], + dragOverFiles = false, highlightedCell, onSelectionChange, onExpand, @@ -165,7 +169,11 @@ export const TRTable = forwardRef(function TRTable( ); } - if (columns.length === 0 && documents.length === 0) { + if ( + columns.length === 0 && + documents.length === 0 && + uploadingFilenames.length === 0 + ) { return (
@@ -177,28 +185,33 @@ export const TRTable = forwardRef(function TRTable(
-
- -

- Tabular Review -

-

- Add columns and documents to get started. -

-
- - +
+ {dragOverFiles && ( +
+ )} +
+ +

+ Tabular Review +

+

+ Add columns and documents to get started. +

+
+ + +
@@ -206,7 +219,10 @@ export const TRTable = forwardRef(function TRTable( } return ( -
+
{/* Header */}
(function TRTable(
{/* Rows */} - {documents.map((doc, docIdx) => { - const rowBg = selectedDocIds.includes(doc.id) - ? "bg-gray-100" - : docIdx % 2 === 0 - ? "bg-white" - : "bg-gray-50"; - return ( +
+ {dragOverFiles && ( +
+ )} + {uploadingFilenames.map((filename) => (
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" />
- - {doc.filename} + + + {filename}
- {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 ( -
- {cell && ( - onExpand(cell)} - onCitationClick={(page, quote) => - onCitationClick( - cell, - page, - quote, - ) - } - /> - )} -
- ); - })} + {sortedColumns.map((col) => ( +
+
+
+ ))}
- ); - })} + ))} + {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 ( +
+
+ toggleDoc(doc.id)} + className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" + /> +
+
+ + {doc.filename} + +
+ {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 ( +
+ {cell && ( + onExpand(cell)} + onCitationClick={( + page, + quote, + ) => + onCitationClick( + cell, + page, + quote, + ) + } + /> + )} +
+ ); + })} +
+
+ ); + })} +
); }); diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 4bdaecb..cd59d12 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -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([]); 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( 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) {
{/* Header */} -
+
{projectId && ( <> @@ -614,7 +657,7 @@ export function TRView({ reviewId, projectId }: Props) {
{/* Toolbar */} -
+
-
- {selectedDocIds.length > 0 && ( +
+ {loading ? ( + <> +
+
+ + ) : null} + {!loading && selectedDocIds.length > 0 && (
)} - - + {!loading && ( + <> + + + + )}
@@ -706,30 +757,60 @@ export function TRView({ reviewId, projectId }: Props) { onChatIdChange={setSelectedChatId} /> )} - { - setExpandedCell(cell); - setExpandedCellCitation(undefined); +
{ + 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), + ); + }} + > + { + setExpandedCell(cell); + setExpandedCellCitation(undefined); + }} + onCitationClick={(cell, page, quote) => { + setExpandedCell(cell); + setExpandedCellCitation({ quote, page }); + }} + onUpdateColumn={handleUpdateColumn} + onDeleteColumn={handleDeleteColumn} + onAddColumn={() => setAddColOpen(true)} + onAddDocuments={() => setAddDocsOpen(true)} + /> +
diff --git a/frontend/src/app/components/workflows/WorkflowList.tsx b/frontend/src/app/components/workflows/WorkflowList.tsx index a98d522..b0f1288 100644 --- a/frontend/src/app/components/workflows/WorkflowList.tsx +++ b/frontend/src/app/components/workflows/WorkflowList.tsx @@ -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 = ( -
+ <> {selectedIds.length > 0 && (
+
+ {typeFilterButton} + {practiceFilterButton} +
+ ); return (
{/* Page header */} -
+

Workflows

@@ -388,7 +390,7 @@ export function WorkflowList() {
{/* Column headers */} -
+
{!loading && ( (
@@ -489,7 +491,7 @@ export function WorkflowList() {
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" >
void; loadChats: () => Promise; + loadMoreChats: () => void; saveChat: (projectId?: string) => Promise; renameChat: (chatId: string, title: string) => Promise; newChatMessages: MikeMessage[] | null; @@ -39,9 +41,14 @@ const ChatHistoryContext = createContext( undefined, ); +const INITIAL_CHAT_LIMIT = 20; +const CHAT_LIMIT_INCREMENT = 10; + export function ChatHistoryProvider({ children }: { children: ReactNode }) { const { user } = useAuth(); const [chats, setChats] = useState(null); + const [chatLimit, setChatLimit] = useState(INITIAL_CHAT_LIMIT); + const [hasMoreChats, setHasMoreChats] = useState(false); const [currentChatId, setCurrentChatId] = useState(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, diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index eeea891..5b7e37e 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -428,8 +428,11 @@ export async function createChat(payload?: { }); } -export async function listChats(): Promise { - return apiRequest("/chat"); +export async function listChats(options?: { limit?: number }): Promise { + const params = new URLSearchParams(); + if (options?.limit) params.set("limit", String(options.limit)); + const query = params.toString(); + return apiRequest(`/chat${query ? `?${query}` : ""}`); } export async function listProjectChats(projectId: string): Promise {