"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 (
{busy ? ( ) : isReady ? ( ) : ( )} {label} {isError ? ( ) : null}
); } 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) { return entry.sections.length ? entry.sections .map((section) => `${section.title}\n${section.body}`) .join("\n\n") : entry.answer; } function WorkspaceMark({ className }: { className?: string } = {}) { return (
); } function ContextSummary({ className, context, academicContext, }: { className?: string; context: ReturnType["context"]; academicContext: ReturnType["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 (
{items.length ? items.join(" · ") : "General study"} {academicContext?.return_href ? ( Return to {academicContext.origin === "tuition" ? "Tuition" : "previous work"} ) : null}
); } export function StudyChatWorkspace() { const { data: workspaceData, updateWorkspace } = useStudentWorkspace(); const [selectedSourceIds, setSelectedSourceIds] = useState([]); 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([]); const [materialsError, setMaterialsError] = useState(null); const [savedAnswers, setSavedAnswers] = useState>( () => new Set(), ); const [noteSaveStates, setNoteSaveStates] = useState< Record >({}); const [followUpSaveStates, setFollowUpSaveStates] = useState< Record >({}); const [persistedFollowUpSources, setPersistedFollowUpSources] = useState>( () => new Set(), ); const pendingNotesRef = useRef(new Map()); const [mobilePanel, setMobilePanel] = useState<"history" | "tools" | null>( null, ); const closeMobilePanel = useCallback(() => setMobilePanel(null), []); const [loadingChatId, setLoadingChatId] = useState(null); const [chatOpenError, setChatOpenError] = useState(null); const fileInputRef = useRef(null); const conversationScrollRef = useRef(null); const conversationEndRef = useRef(null); const shouldFollowConversationRef = useRef(true); const [chatUsage, setChatUsage] = useState(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(); 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["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(null); const [confirmDeleteId, setConfirmDeleteId] = useState(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 (

{title}

{entries.length ? (
{entries.map((entry, index) => (
{entry.role === "user" ? (
{entry.text}
) : entry.role === "error" ? (
{entry.text}
) : entry.role === "info" ? (

{entry.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, }, ) } /> )}
))} {isAsking ? (
Reading the question and building the answer…
) : null}
) : ( void sendMessage(prompt)} onUpload={() => fileInputRef.current?.click()} /> )}
{selectedMaterials.length ? (
Using {selectedMaterials.map((material) => ( ))}
) : null} fileInputRef.current?.click()} /> {historyLoadError || chatOpenError || materialsError ? (
{historyLoadError ?? chatOpenError ?? materialsError}
) : null} {historySyncError ? (
{historySyncError}
) : null}