DocDoeAI / src /components /study-chat /StudyChatWorkspace.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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&apos;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>
);
}