DocDoeAI / src /components /study-chat /useStudyChatTutor.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
34.5 kB
"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<TutorEntry[]>([]);
const [isAsking, setIsAsking] = useState(false);
const [recentChats, setRecentChats] = useState<ChatSessionOut[]>([]);
const [context, setContext] = useState<StudentContext>(() => getLocalStudentContext());
const [academicContext, setAcademicContext] =
useState<StudyChatAcademicContext | null>(null);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const [uploadState, setUploadState] = useState<UploadState>({ phase: "idle" });
const [historySyncError, setHistorySyncError] = useState<string | null>(null);
const [historyLoadError, setHistoryLoadError] = useState<string | null>(null);
const [isHistorySyncing, setIsHistorySyncing] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const sessionIdRef = useRef<string | null>(null);
const abortRef = useRef<AbortController | null>(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<PendingHistorySync[]>([]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
/** Intent carried by the URL handoff β€” consumed by the next successful answer. */
const pendingIntentRef = useRef<string | null>(null);
const pendingSourceIdRef = useRef<string | null>(null);
const pendingOriginPageRef = useRef<ChatOriginPage | null>(null);
const pendingAcademicContextRef = useRef<StudyChatAcademicContext | null>(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 Β· <date>" 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,
};
}