DocDoeAI / src /components /study-chat /LeftWorkspaceRail.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
12.2 kB
"use client";
import Image from "next/image";
import NextLink from "next/link";
import { BookOpen, FileText, Folder, GraduationCap, MessageCircle, Plus, Settings, Upload, User, Video } from "lucide-react";
import { useBackendHealth } from "@/hooks/useBackendHealth";
import type { ChatSessionOut } from "@/lib/api/chatHistory";
import type { ApiDocument } from "@/lib/api/types";
import { cn } from "@/lib/utils";
import { WorkspaceSearch } from "./WorkspacePanelPrimitives";
function dateGroupLabel(date: Date): string | null {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dateStart = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateStart.getTime() === today.getTime()) return "Today";
if (dateStart.getTime() === yesterday.getTime()) return "Yesterday";
return null; // Older — no label needed, just an "Older" header
}
function groupSessionsByDate(sessions: ChatSessionOut[]): [string, ChatSessionOut[]][] {
const groups: Map<string, ChatSessionOut[]> = new Map();
for (const session of sessions) {
const d = new Date(session.updated_at);
const label = dateGroupLabel(d) ?? "Older";
const list = groups.get(label);
if (list) list.push(session);
else groups.set(label, [session]);
}
const order = ["Today", "Yesterday", "Older"];
return order.filter((key) => groups.has(key)).map((key) => [key, groups.get(key)!]);
}
type LeftWorkspaceRailProps = {
documents: ApiDocument[];
sessions: ChatSessionOut[];
selectedSourceIds: string[];
search: string;
onSearchChange: (value: string) => void;
onNewChat: () => void;
onOpenSession: (sessionId: string) => void;
onToggleSource: (id: string) => void;
onUpload: () => void;
isHistoryLoading: boolean;
};
export function LeftWorkspaceRail({
documents,
sessions,
selectedSourceIds,
search,
onSearchChange,
onNewChat,
onOpenSession,
onToggleSource,
onUpload,
isHistoryLoading,
}: LeftWorkspaceRailProps) {
const { health } = useBackendHealth();
const query = search.trim().toLowerCase();
const isSearching = query.length > 0;
const filteredSessions = isSearching
? sessions.filter((session) => session.title.toLowerCase().includes(query) || session.subject.toLowerCase().includes(query))
: sessions;
// The search box promises "chats OR sources" — actually filter sources too.
const recentSources = isSearching
? documents.filter(
(document) =>
document.title.toLowerCase().includes(query) ||
(document.subject ?? "").toLowerCase().includes(query),
)
: documents.slice(0, 5);
const noSearchResults = isSearching && filteredSessions.length === 0 && recentSources.length === 0;
return (
<aside className="hidden min-h-0 w-[248px] shrink-0 flex-col border-r border-white/[0.075] bg-[#050505]/94 p-3 lg:flex">
<div className="flex items-center gap-3 px-2 py-3">
<span className="relative grid size-11 shrink-0 place-items-center overflow-hidden rounded-2xl border border-white/[0.12] bg-[#0a0a0a] shadow-[0_0_24px_rgba(255,255,255,0.06)]">
<Image
src="/docdoe-logo-mark.png"
alt="DocDoe"
width={36}
height={36}
className="object-contain"
priority
/>
</span>
<div className="min-w-0">
<p className="truncate text-[15px] font-bold text-white">DocDoe</p>
{/* Status is only worth a line when something is wrong — "Ready" is noise. Mono only, no traffic-light colours. */}
{health !== "online" ? (
<p className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/38">
<span
className={cn("size-1.5 rounded-full", health === "offline" ? "bg-white/60" : "animate-pulse bg-white/30")}
aria-hidden="true"
/>
{health === "offline" ? "Offline" : "Connecting"}
</p>
) : (
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-white/38">Study Chat</p>
)}
</div>
</div>
<button
type="button"
onClick={onNewChat}
className="mt-3 inline-flex min-h-11 items-center justify-center gap-2 rounded-2xl bg-white px-4 text-sm font-bold text-black transition hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
>
<Plus className="size-4" />
New study chat
</button>
<NextLink
href="/tuition"
className="mt-2 inline-flex min-h-11 items-center justify-center gap-2 rounded-2xl border border-white/[0.1] bg-white/[0.035] px-4 text-sm font-bold text-white transition hover:border-white/[0.18] hover:bg-white/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
<GraduationCap className="size-4" />
Continue Today&apos;s Tuition
</NextLink>
<NextLink
href="/learn-anything"
className="mt-2 inline-flex min-h-10 items-center justify-center gap-2 rounded-2xl px-4 text-[13px] font-semibold text-white/60 transition hover:bg-white/[0.05] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
<BookOpen className="size-4" />
Learn Anything
</NextLink>
<div className="mt-3">
<WorkspaceSearch value={search} onChange={onSearchChange} placeholder="Search chats or sources" />
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{noSearchResults ? (
<p className="rounded-xl px-2 py-3 text-xs leading-5 text-white/40">
No chats or sources match &ldquo;{search.trim()}&rdquo;.
</p>
) : null}
{!noSearchResults && (filteredSessions.length > 0 || !isSearching) ? (
<div>
<p className="px-2 text-[10px] font-bold uppercase tracking-[0.16em] text-white/34">{isSearching ? "Matching chats" : "Chat history"}</p>
<div className="mt-2 space-y-1.5">
{isHistoryLoading ? (
Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-10 animate-pulse rounded-xl bg-white/[0.035]" />
))
) : filteredSessions.length > 0 ? (
isSearching ? (
filteredSessions.slice(0, 12).map((session) => (
<button
key={session.id}
type="button"
onClick={() => onOpenSession(session.id)}
className="flex w-full min-w-0 items-center gap-2 rounded-xl px-2 py-2 text-left text-sm text-white/58 transition hover:bg-white/[0.045] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
<MessageCircle className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{session.title || "Study chat"}</span>
<span className="text-[10px] text-white/30">{session.message_count}</span>
</button>
))
) : (
groupSessionsByDate(filteredSessions).map(([label, groupSessions]) => (
<div key={label}>
<p className="px-2 pb-1 pt-2 text-[10px] font-bold uppercase tracking-[0.16em] text-white/34">{label}</p>
{groupSessions.slice(0, 5).map((session) => (
<button
key={session.id}
type="button"
onClick={() => onOpenSession(session.id)}
className="flex w-full min-w-0 items-center gap-2 rounded-xl px-2 py-2 text-left text-sm text-white/58 transition hover:bg-white/[0.045] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
<MessageCircle className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{session.title || "Study chat"}</span>
<span className="text-[10px] text-white/30">{session.message_count}</span>
</button>
))}
</div>
))
)
) : (
<p className="rounded-xl px-2 py-3 text-xs leading-5 text-white/34">Saved chats appear here after DocDoe answers.</p>
)}
</div>
</div>
) : null}
{!noSearchResults && (recentSources.length > 0 || !isSearching) ? (
<div className="mt-5">
<p className="px-2 text-[10px] font-bold uppercase tracking-[0.16em] text-white/34">{isSearching ? "Matching sources" : "Recent sources"}</p>
<div className="mt-2 space-y-1.5">
{recentSources.length > 0 ? (
recentSources.map((document) => {
const active = selectedSourceIds.includes(document.id);
return (
<button
key={document.id}
type="button"
onClick={() => onToggleSource(document.id)}
className={cn(
"flex w-full min-w-0 items-center gap-2 rounded-xl px-2 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",
active ? "bg-white/[0.08] text-white" : "text-white/52 hover:bg-white/[0.045] hover:text-white",
)}
>
<FileText className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm">{document.title}</span>
<span className={cn("size-1.5 rounded-full", document.status === "ready" ? "bg-white/56" : "bg-white/22")} />
</button>
);
})
) : (
<button
type="button"
onClick={onUpload}
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm text-white/48 transition hover:bg-white/[0.045] hover:text-white"
>
<Upload className="size-3.5" />
Upload first source
</button>
)}
</div>
</div>
) : null}
<div className="mt-5">
<p className="px-2 text-[10px] font-bold uppercase tracking-[0.16em] text-white/34">Folders and missions</p>
<div className="mt-2 space-y-1.5">
{[
{ label: "Today's Tuition", href: "/tuition", icon: GraduationCap },
{ label: "Source library", href: "/sources", icon: Folder },
{ label: "Video outputs", href: "/videos", icon: Video },
].map((item) => {
const Icon = item.icon;
return (
<NextLink
key={item.href}
href={item.href}
className="flex items-center gap-2 rounded-xl px-2 py-2 text-sm text-white/52 transition hover:bg-white/[0.045] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
<Icon className="size-3.5" />
{item.label}
</NextLink>
);
})}
</div>
</div>
</div>
<div className="mt-3 border-t border-white/[0.06] pt-3">
<NextLink
href="/settings"
className="flex min-h-11 items-center gap-3 rounded-2xl px-2 text-sm font-semibold text-white/58 transition hover:bg-white/[0.045] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
<User className="size-4" />
Profile and settings
<Settings className="ml-auto size-3.5 text-white/34" />
</NextLink>
</div>
</aside>
);
}