mirror of
https://github.com/willchen96/mike.git
synced 2026-06-14 20:55:13 +02:00
Update document UI, tabular reviews, and storage caching
This commit is contained in:
parent
2bbb628891
commit
4f3384334a
26 changed files with 856 additions and 341 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue