"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowRight,
BookOpen,
Check,
ClipboardCheck,
FileQuestion,
FileText,
ExternalLink,
Globe2,
GraduationCap,
History,
LoaderCircle,
MessageSquareText,
NotebookPen,
Paperclip,
Plus,
RotateCcw,
Send,
Trash2,
Upload,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { DocDoeLogoMark } from "@/components/landing/DocDoeLogoMark";
import { deleteDocument, listDocuments } from "@/lib/api/documents";
import { PDF_UPLOAD_ACCEPT } from "@/lib/uploads/document-upload-contract";
import type { ApiDocument } from "@/lib/api/types";
import { hasLiveSession } from "@/lib/api/mode";
import { useStudyChatTutor, type TutorEntry, type UploadState } from "./useStudyChatTutor";
import { MaterialDeletePrompt } from "./MaterialDeletePrompt";
import {
LEARNING_STATE_CHANGED_EVENT,
useStudentWorkspace,
} from "@/components/student-os/StudentWorkspaceProvider";
import type { WorkspaceNote } from "@/lib/api/student-workspace";
import {
createStudyFollowUp,
listOpenStudyFollowUps,
} from "@/lib/api/learning-state";
import {
getOperationUsage,
formatStudyChatUsage,
type OperationUsageItem,
} from "@/lib/api/operation-usage";
type NoteSaveStatus = "idle" | "saving" | "saved" | "error";
type FollowUpSaveStatus = "idle" | "saving" | "saved" | "error";
/** Honest upload lifecycle banner: loading, processing, success, and retryable error. */
function UploadStatusBanner({
state,
onRetry,
className,
}: {
state: UploadState;
onRetry: () => void;
className?: string;
}) {
if (state.phase === "idle") return null;
const busy = state.phase === "uploading" || state.phase === "processing";
const isError = state.phase === "error";
const isReady = state.phase === "ready";
const label =
state.phase === "uploading"
? `Uploading ${state.fileName}…`
: state.phase === "processing"
? `Reading ${state.fileName}… this can take a moment.`
: state.phase === "ready"
? `${state.fileName} is ready — ask a question from it.`
: state.message;
return (
{busy ? (
) : isReady ? (
) : (
)}
{label}
{isError ? (
Retry
) : null}
);
}
const STUDY_TOOLS = [
{
icon: FileQuestion,
title: "Solve a PYQ",
detail: "Method, marks and final answer",
prompt:
"Help me solve a previous year question. Ask me to upload or paste the question first.",
},
{
icon: GraduationCap,
title: "Explain a derivation",
detail: "Steps, formula and exam method",
prompt:
"Explain a derivation step by step and show the board exam writing method.",
},
{
icon: NotebookPen,
title: "Make study notes",
detail: "Clean notes from a topic or PDF",
prompt:
"Create clear board-ready notes. Ask me for the topic or material first.",
},
{
icon: ClipboardCheck,
title: "Correct my answer",
detail: "Marks, mistakes and improved answer",
prompt:
"Correct my answer like a Kerala board teacher. Ask for the question and my answer.",
},
] as const;
const STARTER_PROMPTS = [
"Teach this concept from the basics",
"Give me a board-aligned answer",
"Test me with five questions",
"Show a worked example",
] as const;
const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat("en-IN", {
month: "short",
day: "numeric",
timeZone: "Asia/Kolkata",
});
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return SHORT_DATE_FORMATTER.format(date);
}
function answerText(entry: Extract) {
return entry.sections.length
? entry.sections
.map((section) => `${section.title}\n${section.body}`)
.join("\n\n")
: entry.answer;
}
function WorkspaceMark({ className }: { className?: string } = {}) {
return (
);
}
function ContextSummary({
className,
context,
academicContext,
}: {
className?: string;
context: ReturnType["context"];
academicContext: ReturnType["academicContext"];
}) {
const academicItems = [
academicContext?.subject,
academicContext?.chapter,
academicContext?.source_label ?? academicContext?.topic,
academicContext?.activity_type === "tuition-question"
? "Tuition question"
: academicContext?.activity_type === "pyq-question"
? "PYQ question"
: academicContext?.activity_type === "uploaded-material"
? "Uploaded material"
: academicContext?.source_label,
].filter((item): item is string => Boolean(item));
const profileItems = [context.classLevel, context.board, context.subject].filter(
(item): item is string => Boolean(item),
);
const items = Array.from(
new Set(academicItems.length ? academicItems : profileItems),
);
return (
{items.length ? items.join(" · ") : "General study"}
{academicContext?.return_href ? (
Return to {academicContext.origin === "tuition" ? "Tuition" : "previous work"}
) : null}
);
}
export function StudyChatWorkspace() {
const { data: workspaceData, updateWorkspace } = useStudentWorkspace();
const [selectedSourceIds, setSelectedSourceIds] = useState([]);
const tutor = useStudyChatTutor(selectedSourceIds);
const {
message,
setMessage,
entries,
isAsking,
recentChats,
context,
academicContext,
currentSessionId,
uploadState,
historySyncError,
historyLoadError,
isHistorySyncing,
handleSend,
handleCancel,
handleUpload,
retryHistorySync,
openChat,
startNewChat,
saveAnswerAsNote,
} = tutor;
const [materials, setMaterials] = useState([]);
const [materialsError, setMaterialsError] = useState(null);
const [savedAnswers, setSavedAnswers] = useState>(
() => new Set(),
);
const [noteSaveStates, setNoteSaveStates] = useState<
Record
>({});
const [followUpSaveStates, setFollowUpSaveStates] = useState<
Record
>({});
const [persistedFollowUpSources, setPersistedFollowUpSources] = useState>(
() => new Set(),
);
const pendingNotesRef = useRef(new Map());
const [mobilePanel, setMobilePanel] = useState<"history" | "tools" | null>(
null,
);
const closeMobilePanel = useCallback(() => setMobilePanel(null), []);
const [loadingChatId, setLoadingChatId] = useState(null);
const [chatOpenError, setChatOpenError] = useState(null);
const fileInputRef = useRef(null);
const conversationScrollRef = useRef(null);
const conversationEndRef = useRef(null);
const shouldFollowConversationRef = useRef(true);
const [chatUsage, setChatUsage] = useState(null);
const persistedSavedAnswers = useMemo(
() =>
new Set(
workspaceData.notes
.map((note) => note.source_message_id)
.filter((value): value is string => Boolean(value)),
),
[workspaceData.notes],
);
useEffect(() => {
let cancelled = false;
if (!hasLiveSession()) return;
void getOperationUsage()
.then((status) => {
if (!cancelled) {
setChatUsage(
status.items.find((item) => item.operation === "study_chat_ai") ?? null,
);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [isAsking]);
useEffect(() => {
let cancelled = false;
if (!hasLiveSession()) return;
void listOpenStudyFollowUps()
.then((items) => {
if (cancelled) return;
const sources = new Set();
for (const item of items) {
if (item.status !== "open") continue;
if (typeof item.source_ref === "string" && item.source_ref.trim()) {
sources.add(item.source_ref);
}
}
setPersistedFollowUpSources(sources);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [currentSessionId]);
const handleSaveAnswer = useCallback(
async (
entryId: string,
title: string,
content: string,
options?: {
isFallback?: boolean;
topic?: string;
evidence?: Extract["evidence"];
},
) => {
if (savedAnswers.has(entryId) || persistedSavedAnswers.has(entryId)) return;
let workspaceNote = pendingNotesRef.current.get(entryId);
if (!workspaceNote) {
const savedNote = saveAnswerAsNote(title, content, options);
workspaceNote = {
id: savedNote.id,
title: savedNote.title,
body: savedNote.content,
subject: savedNote.subject ?? null,
topic: savedNote.topic ?? null,
source_id: options?.evidence?.source_ids[0] ?? savedNote.sourceId ?? null,
source_label: options?.isFallback
? "StudyChat · offline fallback"
: "StudyChat",
favorite: false,
chat_session_id: currentSessionId,
source_message_id: entryId.startsWith("cmsg_") ? entryId : null,
evidence: options?.evidence ?? null,
created_at: savedNote.at,
updated_at: savedNote.at,
};
pendingNotesRef.current.set(entryId, workspaceNote);
}
setNoteSaveStates((current) => ({
...current,
[entryId]: { status: "saving" },
}));
try {
const note = workspaceNote;
await updateWorkspace((current) => ({
...current,
notes: [
note,
...current.notes.filter((item) => item.id !== note.id),
],
}));
pendingNotesRef.current.delete(entryId);
setSavedAnswers((current) => new Set(current).add(entryId));
setNoteSaveStates((current) => ({
...current,
[entryId]: { status: "saved" },
}));
} catch {
setNoteSaveStates((current) => ({
...current,
[entryId]: {
status: "error",
message:
"Saved on this device. Retry when connected to sync it across devices.",
},
}));
}
},
[currentSessionId, persistedSavedAnswers, saveAnswerAsNote, savedAnswers, updateWorkspace],
);
const handlePlanFollowUp = useCallback(
async (entryId: string, question: string) => {
if (!currentSessionId || !hasLiveSession()) return;
setFollowUpSaveStates((current) => ({
...current,
[entryId]: { status: "saving" },
}));
const activityType = academicContext?.activity_type ?? "";
const kind = activityType.includes("pyq")
? "pyq_review"
: activityType === "uploaded-material"
? "source_review"
: "clarification";
const topic = academicContext?.topic ?? question.slice(0, 180);
try {
await createStudyFollowUp({
client_followup_id: `studychat:${currentSessionId}:${entryId}`.slice(0, 160),
chat_session_id: currentSessionId,
kind,
title: `Follow up: ${topic || "Study Chat answer"}`.slice(0, 220),
subject: academicContext?.subject ?? context.subject ?? null,
chapter: academicContext?.chapter ?? null,
topic: topic || null,
due_at: new Date().toISOString(),
estimated_minutes: 10,
source_ref: entryId.slice(0, 180),
target: { kind: "study_chat_follow_up" },
});
setFollowUpSaveStates((current) => ({
...current,
[entryId]: { status: "saved" },
}));
setPersistedFollowUpSources((current) => {
const next = new Set(current);
next.add(entryId);
return next;
});
window.dispatchEvent(new Event(LEARNING_STATE_CHANGED_EVENT));
} catch {
setFollowUpSaveStates((current) => ({
...current,
[entryId]: {
status: "error",
message: "Could not add this follow-up. Your chat is unchanged; retry when connected.",
},
}));
}
},
[academicContext, context.subject, currentSessionId],
);
useEffect(() => {
let cancelled = false;
void listDocuments()
.then((items) => {
if (cancelled) return;
setMaterials(items);
setMaterialsError(null);
setSelectedSourceIds((current) => {
const available = new Set(items.map((item) => item.id));
const retained = current.filter((id) => available.has(id));
// A freshly-processed upload auto-selects itself so the student can
// immediately ask questions from it — but only once it is READY.
if (uploadState.phase === "ready") {
const readyDoc = items.find(
(item) => item.id === uploadState.documentId && item.status === "ready",
);
if (readyDoc && !retained.includes(readyDoc.id)) {
return [...retained, readyDoc.id];
}
}
return retained;
});
})
.catch(() => {
if (!cancelled) {
setMaterialsError(
"We couldn't load your saved materials. Your files are safe — check your connection and try again.",
);
}
});
return () => {
cancelled = true;
};
// Re-list on each upload-phase change so processing→ready flips the material in.
}, [uploadState.phase, uploadState]);
const selectedMaterials = useMemo(
() =>
materials.filter((material) => selectedSourceIds.includes(material.id)),
[materials, selectedSourceIds],
);
function toggleMaterial(id: string) {
setSelectedSourceIds((current) =>
current.includes(id)
? current.filter((sourceId) => sourceId !== id)
: [...current, id].slice(-4),
);
}
const [deletingMaterialId, setDeletingMaterialId] = useState(null);
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
const [deleteMaterialError, setDeleteMaterialError] = useState<{
id: string;
message: string;
} | null>(null);
async function removeMaterial(id: string) {
setDeletingMaterialId(id);
try {
await deleteDocument(id);
setMaterials((current) => current.filter((material) => material.id !== id));
setSelectedSourceIds((current) => current.filter((sourceId) => sourceId !== id));
setDeleteMaterialError(null);
setConfirmDeleteId(null);
} catch {
setDeleteMaterialError({
id,
message:
"Could not delete this source. It is still available. Check your connection and retry.",
});
setConfirmDeleteId(id);
} finally {
setDeletingMaterialId(null);
}
}
useEffect(() => {
if (!shouldFollowConversationRef.current) return;
conversationEndRef.current?.scrollIntoView({
behavior: isAsking ? "auto" : "smooth",
block: "end",
});
}, [entries, isAsking]);
function sendMessage(text?: string) {
shouldFollowConversationRef.current = true;
return handleSend(text);
}
function beginNewChat() {
shouldFollowConversationRef.current = true;
startNewChat();
}
function handleConversationScroll() {
const element = conversationScrollRef.current;
if (!element) return;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
shouldFollowConversationRef.current = distanceFromBottom <= 96;
}
const title = useMemo(() => {
const firstQuestion = entries.find((entry) => entry.role === "user");
return firstQuestion && firstQuestion.role === "user"
? firstQuestion.text
: "New study chat";
}, [entries]);
async function chooseChat(id: string) {
shouldFollowConversationRef.current = true;
setLoadingChatId(id);
setChatOpenError(null);
try {
const detail = await openChat(id);
if (!detail) return;
const restoredSourceIds =
detail.active_source_ids.length
? detail.active_source_ids
: detail.source_id
? [detail.source_id]
: [];
const availableSourceIds = new Set(materials.map((material) => material.id));
setSelectedSourceIds(restoredSourceIds.filter((sourceId) => availableSourceIds.has(sourceId)));
setMobilePanel(null);
} catch {
setChatOpenError(
"That chat could not open. Your saved chats are safe — check your connection and try again.",
);
} finally {
setLoadingChatId(null);
}
}
function noteTitleFor(index: number) {
const previous = entries[index - 1];
return previous?.role === "user" ? previous.text : "StudyChat note";
}
return (
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"
>
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"
>
New chat
Current study
{context.subject || "Your study space"}
{!context.onboardingComplete ? (
Add your class and subject
) : null}
{recentChats.length ? (
recentChats.slice(0, 10).map((chat) => (
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 ? (
) : (
)}
{chat.title}
{chat.message_count} messages ·{" "}
{formatDate(chat.updated_at)}
))
) : (
{hasLiveSession()
? "Your conversations will appear here."
: "Sign in to sync conversations across devices."}
)}
{entries.length ? (
{entries.map((entry, index) => (
{entry.role === "user" ? (
) : entry.role === "error" ? (
{entry.text}
void sendMessage(entry.retryText)}
className="inline-flex min-h-[44px] items-center gap-1.5 font-semibold"
>
Retry
) : entry.role === "info" ? (
{entry.text}
) : (
void sendMessage(text)}
followUpState={
persistedFollowUpSources.has(entry.id)
? { status: "saved" }
: followUpSaveStates[entry.id] ?? { status: "idle" }
}
followUpUnavailableReason={
!hasLiveSession()
? "Sign in to add to plan"
: historySyncError
? "Sync this answer before adding it to the plan"
: !currentSessionId
? "Syncing chat…"
: null
}
onPlanFollowUp={() =>
void handlePlanFollowUp(entry.id, noteTitleFor(index))
}
onSave={() =>
void handleSaveAnswer(
entry.id,
noteTitleFor(index),
answerText(entry),
{
isFallback: entry.isFallback,
topic: noteTitleFor(index),
evidence: entry.evidence,
},
)
}
/>
)}
))}
{isAsking ? (
Reading the question and building the answer…
Cancel
) : null}
) : (
void sendMessage(prompt)}
onUpload={() => fileInputRef.current?.click()}
/>
)}
{selectedMaterials.length ? (
Using
{selectedMaterials.map((material) => (
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"
>
{material.title || material.file_name}
))}
) : null}
fileInputRef.current?.click()}
/>
{historyLoadError || chatOpenError || materialsError ? (
{historyLoadError ?? chatOpenError ?? materialsError}
) : null}
{historySyncError ? (
{historySyncError}
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"
>
{isHistorySyncing ? "Saving…" : "Retry save"}
) : null}
{formatStudyChatUsage(chatUsage)
? `${formatStudyChatUsage(chatUsage)} · `
: null}
Double-check exam facts against your textbook or notes.
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);
}}
/>
{
const file = event.target.files?.[0];
if (file) void handleUpload(file);
event.target.value = "";
}}
/>
{mobilePanel ? (
{
beginNewChat();
setMobilePanel(null);
}}
onOpenChat={(id) => void chooseChat(id)}
onPrompt={(prompt) => {
setMobilePanel(null);
void sendMessage(prompt);
}}
onToggleMaterial={toggleMaterial}
onUpload={() => {
setMobilePanel(null);
fileInputRef.current?.click();
}}
/>
) : null}
);
}
function EmptyConversation({
context,
academicContext,
onPrompt,
onUpload,
}: {
context: ReturnType["context"];
academicContext: ReturnType["academicContext"];
onPrompt: (prompt: string) => void;
onUpload: () => void;
}) {
return (
Study desk
Stuck on a question? Let's clear it.
Paste a doubt, add a PDF, or choose a study move below. When available, your class and board guide the answer.
{STUDY_TOOLS.map((tool) => {
const Icon = tool.icon;
return (
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"
>
{tool.title}
{tool.detail}
);
})}
Start from a PDF
);
}
function AssistantAnswer({
entry,
saved,
saveState,
onFollowUp,
followUpState,
followUpUnavailableReason,
onPlanFollowUp,
onSave,
}: {
entry: Extract;
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 (
DocDoe
{entry.isFallback ? (
Offline preview
) : null}
{entry.sections.length ? (
{entry.sections.map((section, index) => (
{String(index + 1).padStart(2, "0")}
{section.title}
{section.body}
))}
) : (
(
{children}
),
p: ({ children }) => (
{children}
),
strong: ({ children }) => (
{children}
),
ul: ({ children }) => (
),
ol: ({ children }) => (
{children}
),
code: ({ children }) => (
{children}
),
a: ({ href, children }) => (
{children}
),
}}
>
{entry.answer}
)}
{entry.evidenceLabel &&
entry.evidenceLabel !== "Searched the web" &&
entry.evidenceLabel !== "Web search unavailable" &&
entry.evidenceLabel !== "No source attached" ? (
{entry.evidenceLabel}
) : null}
{entry.evidenceLabel === "Searched the web" ? (
Searched the web
{entry.webSources?.length ? (
) : (
No safe citation details were returned. Verify the answer with an official source.
)}
) : null}
{entry.evidenceLabel === "Web search unavailable" ? (
Web search unavailable
This answer was not verified against live sources. Retry later or check an official website before relying on current details.
) : null}
{entry.evidence ? (
{entry.evidence.pyq_classification === "verified_pyq"
? "Verified PYQ evidence"
: entry.evidence.pyq_classification === "pyq_style"
? "PYQ-style practice"
: "Answer evidence"}
{entry.evidence.evidence_scope}
{entry.evidence.citations.length ? (
{entry.evidence.citations.length} cited passage{entry.evidence.citations.length === 1 ? "" : "s"}
{entry.evidence.citations.map((citation) => {
const content = <>
{citation.source_title}
{citation.page_number ? ` · page ${citation.page_number}` : ""}
{citation.heading ? (
· {citation.heading}
) : null}
{citation.excerpt}
{citation.availability === "snapshot" ? (
Source snapshot · original source removed
) : 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" ? (
{content}
) : (
{content}
);
})}
) : null}
{entry.evidence.pyq_analysis ? (
Verified analyzer result
{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"}
{!entry.evidence.pyq_analysis.available ? (
Sparse verified evidence: {entry.evidence.pyq_analysis.message}
) : null}
) : null}
) : null}
{generationPending ? (
) : saved ? (
) : saving ? (
) : saveFailed ? (
) : (
)}
{generationPending
? "Generating…"
: saved
? "Saved"
: saving
? "Saving…"
: saveFailed
? "Retry sync"
: "Save as note"}
{followUpSaved ? (
) : followUpSaving ? (
) : followUpState.status === "error" ? (
) : (
)}
{followUpSaved
? "Added to plan"
: followUpSaving
? "Adding…"
: followUpUnavailableReason ??
(followUpState.status === "error" ? "Retry follow-up" : "Add follow-up to plan")}
{entry.suggestedActions.slice(0, 3).map((action) => (
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}
))}
{saveFailed && saveState.message ? (
{saveState.message}
) : null}
{followUpState.status === "error" && followUpState.message ? (
{followUpState.message}
) : null}
);
}
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 (
{materials.length ? (
Choose the sources DocDoe should read for the next answer.
) : null}
{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 (
onConfirmDelete(material.id)}
onCancel={onCancelDelete}
/>
);
}
return (
onToggleMaterial(material.id)}
className="flex min-w-0 flex-1 items-start gap-2.5 text-left"
>
{material.title || material.file_name}
{material.status}
{material.subject ? ` · ${material.subject}` : ""}
{selected ? : null}
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",
)}
>
);
})
) : (
Add the material you want DocDoe to use
)}
Try next
{STARTER_PROMPTS.map((prompt) => (
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"
>
{prompt}
))}
);
}
function MobilePanel({
panel,
materials,
selectedSourceIds,
recentChats,
onClose,
onNew,
onOpenChat,
onPrompt,
onToggleMaterial,
onUpload,
}: {
panel: "history" | "tools";
materials: ApiDocument[];
selectedSourceIds: string[];
recentChats: ReturnType["recentChats"];
onClose: () => void;
onNew: () => void;
onOpenChat: (id: string) => void;
onPrompt: (prompt: string) => void;
onToggleMaterial: (id: string) => void;
onUpload: () => void;
}) {
const panelRef = useRef(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(
'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("[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 (
{panel === "history" ? "Recent chats" : "Study tools"}
{panel === "history" ? (
New chat
{recentChats.map((chat) => (
onOpenChat(chat.id)}
className="flex w-full items-center gap-3 py-3 text-left"
>
{chat.title}
{formatDate(chat.updated_at)}
))}
) : (
{STUDY_TOOLS.map((tool) => {
const Icon = tool.icon;
return (
onPrompt(tool.prompt)}
className="flex min-h-[44px] w-full items-center gap-3 py-3.5 text-left"
>
{tool.title}
{tool.detail}
);
})}
{materials.length ? (
Answer from
{materials.slice(0, 6).map((material) => (
onToggleMaterial(material.id)}
className="flex w-full items-center gap-3 rounded-lg py-2 text-left"
>
{selectedSourceIds.includes(material.id) ? (
) : null}
{material.title || material.file_name}
))}
) : null}
Upload material ({materials.length})
)}
);
}