Update document UI, tabular reviews, and storage caching

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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