Download src/components/study-chat/StudyChatWorkspace.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 73.7 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/StudyChatWorkspace.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/study-chat/StudyChatWorkspace.tsx
-
curl -L -o StudyChatWorkspace.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/StudyChatWorkspace.tsx
73.7 kB
| "use client"; | |
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| import Link from "next/link"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| import { | |
| ArrowRight, | |
| BookOpen, | |
| Check, | |
| ClipboardCheck, | |
| FileQuestion, | |
| FileText, | |
| ExternalLink, | |
| Globe2, | |
| GraduationCap, | |
| History, | |
| LoaderCircle, | |
| MessageSquareText, | |
| NotebookPen, | |
| Paperclip, | |
| Plus, | |
| RotateCcw, | |
| Send, | |
| Trash2, | |
| Upload, | |
| X, | |
| } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { DocDoeLogoMark } from "@/components/landing/DocDoeLogoMark"; | |
| import { deleteDocument, listDocuments } from "@/lib/api/documents"; | |
| import { PDF_UPLOAD_ACCEPT } from "@/lib/uploads/document-upload-contract"; | |
| import type { ApiDocument } from "@/lib/api/types"; | |
| import { hasLiveSession } from "@/lib/api/mode"; | |
| import { useStudyChatTutor, type TutorEntry, type UploadState } from "./useStudyChatTutor"; | |
| import { MaterialDeletePrompt } from "./MaterialDeletePrompt"; | |
| import { | |
| LEARNING_STATE_CHANGED_EVENT, | |
| useStudentWorkspace, | |
| } from "@/components/student-os/StudentWorkspaceProvider"; | |
| import type { WorkspaceNote } from "@/lib/api/student-workspace"; | |
| import { | |
| createStudyFollowUp, | |
| listOpenStudyFollowUps, | |
| } from "@/lib/api/learning-state"; | |
| import { | |
| getOperationUsage, | |
| formatStudyChatUsage, | |
| type OperationUsageItem, | |
| } from "@/lib/api/operation-usage"; | |
| type NoteSaveStatus = "idle" | "saving" | "saved" | "error"; | |
| type FollowUpSaveStatus = "idle" | "saving" | "saved" | "error"; | |
| /** Honest upload lifecycle banner: loading, processing, success, and retryable error. */ | |
| function UploadStatusBanner({ | |
| state, | |
| onRetry, | |
| className, | |
| }: { | |
| state: UploadState; | |
| onRetry: () => void; | |
| className?: string; | |
| }) { | |
| if (state.phase === "idle") return null; | |
| const busy = state.phase === "uploading" || state.phase === "processing"; | |
| const isError = state.phase === "error"; | |
| const isReady = state.phase === "ready"; | |
| const label = | |
| state.phase === "uploading" | |
| ? `Uploading ${state.fileName}…` | |
| : state.phase === "processing" | |
| ? `Reading ${state.fileName}… this can take a moment.` | |
| : state.phase === "ready" | |
| ? `${state.fileName} is ready — ask a question from it.` | |
| : state.message; | |
| return ( | |
| <div | |
| role={isError ? "alert" : "status"} | |
| className={cn( | |
| "mb-2 flex items-start gap-2 rounded-lg border px-2.5 py-2 text-xs", | |
| isError | |
| ? "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300" | |
| : isReady | |
| ? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300" | |
| : "border-neutral-300 bg-neutral-50 text-neutral-600 dark:border-white/15 dark:bg-white/5 dark:text-neutral-300", | |
| className, | |
| )} | |
| > | |
| {busy ? ( | |
| <LoaderCircle className="mt-0.5 size-3.5 shrink-0 animate-spin" /> | |
| ) : isReady ? ( | |
| <Check className="mt-0.5 size-3.5 shrink-0" /> | |
| ) : ( | |
| <FileText className="mt-0.5 size-3.5 shrink-0" /> | |
| )} | |
| <span className="min-w-0 flex-1 leading-5">{label}</span> | |
| {isError ? ( | |
| <button | |
| type="button" | |
| onClick={onRetry} | |
| className="inline-flex shrink-0 items-center gap-1 font-bold underline decoration-current/40 underline-offset-2" | |
| > | |
| <RotateCcw className="size-3" /> Retry | |
| </button> | |
| ) : null} | |
| </div> | |
| ); | |
| } | |
| const STUDY_TOOLS = [ | |
| { | |
| icon: FileQuestion, | |
| title: "Solve a PYQ", | |
| detail: "Method, marks and final answer", | |
| prompt: | |
| "Help me solve a previous year question. Ask me to upload or paste the question first.", | |
| }, | |
| { | |
| icon: GraduationCap, | |
| title: "Explain a derivation", | |
| detail: "Steps, formula and exam method", | |
| prompt: | |
| "Explain a derivation step by step and show the board exam writing method.", | |
| }, | |
| { | |
| icon: NotebookPen, | |
| title: "Make study notes", | |
| detail: "Clean notes from a topic or PDF", | |
| prompt: | |
| "Create clear board-ready notes. Ask me for the topic or material first.", | |
| }, | |
| { | |
| icon: ClipboardCheck, | |
| title: "Correct my answer", | |
| detail: "Marks, mistakes and improved answer", | |
| prompt: | |
| "Correct my answer like a Kerala board teacher. Ask for the question and my answer.", | |
| }, | |
| ] as const; | |
| const STARTER_PROMPTS = [ | |
| "Teach this concept from the basics", | |
| "Give me a board-aligned answer", | |
| "Test me with five questions", | |
| "Show a worked example", | |
| ] as const; | |
| const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat("en-IN", { | |
| month: "short", | |
| day: "numeric", | |
| timeZone: "Asia/Kolkata", | |
| }); | |
| function formatDate(value: string) { | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) return ""; | |
| return SHORT_DATE_FORMATTER.format(date); | |
| } | |
| function answerText(entry: Extract<TutorEntry, { role: "assistant" }>) { | |
| return entry.sections.length | |
| ? entry.sections | |
| .map((section) => `${section.title}\n${section.body}`) | |
| .join("\n\n") | |
| : entry.answer; | |
| } | |
| function WorkspaceMark({ className }: { className?: string } = {}) { | |
| return ( | |
| <div | |
| className={cn( | |
| "flex size-9 shrink-0 items-center justify-center rounded-lg bg-[#17181b] text-white dark:bg-neutral-100 dark:text-neutral-950 sm:size-10", | |
| className, | |
| )} | |
| > | |
| <DocDoeLogoMark className="size-[18px]" aria-hidden="true" /> | |
| </div> | |
| ); | |
| } | |
| function ContextSummary({ | |
| className, | |
| context, | |
| academicContext, | |
| }: { | |
| className?: string; | |
| context: ReturnType<typeof useStudyChatTutor>["context"]; | |
| academicContext: ReturnType<typeof useStudyChatTutor>["academicContext"]; | |
| }) { | |
| const academicItems = [ | |
| academicContext?.subject, | |
| academicContext?.chapter, | |
| academicContext?.source_label ?? academicContext?.topic, | |
| academicContext?.activity_type === "tuition-question" | |
| ? "Tuition question" | |
| : academicContext?.activity_type === "pyq-question" | |
| ? "PYQ question" | |
| : academicContext?.activity_type === "uploaded-material" | |
| ? "Uploaded material" | |
| : academicContext?.source_label, | |
| ].filter((item): item is string => Boolean(item)); | |
| const profileItems = [context.classLevel, context.board, context.subject].filter( | |
| (item): item is string => Boolean(item), | |
| ); | |
| const items = Array.from( | |
| new Set(academicItems.length ? academicItems : profileItems), | |
| ); | |
| return ( | |
| <div | |
| className={cn( | |
| "flex min-w-0 flex-wrap items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400", | |
| className, | |
| )} | |
| > | |
| <span | |
| className="size-1.5 shrink-0 rounded-full bg-emerald-500" | |
| aria-hidden | |
| /> | |
| <span | |
| className="truncate" | |
| title={ | |
| academicContext?.question_label ?? | |
| academicContext?.source_label ?? | |
| undefined | |
| } | |
| > | |
| {items.length ? items.join(" · ") : "General study"} | |
| </span> | |
| {academicContext?.return_href ? ( | |
| <Link | |
| href={academicContext.return_href} | |
| className="shrink-0 font-semibold text-neutral-800 underline decoration-neutral-300 underline-offset-4 dark:text-neutral-200 dark:decoration-neutral-700" | |
| > | |
| Return to {academicContext.origin === "tuition" ? "Tuition" : "previous work"} | |
| </Link> | |
| ) : null} | |
| </div> | |
| ); | |
| } | |
| export function StudyChatWorkspace() { | |
| const { data: workspaceData, updateWorkspace } = useStudentWorkspace(); | |
| const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>([]); | |
| const tutor = useStudyChatTutor(selectedSourceIds); | |
| const { | |
| message, | |
| setMessage, | |
| entries, | |
| isAsking, | |
| recentChats, | |
| context, | |
| academicContext, | |
| currentSessionId, | |
| uploadState, | |
| historySyncError, | |
| historyLoadError, | |
| isHistorySyncing, | |
| handleSend, | |
| handleCancel, | |
| handleUpload, | |
| retryHistorySync, | |
| openChat, | |
| startNewChat, | |
| saveAnswerAsNote, | |
| } = tutor; | |
| const [materials, setMaterials] = useState<ApiDocument[]>([]); | |
| const [materialsError, setMaterialsError] = useState<string | null>(null); | |
| const [savedAnswers, setSavedAnswers] = useState<Set<string>>( | |
| () => new Set(), | |
| ); | |
| const [noteSaveStates, setNoteSaveStates] = useState< | |
| Record<string, { status: NoteSaveStatus; message?: string }> | |
| >({}); | |
| const [followUpSaveStates, setFollowUpSaveStates] = useState< | |
| Record<string, { status: FollowUpSaveStatus; message?: string }> | |
| >({}); | |
| const [persistedFollowUpSources, setPersistedFollowUpSources] = useState<Set<string>>( | |
| () => new Set(), | |
| ); | |
| const pendingNotesRef = useRef(new Map<string, WorkspaceNote>()); | |
| const [mobilePanel, setMobilePanel] = useState<"history" | "tools" | null>( | |
| null, | |
| ); | |
| const closeMobilePanel = useCallback(() => setMobilePanel(null), []); | |
| const [loadingChatId, setLoadingChatId] = useState<string | null>(null); | |
| const [chatOpenError, setChatOpenError] = useState<string | null>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const conversationScrollRef = useRef<HTMLDivElement>(null); | |
| const conversationEndRef = useRef<HTMLDivElement>(null); | |
| const shouldFollowConversationRef = useRef(true); | |
| const [chatUsage, setChatUsage] = useState<OperationUsageItem | null>(null); | |
| const persistedSavedAnswers = useMemo( | |
| () => | |
| new Set( | |
| workspaceData.notes | |
| .map((note) => note.source_message_id) | |
| .filter((value): value is string => Boolean(value)), | |
| ), | |
| [workspaceData.notes], | |
| ); | |
| useEffect(() => { | |
| let cancelled = false; | |
| if (!hasLiveSession()) return; | |
| void getOperationUsage() | |
| .then((status) => { | |
| if (!cancelled) { | |
| setChatUsage( | |
| status.items.find((item) => item.operation === "study_chat_ai") ?? null, | |
| ); | |
| } | |
| }) | |
| .catch(() => {}); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [isAsking]); | |
| useEffect(() => { | |
| let cancelled = false; | |
| if (!hasLiveSession()) return; | |
| void listOpenStudyFollowUps() | |
| .then((items) => { | |
| if (cancelled) return; | |
| const sources = new Set<string>(); | |
| for (const item of items) { | |
| if (item.status !== "open") continue; | |
| if (typeof item.source_ref === "string" && item.source_ref.trim()) { | |
| sources.add(item.source_ref); | |
| } | |
| } | |
| setPersistedFollowUpSources(sources); | |
| }) | |
| .catch(() => undefined); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [currentSessionId]); | |
| const handleSaveAnswer = useCallback( | |
| async ( | |
| entryId: string, | |
| title: string, | |
| content: string, | |
| options?: { | |
| isFallback?: boolean; | |
| topic?: string; | |
| evidence?: Extract<TutorEntry, { role: "assistant" }>["evidence"]; | |
| }, | |
| ) => { | |
| if (savedAnswers.has(entryId) || persistedSavedAnswers.has(entryId)) return; | |
| let workspaceNote = pendingNotesRef.current.get(entryId); | |
| if (!workspaceNote) { | |
| const savedNote = saveAnswerAsNote(title, content, options); | |
| workspaceNote = { | |
| id: savedNote.id, | |
| title: savedNote.title, | |
| body: savedNote.content, | |
| subject: savedNote.subject ?? null, | |
| topic: savedNote.topic ?? null, | |
| source_id: options?.evidence?.source_ids[0] ?? savedNote.sourceId ?? null, | |
| source_label: options?.isFallback | |
| ? "StudyChat · offline fallback" | |
| : "StudyChat", | |
| favorite: false, | |
| chat_session_id: currentSessionId, | |
| source_message_id: entryId.startsWith("cmsg_") ? entryId : null, | |
| evidence: options?.evidence ?? null, | |
| created_at: savedNote.at, | |
| updated_at: savedNote.at, | |
| }; | |
| pendingNotesRef.current.set(entryId, workspaceNote); | |
| } | |
| setNoteSaveStates((current) => ({ | |
| ...current, | |
| [entryId]: { status: "saving" }, | |
| })); | |
| try { | |
| const note = workspaceNote; | |
| await updateWorkspace((current) => ({ | |
| ...current, | |
| notes: [ | |
| note, | |
| ...current.notes.filter((item) => item.id !== note.id), | |
| ], | |
| })); | |
| pendingNotesRef.current.delete(entryId); | |
| setSavedAnswers((current) => new Set(current).add(entryId)); | |
| setNoteSaveStates((current) => ({ | |
| ...current, | |
| [entryId]: { status: "saved" }, | |
| })); | |
| } catch { | |
| setNoteSaveStates((current) => ({ | |
| ...current, | |
| [entryId]: { | |
| status: "error", | |
| message: | |
| "Saved on this device. Retry when connected to sync it across devices.", | |
| }, | |
| })); | |
| } | |
| }, | |
| [currentSessionId, persistedSavedAnswers, saveAnswerAsNote, savedAnswers, updateWorkspace], | |
| ); | |
| const handlePlanFollowUp = useCallback( | |
| async (entryId: string, question: string) => { | |
| if (!currentSessionId || !hasLiveSession()) return; | |
| setFollowUpSaveStates((current) => ({ | |
| ...current, | |
| [entryId]: { status: "saving" }, | |
| })); | |
| const activityType = academicContext?.activity_type ?? ""; | |
| const kind = activityType.includes("pyq") | |
| ? "pyq_review" | |
| : activityType === "uploaded-material" | |
| ? "source_review" | |
| : "clarification"; | |
| const topic = academicContext?.topic ?? question.slice(0, 180); | |
| try { | |
| await createStudyFollowUp({ | |
| client_followup_id: `studychat:${currentSessionId}:${entryId}`.slice(0, 160), | |
| chat_session_id: currentSessionId, | |
| kind, | |
| title: `Follow up: ${topic || "Study Chat answer"}`.slice(0, 220), | |
| subject: academicContext?.subject ?? context.subject ?? null, | |
| chapter: academicContext?.chapter ?? null, | |
| topic: topic || null, | |
| due_at: new Date().toISOString(), | |
| estimated_minutes: 10, | |
| source_ref: entryId.slice(0, 180), | |
| target: { kind: "study_chat_follow_up" }, | |
| }); | |
| setFollowUpSaveStates((current) => ({ | |
| ...current, | |
| [entryId]: { status: "saved" }, | |
| })); | |
| setPersistedFollowUpSources((current) => { | |
| const next = new Set(current); | |
| next.add(entryId); | |
| return next; | |
| }); | |
| window.dispatchEvent(new Event(LEARNING_STATE_CHANGED_EVENT)); | |
| } catch { | |
| setFollowUpSaveStates((current) => ({ | |
| ...current, | |
| [entryId]: { | |
| status: "error", | |
| message: "Could not add this follow-up. Your chat is unchanged; retry when connected.", | |
| }, | |
| })); | |
| } | |
| }, | |
| [academicContext, context.subject, currentSessionId], | |
| ); | |
| useEffect(() => { | |
| let cancelled = false; | |
| void listDocuments() | |
| .then((items) => { | |
| if (cancelled) return; | |
| setMaterials(items); | |
| setMaterialsError(null); | |
| setSelectedSourceIds((current) => { | |
| const available = new Set(items.map((item) => item.id)); | |
| const retained = current.filter((id) => available.has(id)); | |
| // A freshly-processed upload auto-selects itself so the student can | |
| // immediately ask questions from it — but only once it is READY. | |
| if (uploadState.phase === "ready") { | |
| const readyDoc = items.find( | |
| (item) => item.id === uploadState.documentId && item.status === "ready", | |
| ); | |
| if (readyDoc && !retained.includes(readyDoc.id)) { | |
| return [...retained, readyDoc.id]; | |
| } | |
| } | |
| return retained; | |
| }); | |
| }) | |
| .catch(() => { | |
| if (!cancelled) { | |
| setMaterialsError( | |
| "We couldn't load your saved materials. Your files are safe — check your connection and try again.", | |
| ); | |
| } | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| // Re-list on each upload-phase change so processing→ready flips the material in. | |
| }, [uploadState.phase, uploadState]); | |
| const selectedMaterials = useMemo( | |
| () => | |
| materials.filter((material) => selectedSourceIds.includes(material.id)), | |
| [materials, selectedSourceIds], | |
| ); | |
| function toggleMaterial(id: string) { | |
| setSelectedSourceIds((current) => | |
| current.includes(id) | |
| ? current.filter((sourceId) => sourceId !== id) | |
| : [...current, id].slice(-4), | |
| ); | |
| } | |
| const [deletingMaterialId, setDeletingMaterialId] = useState<string | null>(null); | |
| const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); | |
| const [deleteMaterialError, setDeleteMaterialError] = useState<{ | |
| id: string; | |
| message: string; | |
| } | null>(null); | |
| async function removeMaterial(id: string) { | |
| setDeletingMaterialId(id); | |
| try { | |
| await deleteDocument(id); | |
| setMaterials((current) => current.filter((material) => material.id !== id)); | |
| setSelectedSourceIds((current) => current.filter((sourceId) => sourceId !== id)); | |
| setDeleteMaterialError(null); | |
| setConfirmDeleteId(null); | |
| } catch { | |
| setDeleteMaterialError({ | |
| id, | |
| message: | |
| "Could not delete this source. It is still available. Check your connection and retry.", | |
| }); | |
| setConfirmDeleteId(id); | |
| } finally { | |
| setDeletingMaterialId(null); | |
| } | |
| } | |
| useEffect(() => { | |
| if (!shouldFollowConversationRef.current) return; | |
| conversationEndRef.current?.scrollIntoView({ | |
| behavior: isAsking ? "auto" : "smooth", | |
| block: "end", | |
| }); | |
| }, [entries, isAsking]); | |
| function sendMessage(text?: string) { | |
| shouldFollowConversationRef.current = true; | |
| return handleSend(text); | |
| } | |
| function beginNewChat() { | |
| shouldFollowConversationRef.current = true; | |
| startNewChat(); | |
| } | |
| function handleConversationScroll() { | |
| const element = conversationScrollRef.current; | |
| if (!element) return; | |
| const distanceFromBottom = | |
| element.scrollHeight - element.scrollTop - element.clientHeight; | |
| shouldFollowConversationRef.current = distanceFromBottom <= 96; | |
| } | |
| const title = useMemo(() => { | |
| const firstQuestion = entries.find((entry) => entry.role === "user"); | |
| return firstQuestion && firstQuestion.role === "user" | |
| ? firstQuestion.text | |
| : "New study chat"; | |
| }, [entries]); | |
| async function chooseChat(id: string) { | |
| shouldFollowConversationRef.current = true; | |
| setLoadingChatId(id); | |
| setChatOpenError(null); | |
| try { | |
| const detail = await openChat(id); | |
| if (!detail) return; | |
| const restoredSourceIds = | |
| detail.active_source_ids.length | |
| ? detail.active_source_ids | |
| : detail.source_id | |
| ? [detail.source_id] | |
| : []; | |
| const availableSourceIds = new Set(materials.map((material) => material.id)); | |
| setSelectedSourceIds(restoredSourceIds.filter((sourceId) => availableSourceIds.has(sourceId))); | |
| setMobilePanel(null); | |
| } catch { | |
| setChatOpenError( | |
| "That chat could not open. Your saved chats are safe — check your connection and try again.", | |
| ); | |
| } finally { | |
| setLoadingChatId(null); | |
| } | |
| } | |
| function noteTitleFor(index: number) { | |
| const previous = entries[index - 1]; | |
| return previous?.role === "user" ? previous.text : "StudyChat note"; | |
| } | |
| return ( | |
| <div | |
| data-full-bleed-workspace | |
| className="-mb-[calc(var(--student-shell-bottom,0px)+12px)] flex h-[calc(100dvh-var(--student-header-h,3.5rem)-var(--student-shell-bottom,0px))] min-h-0 max-w-full flex-col overflow-hidden bg-[#f7f7f5] text-neutral-950 dark:bg-[#090a09] dark:text-neutral-100 lg:mb-0 lg:h-[calc(100dvh-var(--student-header-h,4.25rem))]" | |
| > | |
| <header className="flex min-h-[56px] shrink-0 items-center gap-2 border-b border-neutral-200/80 bg-white px-3 dark:border-white/10 dark:bg-[#0c0d0c] sm:min-h-[64px] sm:gap-3 sm:px-6"> | |
| <WorkspaceMark className="hidden sm:flex" /> | |
| <div className="min-w-0 flex-1"> | |
| <h1 className="truncate text-sm font-semibold">{title}</h1> | |
| <div className="hidden min-[400px]:block"> | |
| <ContextSummary context={context} academicContext={academicContext} /> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1.5 xl:hidden"> | |
| <button | |
| type="button" | |
| onClick={() => setMobilePanel("history")} | |
| className="inline-flex size-11 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-600 hover:text-neutral-950 dark:border-white/10 dark:bg-white/[0.04] dark:text-neutral-400 dark:hover:text-white sm:size-9 lg:hidden" | |
| aria-label="Open chat history" | |
| > | |
| <History className="size-4" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setMobilePanel("tools")} | |
| className="inline-flex size-11 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-600 hover:text-neutral-950 dark:border-white/10 dark:bg-white/[0.04] dark:text-neutral-400 dark:hover:text-white sm:size-9" | |
| aria-label="Open study tools" | |
| > | |
| <BookOpen className="size-4" /> | |
| </button> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={beginNewChat} | |
| className="hidden min-h-11 items-center gap-2 rounded-lg bg-neutral-950 px-3.5 text-sm font-semibold text-white hover:bg-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:bg-neutral-100 dark:text-neutral-950 dark:hover:bg-white lg:inline-flex" | |
| > | |
| <Plus className="size-3.5" /> | |
| New chat | |
| </button> | |
| </header> | |
| <div className="grid min-h-0 flex-1 lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[224px_minmax(0,1fr)_280px]"> | |
| <aside className="hidden min-h-0 flex-col border-r border-neutral-200/80 bg-[#f1f1ee] dark:border-white/10 dark:bg-[#0c0d0c] lg:flex"> | |
| <div className="border-b border-neutral-200/80 p-4 dark:border-white/10"> | |
| <p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-neutral-400 dark:text-neutral-500"> | |
| Current study | |
| </p> | |
| <p className="mt-2 truncate text-sm font-semibold"> | |
| {context.subject || "Your study space"} | |
| </p> | |
| <ContextSummary | |
| className="mt-1.5" | |
| context={context} | |
| academicContext={academicContext} | |
| /> | |
| {!context.onboardingComplete ? ( | |
| <Link | |
| href="/onboarding" | |
| className="mt-3 inline-flex text-xs font-semibold underline underline-offset-4" | |
| > | |
| Add your class and subject | |
| </Link> | |
| ) : null} | |
| </div> | |
| <div className="min-h-0 flex-1 overflow-y-auto py-3"> | |
| <div className="flex items-center justify-between px-4 py-2"> | |
| <p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-neutral-400 dark:text-neutral-500"> | |
| Recent chats | |
| </p> | |
| <button | |
| type="button" | |
| onClick={beginNewChat} | |
| className="inline-flex size-11 items-center justify-center rounded-lg text-neutral-400 hover:bg-white hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#171716] dark:hover:bg-white/[0.05] dark:hover:text-white" | |
| aria-label="Start new chat" | |
| > | |
| <Plus className="size-4" /> | |
| </button> | |
| </div> | |
| <div className="px-2"> | |
| {recentChats.length ? ( | |
| recentChats.slice(0, 10).map((chat) => ( | |
| <button | |
| key={chat.id} | |
| type="button" | |
| onClick={() => void chooseChat(chat.id)} | |
| className="group flex min-h-11 w-full items-start gap-2 rounded-lg px-2.5 py-2.5 text-left hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#171716] dark:hover:bg-white/[0.05]" | |
| > | |
| {loadingChatId === chat.id ? ( | |
| <LoaderCircle className="mt-0.5 size-3.5 shrink-0 animate-spin" /> | |
| ) : ( | |
| <MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-neutral-400" /> | |
| )} | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-xs font-medium"> | |
| {chat.title} | |
| </span> | |
| <span className="mt-0.5 block text-[10px] text-neutral-400"> | |
| {chat.message_count} messages ·{" "} | |
| {formatDate(chat.updated_at)} | |
| </span> | |
| </span> | |
| </button> | |
| )) | |
| ) : ( | |
| <p className="px-2.5 py-3 text-xs leading-5 text-neutral-500"> | |
| {hasLiveSession() | |
| ? "Your conversations will appear here." | |
| : "Sign in to sync conversations across devices."} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| <div className="border-t border-neutral-200/80 p-3 dark:border-white/10"> | |
| <Link | |
| href="/notes" | |
| className="flex min-h-11 items-center gap-2 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-600 hover:bg-white hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#171716] dark:text-neutral-400 dark:hover:bg-white/[0.05] dark:hover:text-white" | |
| > | |
| <BookOpen className="size-4" /> | |
| Open your library | |
| <ArrowRight className="ml-auto size-3.5" /> | |
| </Link> | |
| </div> | |
| </aside> | |
| <div className="flex min-h-0 min-w-0 flex-col bg-[#fbfbf9] dark:bg-[#090a09]"> | |
| <div | |
| ref={conversationScrollRef} | |
| onScroll={handleConversationScroll} | |
| className="min-h-0 flex-1 overflow-y-auto overscroll-contain" | |
| > | |
| {entries.length ? ( | |
| <div className="mx-auto w-full max-w-[760px] px-4 py-7 sm:px-8 sm:py-10"> | |
| <div className="space-y-8"> | |
| {entries.map((entry, index) => ( | |
| <div key={entry.id}> | |
| {entry.role === "user" ? ( | |
| <div className="flex justify-end"> | |
| <div className="max-w-[88%] rounded-[16px] rounded-br-[5px] bg-neutral-950 px-4 py-3 text-sm leading-6 text-white dark:bg-neutral-100 dark:text-neutral-950 sm:max-w-[76%]"> | |
| {entry.text} | |
| </div> | |
| </div> | |
| ) : entry.role === "error" ? ( | |
| <div className="flex items-center gap-3 border-y border-red-200 py-3 text-sm text-red-700 dark:border-red-400/20 dark:text-red-300"> | |
| <span className="flex-1">{entry.text}</span> | |
| <button | |
| type="button" | |
| onClick={() => void sendMessage(entry.retryText)} | |
| className="inline-flex min-h-[44px] items-center gap-1.5 font-semibold" | |
| > | |
| <RotateCcw className="size-3.5" /> Retry | |
| </button> | |
| </div> | |
| ) : entry.role === "info" ? ( | |
| <p className="text-center text-xs text-neutral-400"> | |
| {entry.text} | |
| </p> | |
| ) : ( | |
| <AssistantAnswer | |
| entry={entry} | |
| saved={savedAnswers.has(entry.id) || persistedSavedAnswers.has(entry.id)} | |
| saveState={ | |
| noteSaveStates[entry.id] ?? { status: "idle" } | |
| } | |
| onFollowUp={(text) => void sendMessage(text)} | |
| followUpState={ | |
| persistedFollowUpSources.has(entry.id) | |
| ? { status: "saved" } | |
| : followUpSaveStates[entry.id] ?? { status: "idle" } | |
| } | |
| followUpUnavailableReason={ | |
| !hasLiveSession() | |
| ? "Sign in to add to plan" | |
| : historySyncError | |
| ? "Sync this answer before adding it to the plan" | |
| : !currentSessionId | |
| ? "Syncing chat…" | |
| : null | |
| } | |
| onPlanFollowUp={() => | |
| void handlePlanFollowUp(entry.id, noteTitleFor(index)) | |
| } | |
| onSave={() => | |
| void handleSaveAnswer( | |
| entry.id, | |
| noteTitleFor(index), | |
| answerText(entry), | |
| { | |
| isFallback: entry.isFallback, | |
| topic: noteTitleFor(index), | |
| evidence: entry.evidence, | |
| }, | |
| ) | |
| } | |
| /> | |
| )} | |
| </div> | |
| ))} | |
| {isAsking ? ( | |
| <div className="flex items-center gap-3 text-sm text-neutral-500 dark:text-neutral-400"> | |
| <WorkspaceMark /> | |
| <span className="flex-1"> | |
| Reading the question and building the answer… | |
| </span> | |
| <button | |
| type="button" | |
| onClick={handleCancel} | |
| className="text-xs font-semibold text-neutral-700 hover:text-neutral-950 dark:text-neutral-300 dark:hover:text-white" | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| ) : null} | |
| <div ref={conversationEndRef} /> | |
| </div> | |
| </div> | |
| ) : ( | |
| <EmptyConversation | |
| context={context} | |
| academicContext={academicContext} | |
| onPrompt={(prompt) => void sendMessage(prompt)} | |
| onUpload={() => fileInputRef.current?.click()} | |
| /> | |
| )} | |
| </div> | |
| <div className="shrink-0 border-t border-neutral-200/80 bg-[#fbfbf9]/95 px-3 pb-3 pt-2 backdrop-blur-sm dark:border-white/10 dark:bg-[#090a09]/95 sm:px-6 sm:pb-5"> | |
| <div className="mx-auto max-w-[760px]"> | |
| {selectedMaterials.length ? ( | |
| <div className="mb-2 flex flex-wrap items-center gap-1.5 px-1"> | |
| <span className="mr-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400"> | |
| Using | |
| </span> | |
| {selectedMaterials.map((material) => ( | |
| <button | |
| key={material.id} | |
| type="button" | |
| onClick={() => toggleMaterial(material.id)} | |
| className="inline-flex min-h-10 max-w-[210px] items-center gap-1.5 rounded-md bg-neutral-200/70 px-2 text-xs font-medium text-neutral-700 hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#171716] dark:bg-white/[0.07] dark:text-neutral-300 dark:hover:bg-white/[0.1]" | |
| title="Remove this material from the answer" | |
| > | |
| <FileText className="size-3 shrink-0" /> | |
| <span className="truncate"> | |
| {material.title || material.file_name} | |
| </span> | |
| <X className="size-3 shrink-0 opacity-60" /> | |
| </button> | |
| ))} | |
| </div> | |
| ) : null} | |
| <UploadStatusBanner | |
| state={uploadState} | |
| onRetry={() => fileInputRef.current?.click()} | |
| /> | |
| {historyLoadError || chatOpenError || materialsError ? ( | |
| <div | |
| role="alert" | |
| data-testid="studychat-history-load-error" | |
| className="mb-2 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-2.5 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200" | |
| > | |
| <History className="size-3.5 shrink-0" aria-hidden /> | |
| <span className="flex-1"> | |
| {historyLoadError ?? chatOpenError ?? materialsError} | |
| </span> | |
| </div> | |
| ) : null} | |
| {historySyncError ? ( | |
| <div | |
| role="alert" | |
| data-testid="studychat-history-sync-error" | |
| className="mb-2 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-2.5 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200" | |
| > | |
| <History className="size-3.5 shrink-0" aria-hidden /> | |
| <span className="flex-1">{historySyncError}</span> | |
| <button | |
| type="button" | |
| onClick={() => void retryHistorySync()} | |
| disabled={isHistorySyncing} | |
| className="inline-flex min-h-10 items-center gap-1.5 rounded-md px-2 font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-wait disabled:opacity-60" | |
| > | |
| <RotateCcw | |
| className={cn("size-3.5", isHistorySyncing && "animate-spin")} | |
| aria-hidden | |
| /> | |
| {isHistorySyncing ? "Saving…" : "Retry save"} | |
| </button> | |
| </div> | |
| ) : null} | |
| <div className="rounded-xl border border-neutral-300 bg-white p-2 transition-[border-color,box-shadow] duration-200 focus-within:border-neutral-500 focus-within:shadow-[0_0_0_3px_rgba(23,24,27,0.06)] dark:border-white/15 dark:bg-[#141514] dark:focus-within:border-white/30 dark:focus-within:shadow-[0_0_0_3px_rgba(255,255,255,0.06)]"> | |
| <textarea | |
| aria-label="Message DocDoe StudyChat" | |
| value={message} | |
| onChange={(event) => setMessage(event.target.value)} | |
| onKeyDown={(event) => { | |
| if ( | |
| event.key === "Enter" && | |
| !event.shiftKey && | |
| !event.nativeEvent.isComposing | |
| ) { | |
| event.preventDefault(); | |
| void sendMessage(); | |
| } | |
| }} | |
| rows={2} | |
| placeholder="Ask a doubt, paste a PYQ, or ask for a worked example…" | |
| className="max-h-36 min-h-[48px] w-full resize-none bg-transparent px-2 py-1.5 text-[16px] leading-6 outline-none placeholder:text-[#a8aaaf] sm:text-sm" | |
| /> | |
| <div className="flex items-center gap-1.5"> | |
| <button | |
| type="button" | |
| onClick={() => fileInputRef.current?.click()} | |
| aria-label="Attach study material" | |
| className="inline-flex min-h-11 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-[#73757c] hover:bg-[#f3f3f0] hover:text-[#17181b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#171716] dark:hover:bg-white/[0.06] dark:hover:text-white" | |
| > | |
| <Paperclip className="size-3.5" /> | |
| <span className="hidden sm:inline">Material</span> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => | |
| setMessage("Correct my answer\nQuestion: \nMy answer: ") | |
| } | |
| aria-label="Correct answer" | |
| className="inline-flex min-h-11 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-[#73757c] hover:bg-[#f3f3f0] hover:text-[#17181b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#171716] dark:hover:bg-white/[0.06] dark:hover:text-white" | |
| > | |
| <ClipboardCheck className="size-3.5" aria-hidden /> | |
| <span className="hidden sm:inline">Correct answer</span> | |
| </button> | |
| <span className="hidden text-[10px] text-[#a8aaaf] sm:inline"> | |
| PDF only · source-aware help | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => void sendMessage()} | |
| disabled={!message.trim() || isAsking} | |
| className="ml-auto inline-flex size-11 items-center justify-center rounded-lg bg-[#17181b] text-white transition hover:bg-[#303136] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#171716] disabled:cursor-not-allowed disabled:opacity-30 dark:bg-neutral-100 dark:text-neutral-950 dark:hover:bg-white" | |
| aria-label="Send question" | |
| > | |
| <Send className="size-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| <p className="mt-1.5 text-center text-xs leading-5 text-[#858780]"> | |
| {formatStudyChatUsage(chatUsage) | |
| ? `${formatStudyChatUsage(chatUsage)} · ` | |
| : null} | |
| Double-check exam facts against your textbook or notes. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| <aside className="hidden min-h-0 overflow-y-auto border-l border-neutral-200/80 bg-[#f7f7f5] dark:border-white/10 dark:bg-[#0c0d0c] xl:block"> | |
| <RightRail | |
| materials={materials} | |
| selectedSourceIds={selectedSourceIds} | |
| uploadState={uploadState} | |
| onToggleMaterial={toggleMaterial} | |
| onPrompt={(prompt) => void sendMessage(prompt)} | |
| onUpload={() => fileInputRef.current?.click()} | |
| confirmDeleteId={confirmDeleteId} | |
| deletingMaterialId={deletingMaterialId} | |
| deleteMaterialError={deleteMaterialError} | |
| onRequestDelete={(id) => { | |
| setDeleteMaterialError(null); | |
| setConfirmDeleteId(id); | |
| }} | |
| onConfirmDelete={(id) => void removeMaterial(id)} | |
| onCancelDelete={() => { | |
| setDeleteMaterialError(null); | |
| setConfirmDeleteId(null); | |
| }} | |
| /> | |
| </aside> | |
| </div> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| className="hidden" | |
| accept={PDF_UPLOAD_ACCEPT} | |
| onChange={(event) => { | |
| const file = event.target.files?.[0]; | |
| if (file) void handleUpload(file); | |
| event.target.value = ""; | |
| }} | |
| /> | |
| {mobilePanel ? ( | |
| <MobilePanel | |
| panel={mobilePanel} | |
| materials={materials} | |
| selectedSourceIds={selectedSourceIds} | |
| recentChats={recentChats} | |
| onClose={closeMobilePanel} | |
| onNew={() => { | |
| beginNewChat(); | |
| setMobilePanel(null); | |
| }} | |
| onOpenChat={(id) => void chooseChat(id)} | |
| onPrompt={(prompt) => { | |
| setMobilePanel(null); | |
| void sendMessage(prompt); | |
| }} | |
| onToggleMaterial={toggleMaterial} | |
| onUpload={() => { | |
| setMobilePanel(null); | |
| fileInputRef.current?.click(); | |
| }} | |
| /> | |
| ) : null} | |
| </div> | |
| ); | |
| } | |
| function EmptyConversation({ | |
| context, | |
| academicContext, | |
| onPrompt, | |
| onUpload, | |
| }: { | |
| context: ReturnType<typeof useStudyChatTutor>["context"]; | |
| academicContext: ReturnType<typeof useStudyChatTutor>["academicContext"]; | |
| onPrompt: (prompt: string) => void; | |
| onUpload: () => void; | |
| }) { | |
| return ( | |
| <div className="mx-auto flex min-h-full w-full max-w-[760px] flex-col justify-center px-3 py-6 sm:px-8 sm:py-10"> | |
| <div className="mb-5 border-y border-[#e3e1da] py-5 sm:mb-6 sm:py-6"> | |
| <div className="flex items-start gap-3 sm:gap-4"> | |
| <WorkspaceMark /> | |
| <div className="min-w-0"> | |
| <p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[#92949a]"> | |
| Study desk | |
| </p> | |
| <h2 className="mt-1 text-[20px] font-semibold tracking-[-0.03em] text-[#17181b] sm:text-[26px]"> | |
| Stuck on a question? Let's clear it. | |
| </h2> | |
| <p className="mt-2 max-w-xl text-[13px] leading-6 text-[#73757c]"> | |
| Paste a doubt, add a PDF, or choose a study move below. When available, your class and board guide the answer. | |
| </p> | |
| </div> | |
| </div> | |
| <ContextSummary | |
| className="mt-5" | |
| context={context} | |
| academicContext={academicContext} | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 divide-y divide-[#e3e1da] border-y border-[#e3e1da] sm:grid-cols-2 sm:divide-y-0"> | |
| {STUDY_TOOLS.map((tool) => { | |
| const Icon = tool.icon; | |
| return ( | |
| <button | |
| key={tool.title} | |
| type="button" | |
| onClick={() => onPrompt(tool.prompt)} | |
| className="group flex min-h-16 w-full items-center gap-3 px-3.5 py-3 text-left transition hover:bg-[#f3f3f0] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#171716] dark:hover:bg-white/[0.04] sm:border-r sm:border-[#e3e1da] sm:gap-3.5 sm:px-4 sm:py-3.5 sm:[&:nth-child(even)]:border-r-0 sm:[&:nth-child(n+3)]:border-t" | |
| > | |
| <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-[#f3f3f0] text-[#17181b] transition group-hover:bg-[#17181b] group-hover:text-white"> | |
| <Icon className="size-4" /> | |
| </span> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block text-[13px] font-semibold text-[#17181b]"> | |
| {tool.title} | |
| </span> | |
| <span className="mt-0.5 block text-[11px] text-[#73757c]"> | |
| {tool.detail} | |
| </span> | |
| </span> | |
| <ArrowRight className="size-3.5 shrink-0 text-[#cfcfca] transition group-hover:translate-x-0.5 group-hover:text-[#17181b]" /> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <button | |
| type="button" | |
| onClick={onUpload} | |
| className="mt-4 inline-flex min-h-11 w-fit items-center gap-2 rounded-lg border border-[#e5e5e1] bg-white px-3.5 text-sm font-semibold text-[#45474d] transition hover:bg-[#f7f7f5] hover:text-[#17181b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#171716]" | |
| > | |
| <Upload className="size-3.5" /> | |
| Start from a PDF | |
| </button> | |
| </div> | |
| ); | |
| } | |
| function AssistantAnswer({ | |
| entry, | |
| saved, | |
| saveState, | |
| onFollowUp, | |
| followUpState, | |
| followUpUnavailableReason, | |
| onPlanFollowUp, | |
| onSave, | |
| }: { | |
| entry: Extract<TutorEntry, { role: "assistant" }>; | |
| saved: boolean; | |
| saveState: { status: NoteSaveStatus; message?: string }; | |
| onFollowUp: (text: string) => void; | |
| followUpState: { status: FollowUpSaveStatus; message?: string }; | |
| followUpUnavailableReason: string | null; | |
| onPlanFollowUp: () => void; | |
| onSave: () => void; | |
| }) { | |
| const saving = saveState.status === "saving"; | |
| const generationPending = Boolean(entry.isStreaming); | |
| const saveFailed = saveState.status === "error"; | |
| const followUpSaving = followUpState.status === "saving"; | |
| const followUpSaved = followUpState.status === "saved"; | |
| return ( | |
| <div | |
| data-testid="studychat-assistant-answer" | |
| className="grid grid-cols-[32px_minmax(0,1fr)] gap-3 sm:gap-4" | |
| > | |
| <WorkspaceMark /> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <p className="text-xs font-semibold">DocDoe</p> | |
| {entry.isFallback ? ( | |
| <span className="text-[10px] uppercase tracking-[0.12em] text-amber-600 dark:text-amber-300"> | |
| Offline preview | |
| </span> | |
| ) : null} | |
| </div> | |
| {entry.sections.length ? ( | |
| <div className="mt-3 border-t border-neutral-200 dark:border-white/10"> | |
| {entry.sections.map((section, index) => ( | |
| <section | |
| key={`${section.title}:${section.body}`} | |
| className="border-b border-neutral-200 py-4 dark:border-white/10" | |
| > | |
| <div className="flex items-baseline gap-3"> | |
| <span className="font-mono text-[10px] text-neutral-400"> | |
| {String(index + 1).padStart(2, "0")} | |
| </span> | |
| <h3 className="text-sm font-semibold">{section.title}</h3> | |
| </div> | |
| <p className="mt-2 whitespace-pre-wrap pl-8 text-sm leading-7 text-neutral-700 dark:text-neutral-300"> | |
| {section.body} | |
| </p> | |
| </section> | |
| ))} | |
| </div> | |
| ) : ( | |
| <div | |
| data-testid="studychat-answer-body" | |
| className="mt-2 text-sm leading-7 text-neutral-700 dark:text-neutral-300" | |
| > | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={{ | |
| h3: ({ children }) => ( | |
| <h2 className="mb-2 text-base font-semibold text-neutral-950 dark:text-white"> | |
| {children} | |
| </h2> | |
| ), | |
| p: ({ children }) => ( | |
| <p className="mb-3 last:mb-0">{children}</p> | |
| ), | |
| strong: ({ children }) => ( | |
| <strong className="font-semibold text-neutral-950 dark:text-white"> | |
| {children} | |
| </strong> | |
| ), | |
| ul: ({ children }) => ( | |
| <ul className="my-3 list-disc space-y-1 pl-5">{children}</ul> | |
| ), | |
| ol: ({ children }) => ( | |
| <ol className="my-3 list-decimal space-y-1 pl-5"> | |
| {children} | |
| </ol> | |
| ), | |
| code: ({ children }) => ( | |
| <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-[0.92em] dark:bg-white/[0.07]"> | |
| {children} | |
| </code> | |
| ), | |
| a: ({ href, children }) => ( | |
| <a | |
| href={href} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="font-medium text-neutral-700 underline decoration-neutral-300 underline-offset-2 hover:text-neutral-950 dark:text-neutral-300 dark:hover:text-white" | |
| > | |
| {children} | |
| </a> | |
| ), | |
| }} | |
| > | |
| {entry.answer} | |
| </ReactMarkdown> | |
| </div> | |
| )} | |
| {entry.evidenceLabel && | |
| entry.evidenceLabel !== "Searched the web" && | |
| entry.evidenceLabel !== "Web search unavailable" && | |
| entry.evidenceLabel !== "No source attached" ? ( | |
| <div | |
| data-testid="studychat-evidence-label" | |
| className="mt-4 inline-flex items-center gap-2 rounded-full border border-emerald-200/80 bg-emerald-50 px-3 py-1.5 text-[11px] font-semibold text-emerald-800 dark:border-emerald-400/20 dark:bg-emerald-400/[0.07] dark:text-emerald-200" | |
| > | |
| <BookOpen className="size-3.5" aria-hidden /> | |
| {entry.evidenceLabel} | |
| </div> | |
| ) : null} | |
| {entry.evidenceLabel === "Searched the web" ? ( | |
| <div className="mt-4 rounded-2xl border border-neutral-200/80 bg-neutral-100/80 p-3.5 dark:border-white/12 dark:bg-white/[0.05]"> | |
| <div className="flex items-center gap-2 text-xs font-semibold text-neutral-800 dark:text-neutral-200"> | |
| <Globe2 className="size-4" aria-hidden /> | |
| Searched the web | |
| </div> | |
| {entry.webSources?.length ? ( | |
| <div className="mt-3 grid gap-2 sm:grid-cols-2"> | |
| {entry.webSources.map((source, index) => ( | |
| <a | |
| key={`${source.url}:${index}`} | |
| href={source.url} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="group rounded-xl border border-neutral-200/70 bg-white p-3 transition hover:-translate-y-0.5 hover:border-neutral-400 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-white/25" | |
| > | |
| <div className="flex items-start gap-2"> | |
| <span className="min-w-0 flex-1 text-xs font-semibold leading-5 text-neutral-900 dark:text-white"> | |
| {source.title} | |
| </span> | |
| <ExternalLink className="mt-0.5 size-3.5 shrink-0 text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-white" /> | |
| </div> | |
| <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-neutral-500 dark:text-neutral-400"> | |
| <span>{source.publisher}</span> | |
| {source.is_official ? ( | |
| <span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300"> | |
| Official | |
| </span> | |
| ) : null} | |
| {source.published_date ? <span>{formatDate(source.published_date)}</span> : null} | |
| </div> | |
| </a> | |
| ))} | |
| </div> | |
| ) : ( | |
| <p className="mt-2 text-xs text-neutral-700/80 dark:text-neutral-300/70"> | |
| No safe citation details were returned. Verify the answer with an official source. | |
| </p> | |
| )} | |
| </div> | |
| ) : null} | |
| {entry.evidenceLabel === "Web search unavailable" ? ( | |
| <div | |
| data-testid="studychat-web-search-unavailable" | |
| className="mt-4 rounded-2xl border border-amber-200/80 bg-amber-50/80 p-3.5 text-amber-950 dark:border-amber-400/20 dark:bg-amber-400/[0.07] dark:text-amber-100" | |
| > | |
| <div className="flex items-center gap-2 text-xs font-semibold"> | |
| <Globe2 className="size-4" aria-hidden /> | |
| Web search unavailable | |
| </div> | |
| <p className="mt-1.5 text-xs leading-5 text-amber-900/80 dark:text-amber-100/70"> | |
| This answer was not verified against live sources. Retry later or check an official website before relying on current details. | |
| </p> | |
| </div> | |
| ) : null} | |
| {entry.evidence ? ( | |
| <div | |
| data-testid="studychat-source-evidence" | |
| className="mt-4 rounded-xl border border-neutral-200/80 bg-neutral-50 p-3 dark:border-white/10 dark:bg-white/[0.035]" | |
| > | |
| <p className="text-xs font-semibold text-neutral-900 dark:text-white"> | |
| {entry.evidence.pyq_classification === "verified_pyq" | |
| ? "Verified PYQ evidence" | |
| : entry.evidence.pyq_classification === "pyq_style" | |
| ? "PYQ-style practice" | |
| : "Answer evidence"} | |
| </p> | |
| <p className="mt-1 text-xs leading-5 text-neutral-600 dark:text-neutral-400"> | |
| {entry.evidence.evidence_scope} | |
| </p> | |
| {entry.evidence.citations.length ? ( | |
| <details className="mt-2 text-xs"> | |
| <summary className="cursor-pointer font-medium text-neutral-700 dark:text-neutral-300"> | |
| {entry.evidence.citations.length} cited passage{entry.evidence.citations.length === 1 ? "" : "s"} | |
| </summary> | |
| <div className="mt-2 grid gap-2"> | |
| {entry.evidence.citations.map((citation) => { | |
| const content = <> | |
| <span className="font-semibold text-neutral-900 dark:text-white"> | |
| {citation.source_title} | |
| {citation.page_number ? ` · page ${citation.page_number}` : ""} | |
| </span> | |
| {citation.heading ? ( | |
| <span className="ml-1 text-neutral-500">· {citation.heading}</span> | |
| ) : null} | |
| <span className="mt-1 block line-clamp-2 leading-5 text-neutral-600 dark:text-neutral-400"> | |
| {citation.excerpt} | |
| </span> | |
| {citation.availability === "snapshot" ? ( | |
| <span className="mt-2 block text-[11px] font-medium text-amber-700 dark:text-amber-300/80">Source snapshot · original source removed</span> | |
| ) : null} | |
| </>; | |
| const className = "rounded-lg border border-neutral-200 bg-white p-2.5 dark:border-white/10 dark:bg-white/[0.04]"; | |
| return citation.availability === "snapshot" ? ( | |
| <div key={`${citation.source_id}:${citation.chunk_id}`} className={className}>{content}</div> | |
| ) : ( | |
| <Link key={`${citation.source_id}:${citation.chunk_id}`} href={`/documents/${encodeURIComponent(citation.source_id)}`} className={`${className} hover:border-neutral-400`}> | |
| {content} | |
| </Link> | |
| ); | |
| })} | |
| </div> | |
| </details> | |
| ) : null} | |
| {entry.evidence.pyq_analysis ? ( | |
| <div className="mt-3 rounded-lg border border-emerald-500/20 bg-emerald-500/[0.06] p-2.5" data-testid="verified-pyq-analysis"> | |
| <p className="text-xs font-semibold text-emerald-700 dark:text-emerald-200">Verified analyzer result</p> | |
| <p className="mt-1 text-xs text-neutral-700 dark:text-neutral-300"> | |
| {entry.evidence.pyq_analysis.data_coverage.papers} paper(s) · {entry.evidence.pyq_analysis.data_coverage.questions} extracted question(s) | |
| {entry.evidence.pyq_analysis.data_coverage.years.length ? ` · ${entry.evidence.pyq_analysis.data_coverage.years.join(", ")}` : " · years not recorded"} | |
| </p> | |
| {!entry.evidence.pyq_analysis.available ? ( | |
| <p className="mt-2 text-xs leading-5 text-amber-700 dark:text-amber-200/80">Sparse verified evidence: {entry.evidence.pyq_analysis.message}</p> | |
| ) : null} | |
| </div> | |
| ) : null} | |
| </div> | |
| ) : null} | |
| <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={onSave} | |
| disabled={saved || saving || generationPending} | |
| className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-neutral-200 px-2.5 text-xs font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-950 disabled:opacity-60 dark:border-white/10 dark:text-neutral-400 dark:hover:bg-white/[0.05] dark:hover:text-white" | |
| > | |
| {generationPending ? ( | |
| <LoaderCircle className="size-3.5 animate-spin" /> | |
| ) : saved ? ( | |
| <Check className="size-3.5" /> | |
| ) : saving ? ( | |
| <LoaderCircle className="size-3.5 animate-spin" /> | |
| ) : saveFailed ? ( | |
| <RotateCcw className="size-3.5" /> | |
| ) : ( | |
| <NotebookPen className="size-3.5" /> | |
| )} | |
| {generationPending | |
| ? "Generating…" | |
| : saved | |
| ? "Saved" | |
| : saving | |
| ? "Saving…" | |
| : saveFailed | |
| ? "Retry sync" | |
| : "Save as note"} | |
| </button> | |
| <button | |
| type="button" | |
| data-testid="studychat-add-followup" | |
| onClick={onPlanFollowUp} | |
| disabled={generationPending || Boolean(followUpUnavailableReason) || followUpSaving || followUpSaved} | |
| className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-neutral-200 px-2.5 text-xs font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-950 disabled:opacity-60 dark:border-white/10 dark:text-neutral-400 dark:hover:bg-white/[0.05] dark:hover:text-white" | |
| > | |
| {followUpSaved ? ( | |
| <Check className="size-3.5" /> | |
| ) : followUpSaving ? ( | |
| <LoaderCircle className="size-3.5 animate-spin" /> | |
| ) : followUpState.status === "error" ? ( | |
| <RotateCcw className="size-3.5" /> | |
| ) : ( | |
| <ClipboardCheck className="size-3.5" /> | |
| )} | |
| {followUpSaved | |
| ? "Added to plan" | |
| : followUpSaving | |
| ? "Adding…" | |
| : followUpUnavailableReason ?? | |
| (followUpState.status === "error" ? "Retry follow-up" : "Add follow-up to plan")} | |
| </button> | |
| {entry.suggestedActions.slice(0, 3).map((action) => ( | |
| <button | |
| key={action} | |
| type="button" | |
| onClick={() => onFollowUp(action)} | |
| className="h-8 rounded-lg border border-neutral-200 px-2.5 text-xs text-neutral-600 hover:bg-neutral-100 hover:text-neutral-950 dark:border-white/10 dark:text-neutral-400 dark:hover:bg-white/[0.05] dark:hover:text-white" | |
| > | |
| {action} | |
| </button> | |
| ))} | |
| </div> | |
| {saveFailed && saveState.message ? ( | |
| <p | |
| role="alert" | |
| className="mt-2 text-[11px] font-medium text-amber-700 dark:text-amber-300" | |
| > | |
| {saveState.message} | |
| </p> | |
| ) : null} | |
| {followUpState.status === "error" && followUpState.message ? ( | |
| <p role="alert" className="mt-2 text-[11px] font-medium text-amber-700 dark:text-amber-300"> | |
| {followUpState.message} | |
| </p> | |
| ) : null} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function RightRail({ | |
| materials, | |
| selectedSourceIds, | |
| uploadState, | |
| onToggleMaterial, | |
| onPrompt, | |
| onUpload, | |
| confirmDeleteId, | |
| deletingMaterialId, | |
| deleteMaterialError, | |
| onRequestDelete, | |
| onConfirmDelete, | |
| onCancelDelete, | |
| }: { | |
| materials: ApiDocument[]; | |
| selectedSourceIds: string[]; | |
| uploadState: UploadState; | |
| onToggleMaterial: (id: string) => void; | |
| onPrompt: (prompt: string) => void; | |
| onUpload: () => void; | |
| confirmDeleteId: string | null; | |
| deletingMaterialId: string | null; | |
| deleteMaterialError: { id: string; message: string } | null; | |
| onRequestDelete: (id: string) => void; | |
| onConfirmDelete: (id: string) => void; | |
| onCancelDelete: () => void; | |
| }) { | |
| return ( | |
| <div className="divide-y divide-neutral-200/80 dark:divide-white/10"> | |
| <section className="p-5"> | |
| <div className="flex items-center justify-between"> | |
| <p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-neutral-400 dark:text-neutral-500"> | |
| Sources for this answer | |
| </p> | |
| <button | |
| type="button" | |
| onClick={onUpload} | |
| className="inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-lg text-neutral-400 hover:bg-white hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#171716] dark:hover:bg-white/[0.05] dark:hover:text-white" | |
| aria-label="Upload material" | |
| > | |
| <Plus className="size-4" /> | |
| </button> | |
| </div> | |
| {materials.length ? ( | |
| <p className="mt-2 text-[11px] leading-4 text-neutral-500 dark:text-neutral-400"> | |
| Choose the sources DocDoe should read for the next answer. | |
| </p> | |
| ) : null} | |
| <div className="mt-3 space-y-1"> | |
| {materials.length ? ( | |
| materials.slice(0, 5).map((material) => { | |
| const selected = selectedSourceIds.includes(material.id); | |
| const isConfirming = confirmDeleteId === material.id; | |
| const isDeleting = deletingMaterialId === material.id; | |
| if (isConfirming) { | |
| return ( | |
| <MaterialDeletePrompt | |
| key={material.id} | |
| title={material.title || material.file_name} | |
| deleting={isDeleting} | |
| error={ | |
| deleteMaterialError?.id === material.id | |
| ? deleteMaterialError.message | |
| : null | |
| } | |
| onConfirm={() => onConfirmDelete(material.id)} | |
| onCancel={onCancelDelete} | |
| /> | |
| ); | |
| } | |
| return ( | |
| <div | |
| key={material.id} | |
| className={cn( | |
| "group flex w-full items-start gap-2.5 rounded-lg px-2 py-2.5 transition-colors", | |
| selected | |
| ? "bg-neutral-950 text-white dark:bg-neutral-100 dark:text-neutral-950" | |
| : "hover:bg-white dark:hover:bg-white/[0.05]", | |
| )} | |
| > | |
| <button | |
| type="button" | |
| onClick={() => onToggleMaterial(material.id)} | |
| className="flex min-w-0 flex-1 items-start gap-2.5 text-left" | |
| > | |
| <FileText className="mt-0.5 size-4 shrink-0 text-neutral-400" /> | |
| <div className="min-w-0 flex-1"> | |
| <p className="truncate text-xs font-medium"> | |
| {material.title || material.file_name} | |
| </p> | |
| <p | |
| className={cn( | |
| "mt-0.5 text-[10px] capitalize", | |
| selected | |
| ? "text-white/60 dark:text-neutral-500" | |
| : "text-neutral-400", | |
| )} | |
| > | |
| {material.status} | |
| {material.subject ? ` · ${material.subject}` : ""} | |
| </p> | |
| </div> | |
| <span | |
| className={cn( | |
| "mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded border", | |
| selected | |
| ? "border-white/30 bg-white/15 dark:border-neutral-400 dark:bg-neutral-200" | |
| : "border-neutral-300 dark:border-white/15", | |
| )} | |
| > | |
| {selected ? <Check className="size-3" /> : null} | |
| </span> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => onRequestDelete(material.id)} | |
| aria-label={`Delete ${material.title || material.file_name}`} | |
| className={cn( | |
| "mt-0.5 shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100", | |
| selected | |
| ? "text-white/70 hover:text-white" | |
| : "text-neutral-400 hover:text-red-600", | |
| )} | |
| > | |
| <Trash2 className="size-3.5" /> | |
| </button> | |
| </div> | |
| ); | |
| }) | |
| ) : ( | |
| <button | |
| type="button" | |
| onClick={onUpload} | |
| className="flex w-full items-center gap-3 rounded-lg border border-dashed border-neutral-300 px-3 py-3 text-left dark:border-white/15" | |
| > | |
| <Upload className="size-4 text-neutral-400" /> | |
| <span className="text-xs text-neutral-500 dark:text-neutral-400"> | |
| Add the material you want DocDoe to use | |
| </span> | |
| </button> | |
| )} | |
| </div> | |
| <UploadStatusBanner state={uploadState} onRetry={onUpload} className="mt-2" /> | |
| </section> | |
| <section className="p-5"> | |
| <p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-neutral-400 dark:text-neutral-500"> | |
| Try next | |
| </p> | |
| <div className="mt-3 space-y-1"> | |
| {STARTER_PROMPTS.map((prompt) => ( | |
| <button | |
| key={prompt} | |
| type="button" | |
| onClick={() => onPrompt(prompt)} | |
| className="flex min-h-[44px] w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs text-neutral-600 hover:bg-white hover:text-neutral-950 dark:text-neutral-400 dark:hover:bg-white/[0.05] dark:hover:text-white" | |
| > | |
| <ArrowRight className="size-3 shrink-0" /> | |
| {prompt} | |
| </button> | |
| ))} | |
| </div> | |
| </section> | |
| </div> | |
| ); | |
| } | |
| function MobilePanel({ | |
| panel, | |
| materials, | |
| selectedSourceIds, | |
| recentChats, | |
| onClose, | |
| onNew, | |
| onOpenChat, | |
| onPrompt, | |
| onToggleMaterial, | |
| onUpload, | |
| }: { | |
| panel: "history" | "tools"; | |
| materials: ApiDocument[]; | |
| selectedSourceIds: string[]; | |
| recentChats: ReturnType<typeof useStudyChatTutor>["recentChats"]; | |
| onClose: () => void; | |
| onNew: () => void; | |
| onOpenChat: (id: string) => void; | |
| onPrompt: (prompt: string) => void; | |
| onToggleMaterial: (id: string) => void; | |
| onUpload: () => void; | |
| }) { | |
| const panelRef = useRef<HTMLDivElement>(null); | |
| useEffect(() => { | |
| const returnFocusTo = | |
| document.activeElement instanceof HTMLElement | |
| ? document.activeElement | |
| : null; | |
| const previousOverflow = document.body.style.overflow; | |
| const handleKeyDown = (event: KeyboardEvent) => { | |
| if (event.key === "Escape") { | |
| event.preventDefault(); | |
| onClose(); | |
| return; | |
| } | |
| if (event.key !== "Tab") return; | |
| const focusable = Array.from( | |
| panelRef.current?.querySelectorAll<HTMLElement>( | |
| 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', | |
| ) ?? [], | |
| ); | |
| if (!focusable.length) { | |
| event.preventDefault(); | |
| return; | |
| } | |
| const first = focusable[0]; | |
| const last = focusable[focusable.length - 1]; | |
| const active = document.activeElement; | |
| if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) { | |
| event.preventDefault(); | |
| last.focus(); | |
| } else if (!event.shiftKey && (active === last || !panelRef.current?.contains(active))) { | |
| event.preventDefault(); | |
| first.focus(); | |
| } | |
| }; | |
| document.body.style.overflow = "hidden"; | |
| window.addEventListener("keydown", handleKeyDown); | |
| const frame = window.requestAnimationFrame(() => { | |
| panelRef.current | |
| ?.querySelector<HTMLElement>("[data-panel-initial-focus]") | |
| ?.focus({ preventScroll: true }); | |
| }); | |
| return () => { | |
| window.cancelAnimationFrame(frame); | |
| document.body.style.overflow = previousOverflow; | |
| window.removeEventListener("keydown", handleKeyDown); | |
| returnFocusTo?.focus({ preventScroll: true }); | |
| }; | |
| }, [onClose]); | |
| return ( | |
| <div | |
| className="fixed inset-0 z-[80] xl:hidden" | |
| role="dialog" | |
| aria-modal="true" | |
| aria-label={panel === "history" ? "Recent chats" : "Study tools"} | |
| > | |
| <button | |
| type="button" | |
| aria-label="Close panel" | |
| onClick={onClose} | |
| className="absolute inset-0 bg-black/35 backdrop-blur-[2px]" | |
| /> | |
| <div | |
| ref={panelRef} | |
| data-testid="study-chat-mobile-panel" | |
| className="absolute inset-x-0 bottom-0 max-h-[min(82dvh,720px)] overflow-y-auto overscroll-contain rounded-t-[22px] bg-[#fbfbf9] p-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-neutral-950 shadow-2xl dark:bg-[#141514] dark:text-neutral-100" | |
| > | |
| <div className="mb-4 flex items-center justify-between"> | |
| <h2 className="text-sm font-semibold"> | |
| {panel === "history" ? "Recent chats" : "Study tools"} | |
| </h2> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| data-panel-initial-focus | |
| className="inline-flex size-11 items-center justify-center rounded-lg hover:bg-neutral-100 dark:hover:bg-white/[0.06] sm:size-9" | |
| aria-label="Close panel" | |
| > | |
| <X className="size-4" /> | |
| </button> | |
| </div> | |
| {panel === "history" ? ( | |
| <div> | |
| <button | |
| type="button" | |
| onClick={onNew} | |
| className="mb-3 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-950 text-xs font-semibold text-white dark:bg-neutral-100 dark:text-neutral-950" | |
| > | |
| <Plus className="size-4" /> New chat | |
| </button> | |
| <div className="divide-y divide-neutral-200 dark:divide-white/10"> | |
| {recentChats.map((chat) => ( | |
| <button | |
| key={chat.id} | |
| type="button" | |
| onClick={() => onOpenChat(chat.id)} | |
| className="flex w-full items-center gap-3 py-3 text-left" | |
| > | |
| <MessageSquareText className="size-4 text-neutral-400" /> | |
| <span className="min-w-0 flex-1 truncate text-sm"> | |
| {chat.title} | |
| </span> | |
| <span className="text-[10px] text-neutral-400"> | |
| {formatDate(chat.updated_at)} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="divide-y divide-neutral-200 dark:divide-white/10"> | |
| {STUDY_TOOLS.map((tool) => { | |
| const Icon = tool.icon; | |
| return ( | |
| <button | |
| key={tool.title} | |
| type="button" | |
| onClick={() => onPrompt(tool.prompt)} | |
| className="flex min-h-[44px] w-full items-center gap-3 py-3.5 text-left" | |
| > | |
| <Icon className="size-4 text-neutral-400" /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block text-sm font-semibold"> | |
| {tool.title} | |
| </span> | |
| <span className="block text-xs text-neutral-400"> | |
| {tool.detail} | |
| </span> | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| {materials.length ? ( | |
| <div className="py-3"> | |
| <p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400"> | |
| Answer from | |
| </p> | |
| <div className="space-y-1"> | |
| {materials.slice(0, 6).map((material) => ( | |
| <button | |
| key={material.id} | |
| type="button" | |
| onClick={() => onToggleMaterial(material.id)} | |
| className="flex w-full items-center gap-3 rounded-lg py-2 text-left" | |
| > | |
| <span | |
| className={cn( | |
| "inline-flex size-4 items-center justify-center rounded border", | |
| selectedSourceIds.includes(material.id) | |
| ? "border-neutral-950 bg-neutral-950 text-white dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-950" | |
| : "border-neutral-300 dark:border-white/15", | |
| )} | |
| > | |
| {selectedSourceIds.includes(material.id) ? ( | |
| <Check className="size-3" /> | |
| ) : null} | |
| </span> | |
| <span className="min-w-0 flex-1 truncate text-sm"> | |
| {material.title || material.file_name} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : null} | |
| <button | |
| type="button" | |
| onClick={onUpload} | |
| className="flex w-full items-center gap-3 py-3.5 text-left" | |
| > | |
| <Upload className="size-4 text-neutral-400" /> | |
| <span className="text-sm font-semibold"> | |
| Upload material ({materials.length}) | |
| </span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |