Download src/components/study-chat/LeftWorkspaceRail.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 12.2 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/LeftWorkspaceRail.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/study-chat/LeftWorkspaceRail.tsx
-
curl -L -o LeftWorkspaceRail.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/LeftWorkspaceRail.tsx
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'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 “{search.trim()}”. | |
| </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> | |
| ); | |
| } | |