"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { askDocDoe, generateBrainQuiz } from "@/lib/api/brain"; import { isChatOriginPage, sendChatMessage, streamChatMessage, type ChatOriginPage, type StudyChatAcademicContext, type StudyChatEvidence, type StudyChatMode, type WebSource, } from "@/lib/api/chat"; import { getDocument, uploadDocument } from "@/lib/api/documents"; import { isApiError, normalizeApiError } from "@/lib/api/client"; import { appendChatMessages, createChatSession, getChatSession, listChatSessions, type ChatSessionOut, } from "@/lib/api/chatHistory"; import { hasLiveSession } from "@/lib/api/mode"; import { getLocalStudentContext, loadStudentContext, type StudentContext, } from "@/lib/student-context"; import { addActivity, saveNote, saveQuiz } from "@/lib/student-activity"; import { validateStudentUpload } from "@/lib/uploads/document-upload-contract"; // ============================================================================= // Shared StudyChat tutor controller — one brain for both theme variants. // // Fixes the reliability gaps found in student testing: // - URL intent prompts (?prompt=…&run=1 from buildStudyIntentUrl / "Start // Today's Lesson") now auto-send instead of being silently ignored. // - A stuck "DocDoe is thinking…" can always be cancelled; failures show a // friendly error entry with one-click Retry, and the question is never lost. // - Successful answers persist to the real chat session history. // ============================================================================= let tutorEntrySequence = 0; function nextTutorEntryId() { tutorEntrySequence += 1; return `tutor-entry-${tutorEntrySequence}`; } export type TutorEntry = { id: string } & ( | { role: "user"; text: string } | { role: "assistant"; answer: string; sections: Array<{ title: string; body: string }>; suggestedActions: string[]; isFallback?: boolean; evidenceLabel?: string | null; webSources?: WebSource[]; evidence?: StudyChatEvidence | null; isStreaming?: boolean; } | { role: "error"; text: string; retryText: string } | { role: "info"; text: string } ); /** Structured upload lifecycle so the UI can show real loading/success/error/retry states. */ export type UploadState = | { phase: "idle" } | { phase: "uploading"; fileName: string } | { phase: "processing"; fileName: string; documentId: string } | { phase: "ready"; fileName: string; documentId: string } | { phase: "error"; fileName: string; message: string; documentId?: string }; type PendingHistorySync = { clientTurnId: string; question: string; answer: string; subject: string; sourceId: string | null; intent: string | null; evidenceLabel: string | null; webSources: WebSource[]; evidence: StudyChatEvidence | null; mode: StudyChatMode; contextData: StudyChatAcademicContext | null; }; function nextHistoryTurnId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; } function requestMode(question: string, sourceIds: string[]): StudyChatMode { if (parseCorrectionRequest(question)) return "answer_correction"; if (/\b(pyq|previous\s+year|past\s+paper)\b/i.test(question)) { return sourceIds.length ? "pyq_verified" : "pyq_style"; } return sourceIds.length ? "selected_sources" : "general_explanation"; } function parseCorrectionRequest(text: string): { question: string; studentAnswer: string } | null { const match = text.match(/Question:\s*([\s\S]+?)\n\s*My answer:\s*([\s\S]+)/i); if (!match?.[1]?.trim() || !match[2]?.trim()) return null; return { question: match[1].trim(), studentAnswer: match[2].trim() }; } // Image upload is intentionally NOT offered: production verification showed // the backend has no OCR/image-text-extraction service configured, so an // uploaded image can never reach a usable "ready" state — it always ends in // "failed". Showing a feature that cannot complete is worse than not showing // it. PDF (selectable text) is the one upload path that genuinely works end // to end. Re-enable image extensions here only once real OCR/multimodal // extraction is deployed and verified (see docs/adaptive-engine-audit.md / // memory for the OCR-unavailable finding). /** Client-side pre-check so obvious rejects fail instantly with a clear reason. */ function validateUploadFile(file: File): string | null { const validation = validateStudentUpload(file, "study-chat"); return validation.ok ? null : validation.message; } export function useStudyChatTutor(selectedSourceIds: string[] = []) { const router = useRouter(); const searchParams = useSearchParams(); const [message, setMessage] = useState(""); const [entries, setEntries] = useState([]); const [isAsking, setIsAsking] = useState(false); const [recentChats, setRecentChats] = useState([]); const [context, setContext] = useState(() => getLocalStudentContext()); const [academicContext, setAcademicContext] = useState(null); const [uploadStatus, setUploadStatus] = useState(null); const [uploadState, setUploadState] = useState({ phase: "idle" }); const [historySyncError, setHistorySyncError] = useState(null); const [historyLoadError, setHistoryLoadError] = useState(null); const [isHistorySyncing, setIsHistorySyncing] = useState(false); const [currentSessionId, setCurrentSessionId] = useState(null); const sessionIdRef = useRef(null); const abortRef = useRef(null); const autoRanRef = useRef(false); // Upload poll guards: `mountedRef` stops state updates after unmount; // `uploadRunRef` is a generation token so only the most recent upload's poll // can write state — a rapid second upload supersedes the first, never runs a // duplicate loop that fights it. const mountedRef = useRef(true); const uploadRunRef = useRef(0); const pendingHistorySyncRef = useRef([]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); /** Intent carried by the URL handoff — consumed by the next successful answer. */ const pendingIntentRef = useRef(null); const pendingSourceIdRef = useRef(null); const pendingOriginPageRef = useRef(null); const pendingAcademicContextRef = useRef(null); const persistHistoryPair = useCallback(async (payload: PendingHistorySync) => { let activeSessionId = sessionIdRef.current; if (!activeSessionId) { const session = await createChatSession({ title: payload.question.slice(0, 60), subject: payload.contextData?.subject ?? payload.subject, source_id: payload.sourceId, context_data: payload.contextData, active_source_ids: payload.evidence?.source_ids ?? [], mode: payload.mode, }); activeSessionId = session.id; sessionIdRef.current = session.id; setCurrentSessionId(session.id); setRecentChats((prev) => [session, ...prev]); setAcademicContext(session.context_data ?? payload.contextData); } const persistedMessages = await appendChatMessages(activeSessionId, { user_content: payload.question, assistant_content: payload.answer, client_turn_id: payload.clientTurnId, intent: payload.intent ?? undefined, evidence_label: payload.evidenceLabel, web_sources: payload.webSources, evidence: payload.evidence, }); setRecentChats((prev) => { const index = prev.findIndex((session) => session.id === activeSessionId); if (index === -1) return prev; const updated: ChatSessionOut = { ...prev[index], message_count: prev[index].message_count + 2, updated_at: new Date().toISOString(), }; return [ updated, ...prev.slice(0, index), ...prev.slice(index + 1), ]; }); return persistedMessages.find((item) => item.role === "assistant")?.id ?? null; }, []); const handleSend = useCallback( async (text?: string) => { const question = (text ?? message).trim(); if (!question || abortRef.current) return; const historyTurnId = nextHistoryTurnId(); setMessage(""); setEntries((prev) => [...prev, { id: nextTutorEntryId(), role: "user", text: question }]); setIsAsking(true); const controller = new AbortController(); abortRef.current = controller; // Bind the URL handoff to this request. Failure and cancellation consume // it, so a later unrelated answer can never inherit a stale save action. const requestIntent = pendingIntentRef.current; const requestSourceId = pendingSourceIdRef.current; const requestOriginPage = pendingOriginPageRef.current; const requestAcademicContext = pendingAcademicContextRef.current ?? academicContext; const isRouteHandoff = Boolean( requestIntent || requestOriginPage || pendingAcademicContextRef.current, ); // A route handoff starts one exact academic thread. An old material that // happened to remain selected on this device must not silently ground a // Progress/Tuition/Learn Anything request. A source only crosses that // boundary when the handoff names it explicitly. const requestSourceIds = requestSourceId ? [requestSourceId] : isRouteHandoff ? [] : selectedSourceIds; const resolvedRequestMode = requestMode(question, requestSourceIds); const correctionRequest = parseCorrectionRequest(question); const requestSubject = requestAcademicContext?.subject ?? context.subject; pendingIntentRef.current = null; pendingSourceIdRef.current = null; pendingOriginPageRef.current = null; pendingAcademicContextRef.current = null; try { if (requestIntent === "generate_quiz") { const quizResponse = await generateBrainQuiz({ topic: requestAcademicContext?.topic?.trim() || question, subject: requestSubject, source_id: requestSourceIds[0] ?? null, source_ids: requestSourceIds, options: { num_questions: 5 }, }); if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); const questions = quizResponse.output.questions.filter( (item) => item.question.trim().length > 0 && item.answer.trim().length > 0, ); if (questions.length === 0) throw new Error("Quiz generator returned no gradable questions"); const isStarter = Boolean(quizResponse.is_fallback) || /offline|starter|sample/i.test(`${quizResponse.evidence_label ?? ""} ${quizResponse.trust_note ?? ""}`); const title = quizResponse.output.quiz_title?.trim() || `${quizResponse.topic || question} quiz`; const saved = saveQuiz({ title, topic: quizResponse.topic || question, subject: requestSubject ?? undefined, source: "study-chat", provenance: isStarter ? "starter" : "generated", questions: questions.map((item) => ({ question: item.question, answer: item.answer, topic: item.topic || quizResponse.topic || question, options: item.options, explanation: item.explanation, difficulty: item.difficulty, })), }); const answer = isStarter ? `Starter quiz saved: ${saved.title}. These are deterministic practice prompts, not a live AI result.` : `Quiz saved: ${saved.title}. Open Tests & Quizzes when you are ready to take it.`; const sections = saved.questions.map((item, index) => ({ title: `${index + 1}. ${item.question}`, body: [item.options?.length ? item.options.join("\n") : null, `Answer: ${item.answer}`, item.explanation] .filter(Boolean) .join("\n\n"), })); setEntries((prev) => [ ...prev, { id: nextTutorEntryId(), role: "assistant", answer, sections, suggestedActions: quizResponse.suggested_actions ?? [], isFallback: isStarter, }, ]); addActivity("question_asked", question, isStarter ? { sample: true } : undefined); return; } let finalAnswer = ""; let finalSections: Array<{ title: string; body: string }> = []; let finalActions: string[] = []; let finalIsFallback = false; let finalIntent: string | null = null; let finalEvidenceLabel: string | null = null; let finalWebSources: WebSource[] = []; let finalEvidence: StudyChatEvidence | null = null; if (hasLiveSession()) { let streamCompleted = false; let streamFailed = false; setEntries((prev) => [ ...prev, { id: nextTutorEntryId(), role: "assistant", answer: "", sections: [], suggestedActions: [], isStreaming: true }, ]); await streamChatMessage( { message: question, intent: requestIntent ?? undefined, subject: requestSubject ?? undefined, source_ids: requestSourceIds, origin_page: requestOriginPage ?? undefined, academic_context: requestAcademicContext ?? undefined, mode: resolvedRequestMode, correction_question: correctionRequest?.question, student_answer: correctionRequest?.studentAnswer, session_id: sessionIdRef.current ?? undefined, idempotency_key: historyTurnId, }, { onDelta: (token) => { finalAnswer += token; setEntries((prev) => { const next = [...prev]; const last = next[next.length - 1]; if (last?.role === "assistant") next[next.length - 1] = { ...last, answer: finalAnswer }; return next; }); }, onDone: (meta) => { finalIntent = meta.intent || null; finalEvidenceLabel = meta.evidenceLabel; finalWebSources = meta.webSources; finalEvidence = meta.evidence; streamCompleted = true; }, onError: () => { streamFailed = true; }, }, controller.signal, ); if (streamFailed || !streamCompleted || !finalAnswer.trim()) { setEntries((prev) => { const next = [...prev]; if (next[next.length - 1]?.role === "assistant") next.pop(); return next; }); try { // Stay on the same authoritative router when SSE is unavailable. // This preserves current-info Exa citations, uploaded-source // ownership checks, and the exact evidence label returned by // /chat instead of silently switching products to /ask. const response = await sendChatMessage( { message: question, intent: requestIntent ?? undefined, subject: requestSubject ?? undefined, source_ids: requestSourceIds, origin_page: requestOriginPage ?? undefined, academic_context: requestAcademicContext ?? undefined, mode: resolvedRequestMode, correction_question: correctionRequest?.question, student_answer: correctionRequest?.studentAnswer, session_id: sessionIdRef.current ?? undefined, idempotency_key: historyTurnId, }, { signal: controller.signal, timeoutMs: 60000 }, ); finalAnswer = response.answer; finalIntent = response.intent_detected || null; finalEvidenceLabel = response.evidence_label ?? null; finalWebSources = response.web_sources ?? []; finalEvidence = response.evidence; finalIsFallback = /mock|fallback|offline/i.test(response.model_used); } catch (error) { // A failed authoritative Study Chat request must remain failed. // Falling over to /ask would lose the server-resolved mode and // provenance envelope while presenting the result as success. throw error; } setEntries((prev) => [ ...prev, { id: nextTutorEntryId(), role: "assistant", answer: finalAnswer, sections: finalSections, suggestedActions: finalActions, isFallback: finalIsFallback, evidenceLabel: finalEvidenceLabel, webSources: finalWebSources, evidence: finalEvidence, }, ]); } else { setEntries((prev) => { const next = [...prev]; const last = next[next.length - 1]; if (last?.role === "assistant") { next[next.length - 1] = { ...last, isFallback: finalIsFallback, evidenceLabel: finalEvidenceLabel, webSources: finalWebSources, evidence: finalEvidence, isStreaming: false, }; } return next; }); } } else { const response = await askDocDoe( { question, source_id: requestSourceIds[0] ?? null, source_ids: requestSourceIds, }, { signal: controller.signal }, ); finalAnswer = response.answer; finalSections = response.sections; finalActions = response.suggested_actions; finalIsFallback = Boolean(response.is_fallback); finalEvidenceLabel = response.evidence_label ?? null; setEntries((prev) => [ ...prev, { id: nextTutorEntryId(), role: "assistant", answer: finalAnswer, sections: finalSections, suggestedActions: finalActions, isFallback: finalIsFallback, evidenceLabel: finalEvidenceLabel, }, ]); } if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); // Real activity: this question was actually asked and answered. addActivity("question_asked", question); // A notes-generation handoff saves its output as a real note. if (requestIntent === "generate_notes") { saveNote({ title: question.slice(0, 80), content: finalAnswer, topic: question.slice(0, 120), subject: requestSubject ?? undefined, sourceId: requestSourceIds[0] ?? undefined, source: "study-chat", provenance: finalIsFallback ? "starter" : "generated", }); } if (hasLiveSession()) { const historyPayload: PendingHistorySync = { clientTurnId: historyTurnId, question, answer: finalAnswer, subject: requestSubject ?? "", sourceId: requestSourceIds[0] ?? null, intent: finalIntent ?? requestIntent, evidenceLabel: finalEvidenceLabel, webSources: finalWebSources, evidence: finalEvidence, mode: resolvedRequestMode, contextData: requestAcademicContext, }; try { const persistedAssistantId = await persistHistoryPair(historyPayload); if (persistedAssistantId) { setEntries((current) => { const next = [...current]; for (let index = next.length - 1; index >= 0; index -= 1) { const entry = next[index]; if (entry.role === "assistant" && entry.answer === finalAnswer) { next[index] = { ...entry, id: persistedAssistantId }; break; } } return next; }); } // "N messages · " until a full reload. } catch { pendingHistorySyncRef.current = [ ...pendingHistorySyncRef.current.filter( (item) => item.clientTurnId !== historyPayload.clientTurnId, ), historyPayload, ]; setHistorySyncError( "Your answer is ready, but it has not synced to chat history yet.", ); } } } catch (error) { if (controller.signal.aborted) { setEntries((prev) => [...prev, { id: nextTutorEntryId(), role: "info", text: "Cancelled. Your question is kept below — send it again anytime." }]); } else { const safeMessage = isApiError(error) ? `${normalizeApiError(error)}${error.requestId ? ` Reference ID: ${error.requestId}` : ""}` : "Could not get an answer. Check your connection and try again."; setEntries((prev) => [ ...prev, { id: nextTutorEntryId(), role: "error", text: safeMessage, retryText: question, }, ]); } // Either way, don't lose the student's words. setMessage(question); } finally { abortRef.current = null; setIsAsking(false); } }, [academicContext, context.subject, message, persistHistoryPair, selectedSourceIds], ); const retryHistorySync = useCallback(async () => { if (isHistorySyncing || pendingHistorySyncRef.current.length === 0) return; setIsHistorySyncing(true); const failed: PendingHistorySync[] = []; for (const pending of pendingHistorySyncRef.current) { try { await persistHistoryPair(pending); } catch { failed.push(pending); } } pendingHistorySyncRef.current = failed; setHistorySyncError( failed.length ? `${failed.length} answer${failed.length === 1 ? " is" : "s are"} still waiting to sync.` : null, ); setIsHistorySyncing(false); }, [isHistorySyncing, persistHistoryPair]); const handleCancel = useCallback(() => { abortRef.current?.abort(); }, []); const openChat = useCallback(async (sessionId: string) => { if (abortRef.current) return; const detail = await getChatSession(sessionId); sessionIdRef.current = detail.id; setCurrentSessionId(detail.id); setAcademicContext(detail.context_data); setEntries( detail.messages.map((item) => item.role === "user" ? ({ id: item.id, role: "user", text: item.content } as TutorEntry) : ({ id: item.id, role: "assistant", answer: item.content, sections: [], suggestedActions: [], evidenceLabel: item.evidence_label ?? null, webSources: item.web_sources ?? [], evidence: item.evidence ?? null, } as TutorEntry), ), ); setMessage(""); return detail; }, []); const startNewChat = useCallback(() => { if (abortRef.current) return; sessionIdRef.current = null; setCurrentSessionId(null); setEntries([]); setMessage(""); setUploadStatus(null); setAcademicContext(null); pendingAcademicContextRef.current = null; }, []); /** Save one assistant answer as a note; title comes from the question asked. */ const saveAnswerAsNote = useCallback( (title: string, content: string, options?: { isFallback?: boolean; topic?: string }) => { return saveNote({ title: title.slice(0, 80) || "StudyChat note", content, topic: options?.topic ?? title.slice(0, 120), subject: context.subject ?? undefined, source: "study-chat", provenance: options?.isFallback ? "starter" : "manual", }); }, [context.subject], ); const handleUpload = useCallback(async (file: File) => { // A new upload supersedes any in-flight poll: bump the generation and only // let this run write state while it is still the current one AND mounted. const run = ++uploadRunRef.current; const isCurrent = () => mountedRef.current && uploadRunRef.current === run; const apply = (state: UploadState, status: string) => { if (!isCurrent()) return; setUploadState(state); setUploadStatus(status); }; const rememberReadySource = (readyDocument: { id: string; title?: string | null; }) => { if (!isCurrent()) return; setAcademicContext((current) => { const next: StudyChatAcademicContext = { ...current, origin: current?.origin ?? "study-chat", subject: current?.subject ?? context.subject ?? null, source_id: readyDocument.id, source_ref: readyDocument.id, source_label: readyDocument.title || file.name, activity_type: "uploaded-material", return_href: current?.return_href ?? "/study-chat", captured_at: new Date().toISOString(), }; pendingSourceIdRef.current = readyDocument.id; pendingAcademicContextRef.current = next; return next; }); }; const clientError = validateUploadFile(file); if (clientError) { apply({ phase: "error", fileName: file.name, message: clientError }, clientError); return; } apply({ phase: "uploading", fileName: file.name }, `Uploading ${file.name}…`); let doc; try { doc = await uploadDocument({ file }); } catch (error) { // Surface the real reason (rate limit, size, auth) instead of swallowing it. const message = isApiError(error) ? normalizeApiError(error) : "Upload failed. Check your connection and try again."; apply({ phase: "error", fileName: file.name, message }, message); return; } if (!isCurrent()) return; // superseded or unmounted while uploading // The record exists; extraction runs async. Poll until it is usable so the // UI never claims "ready" while parsing is still in progress or has failed. apply( { phase: "processing", fileName: file.name, documentId: doc.id }, `Processing ${doc.title || file.name}…`, ); if (doc.status === "ready") { rememberReadySource(doc); apply( { phase: "ready", fileName: file.name, documentId: doc.id }, `${doc.title || file.name} is ready to study from.`, ); return; } for (let attempt = 0; attempt < 40; attempt++) { await new Promise((resolve) => setTimeout(resolve, 3000)); if (!isCurrent()) return; // unmounted or a newer upload took over — stop cleanly let latest; try { latest = await getDocument(doc.id); } catch { continue; // transient; keep polling } if (latest.status === "ready") { rememberReadySource(latest); apply( { phase: "ready", fileName: file.name, documentId: doc.id }, `${latest.title || file.name} is ready to study from.`, ); return; } if (latest.status === "failed") { apply( { phase: "error", fileName: file.name, documentId: doc.id, message: "DocDoe could not read this file. It may be scanned images without text, or corrupted. Try another file.", }, "Processing failed. Try another file.", ); return; } } // Still processing after the poll window — honest, not a false success. apply( { phase: "error", fileName: file.name, documentId: doc.id, message: "This file is taking longer than expected to process. It will appear in your materials when ready.", }, "Still processing — check your materials shortly.", ); }, [context.subject]); // Context + history restore. useEffect(() => { let cancelled = false; void loadStudentContext().then((merged) => { if (!cancelled) setContext(merged); }); if (!hasLiveSession()) { return () => { cancelled = true; }; } void listChatSessions() .then((sessions) => { if (cancelled) return; setRecentChats(sessions); setHistoryLoadError(null); if (autoRanRef.current) return; const mostRecent = sessions[0]; if (!mostRecent) return; return getChatSession(mostRecent.id).then((detail) => { if (cancelled) return; if (autoRanRef.current) return; sessionIdRef.current = detail.id; setCurrentSessionId(detail.id); setAcademicContext(detail.context_data); setEntries((prev) => prev.length > 0 ? prev : detail.messages.map((m) => m.role === "user" ? ({ id: m.id, role: "user", text: m.content } as TutorEntry) : ({ id: m.id, role: "assistant", answer: m.content, sections: [], suggestedActions: [], evidenceLabel: m.evidence_label ?? null, webSources: m.web_sources ?? [], evidence: m.evidence ?? null, } as TutorEntry), ), ); }); }) .catch(() => { if (cancelled) return; setHistoryLoadError( "We couldn't load your recent chat. Your saved chats are safe — refresh to try again, or start a new question below.", ); }); return () => { cancelled = true; }; }, []); // URL intent handoff: /study-chat?prompt=…&run=1 auto-sends; without run=1 // it prefills the composer. The params are stripped afterwards so a refresh // doesn't re-fire the lesson. useEffect(() => { if (autoRanRef.current) return; const requestedSessionId = searchParams.get("sessionId"); if (requestedSessionId) { autoRanRef.current = true; void openChat(requestedSessionId).catch(() => { setHistoryLoadError( "That shared chat could not open. Check your connection, then open it again from your chat list.", ); }); router.replace("/study-chat", { scroll: false }); return; } const prompt = searchParams.get("prompt"); if (!prompt) return; autoRanRef.current = true; const intent = searchParams.get("intent"); pendingIntentRef.current = intent; pendingSourceIdRef.current = searchParams.get("sourceId"); const originPage = searchParams.get("originPage"); pendingOriginPageRef.current = isChatOriginPage(originPage) ? originPage : null; const returnHref = searchParams.get("returnTo"); const handoffContext: StudyChatAcademicContext = { origin: pendingOriginPageRef.current, subject: searchParams.get("subject"), chapter: searchParams.get("chapter"), topic: searchParams.get("topic"), objective: searchParams.get("objective"), current_step: searchParams.get("currentStep"), relevant_context: searchParams.get("relevantContext"), source_id: pendingSourceIdRef.current, source_ref: searchParams.get("sourceRef"), source_label: searchParams.get("sourceLabel"), activity_type: searchParams.get("activity"), question_id: searchParams.get("questionId"), question_label: searchParams.get("questionLabel") ?? prompt, return_href: returnHref?.startsWith("/") && !returnHref.startsWith("//") ? returnHref : null, captured_at: new Date().toISOString(), }; if (Object.values(handoffContext).some(Boolean)) { pendingAcademicContextRef.current = handoffContext; setAcademicContext(handoffContext); // A route handoff is one academic thread, not an unrelated append to the // last chat that happened to be open on this device. sessionIdRef.current = null; setCurrentSessionId(null); setEntries([]); } const shouldRun = searchParams.get("run") === "1"; // A teach_topic handoff that auto-runs IS a lesson starting — record it // here so every entry point (Learn Anything, Tuition, Home) counts once. if (shouldRun && intent === "teach_topic") { // "Teach me X — today's lesson: Y. Cover: …" → keep only the readable head. addActivity("lesson_started", prompt.split(". Cover")[0].slice(0, 90)); } // Microtask (not setTimeout): router.replace below changes searchParams, // which re-runs this effect — a timer would be cancelled by the cleanup // before it ever fired. A microtask runs before that re-render. void Promise.resolve().then(() => { if (shouldRun) { void handleSend(prompt); } else { setMessage(prompt); } }); router.replace("/study-chat", { scroll: false }); }, [searchParams, router, handleSend, openChat]); return { message, setMessage, entries, isAsking, recentChats, context, academicContext, currentSessionId, uploadStatus, uploadState, historySyncError, historyLoadError, isHistorySyncing, handleSend, handleCancel, handleUpload, retryHistorySync, openChat, startNewChat, saveAnswerAsNote, }; }