freezmo/components/teach/TeachScreen.tsx
Martin Dvorak 6036238bf0 Teach it back: the AI becomes the curious student
A new mode where the user explains a topic in their own words and the AI
plays a curious classmate: short reactions, exactly one follow-up question
at a time, never "correct"/"wrong", never lecturing, and everything it knows
comes from the user's own decks and notes.

- Entry points: a sidebar row next to Exam mode and a "Teach it back" action
  in every deck's menu (which prefills the topic and scope).
- Start screen: deck or typed topic, friendly/skeptical student, the same
  context and model chips as Chat (/teach?deck=<id> pre-selects the deck).
- Session: the student prompt is a mode of the existing /api/chat route, so
  model resolution, allowlists, rate limits and the streaming client are the
  same code; the personality lives in lib/prompts.ts (TEACH_*). The deck's
  cards travel as a numbered checklist and the student is told to ask about
  the ideas that were skipped; it must say so rather than guess when the
  notes cover nothing. One constant (TEACH_MAX_EXCHANGES = 10) ends the
  session, or the Finish button does.
- Recap: a teach_summary call (JSON, one repair retry) yields well / gaps
  with card references / next steps; each gap links to the card it means.
  "Add missed cards to review" schedules those activities due now; "Turn
  gaps into cards" generates one flashcard per gap from the deck's notes and
  shows an editable review list before saving.
- Sessions are chat conversations with a mode:"teach" marker + session
  record: same IndexedDB history, backup and sync, filtered out of the Chat
  screens; the recent list reopens a recap or continues a session.
- Reused pieces: MessageRow, Composer (now optional attach/scope chips and a
  mic), Markdown/KaTeX, AiDisclaimer, ContextPicker/ModelPicker, plus a
  shared useChatModels hook and useKeyboardInset extracted from Chat.
- Dictation: Web Speech API recognition (lib/speech-input.ts); the mic only
  renders where the browser supports it, and dictated phrases land in the
  draft.
- All strings in en + cs. teach-test: 44 checks (API prompts and guards,
  both entry points, the session, recap actions, history, exchange limit,
  360/390px layouts, mic on/off). Version 0.8.1, SW v103.
2026-10-16 23:05:24 +00:00

885 lines
34 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { MessageRow } from "@/components/chat/MessageRow";
import { ModelPicker } from "@/components/chat/ModelPicker";
import { Composer, PickerChip } from "@/components/chat/Composer";
import { ContextPicker } from "@/components/chat/ContextPicker";
import { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui";
import { ChatIcon, ChevronLeftIcon, RefreshIcon, TeacherIcon, XIcon } from "@/components/icons";
import { ApiError } from "@/lib/client-api";
import { streamChat } from "@/lib/chat-client";
import { buildChatContext } from "@/lib/chat-context";
import { useChatModels } from "@/lib/chat-models";
import { chatMessageId, titleFromMessage, type ChatMessage, type ChatModelChoice, type ChatScope } from "@/lib/chat";
import {
chatHistoryStore,
conversationMessages,
deleteConversation,
ensureChatsHydrated,
getConversation,
saveConversation,
} from "@/lib/chat-store";
import { folderStore } from "@/lib/folders";
import { getLocale, useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import { providerInfo } from "@/lib/providers";
import { TEACH_SUMMARY_REPAIR_INSTRUCTION, TEACH_SUMMARY_USER_INSTRUCTION } from "@/lib/prompts";
import type { ChatConversation, Deck, Folder, TeachCardRef, TeachDifficulty, TeachSummary } from "@/lib/schemas";
import { settingsStore } from "@/lib/settings";
import { speechLocale } from "@/lib/speech";
import { startSpeechInput, speechInputAvailable, type SpeechInput } from "@/lib/speech-input";
import { deckStore } from "@/lib/storage";
import { buildTeachCards, parseTeachSummary, scopedDecks, teachCardLines, TEACH_MAX_EXCHANGES } from "@/lib/teach";
import { cn, formatDate, prettyModelName } from "@/lib/utils";
import { showToast } from "@/lib/toast";
import { useKeyboardInset } from "@/lib/use-keyboard-inset";
import { useOnline } from "@/lib/use-online";
import { TeachRecap } from "./TeachRecap";
type View = "start" | "session" | "summary";
/**
* Teach it back: the user explains a topic in their own words, the AI plays a
* curious student (never a grader) and checks the explanation against the
* user's own cards. Sessions are chat conversations with `mode: "teach"`, so
* they live in the same history, backup and sync as chats but stay out of the
* Chat screens; the recap is stored with the session and can be reopened.
*/
export function TeachScreen() {
const { t, plural } = useI18n();
const online = useOnline();
const reduceMotion = useReducedMotion();
const router = useRouter();
const search = useSearchParams();
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore(folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot);
const settings = useSyncExternalStore(settingsStore.subscribe, settingsStore.getSnapshot, settingsStore.getServerSnapshot);
const conversations = useSyncExternalStore(
chatHistoryStore.subscribe,
chatHistoryStore.getSnapshot,
chatHistoryStore.getServerSnapshot,
);
const chatModel = useChatModels();
useKeyboardInset();
const deckParam = search.get("deck");
const conversationParam = search.get("c");
const [view, setView] = useState<View>("start");
const [topic, setTopic] = useState("");
const [difficulty, setDifficulty] = useState<TeachDifficulty>("friendly");
const [scope, setScope] = useState<ChatScope>(() =>
deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
);
const [cards, setCards] = useState<TeachCardRef[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [conversationId, setConversationId] = useState<string | null>(null);
const [summary, setSummary] = useState<TeachSummary | null>(null);
const [summaryBusy, setSummaryBusy] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
const [scopeOpen, setScopeOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [listening, setListening] = useState(false);
const [micSupported, setMicSupported] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const loadedRef = useRef<string | null>(null);
const prefilledRef = useRef(false);
const speechRef = useRef<SpeechInput | null>(null);
const sessions = useMemo(() => conversations.filter((entry) => entry.mode === "teach"), [conversations]);
useEffect(() => {
void ensureChatsHydrated();
setMicSupported(speechInputAvailable());
return () => {
abortRef.current?.abort();
speechRef.current?.stop();
};
}, []);
/* Arriving from a deck's page: scope to it and prefill the topic once the
deck store has hydrated. */
useEffect(() => {
if (!deckParam || prefilledRef.current) return;
const deck = decks.find((entry) => entry.id === deckParam);
if (!deck) return;
prefilledRef.current = true;
setScope({ kind: "decks", deckIds: [deck.id] });
setTopic((current) => current || deck.title);
}, [deckParam, decks]);
/* Open ?c=<id> sessions (from the recent list or a reload). */
const { restore: restoreModel } = chatModel;
const loadConversation = useCallback(
(found: ChatConversation) => {
setConversationId(found.id);
setTopic(found.teach?.topic || found.title);
setDifficulty(found.teach?.difficulty ?? "friendly");
setScope(found.scope);
setCards(found.teach?.cards ?? []);
setMessages(conversationMessages(found));
setSummary(found.teach?.summary ?? null);
setSummaryError(null);
setDraft("");
setError(null);
restoreModel(found.model, found.provider);
loadedRef.current = found.id;
setView(found.teach?.summary ? "summary" : found.messages.length > 0 ? "session" : "start");
},
[restoreModel],
);
useEffect(() => {
if (!conversationParam || loadedRef.current === conversationParam) return;
const found = getConversation(conversationParam);
if (!found || found.mode !== "teach") return; // hydration may still be running
loadConversation(found);
}, [conversationParam, conversations, loadConversation]);
const persist = useCallback(
(
nextMessages: ChatMessage[],
extra: { summary?: TeachSummary | null; cards?: TeachCardRef[]; topic?: string } = {},
) => {
if (!conversationId) return;
const sessionTopic = (extra.topic ?? topic).trim();
const firstUser = nextMessages.find((message) => message.role === "user");
const title = sessionTopic || (firstUser ? titleFromMessage(firstUser.content) : "") || t("teach.untitled");
const nextCards = extra.cards ?? cards;
const nextSummary = extra.summary !== undefined ? extra.summary : summary;
const existing = getConversation(conversationId);
saveConversation({
id: conversationId,
title,
messages: nextMessages,
scope,
model: chatModel.model || undefined,
provider: chatModel.provider,
mode: "teach",
teach: {
difficulty,
topic: title,
scope,
cards: nextCards,
...(nextSummary ? { summary: nextSummary } : {}),
},
createdAt: existing?.createdAt ?? Date.now(),
updatedAt: Date.now(),
});
},
[conversationId, topic, cards, summary, scope, difficulty, chatModel.model, chatModel.provider, t],
);
const cardLines = useMemo(() => teachCardLines(cards, decks), [cards, decks]);
const sessionDecks = useMemo(() => scopedDecks(decks, scope), [decks, scope]);
const voiceLanguage = useMemo(() => sessionDecks.find((deck) => deck.language)?.language, [sessionDecks]);
const scopeText = useMemo(() => {
if (scope.kind === "none") return t("chat.scope.none");
if (scope.kind === "all") return t("chat.scope.all");
return plural(scope.deckIds.length, "chat.scope.decks");
}, [scope, t, plural]);
const userCount = messages.filter((message) => message.role === "user").length;
const scrollToBottom = useCallback(
(smooth = false) => {
const element = scrollRef.current;
if (!element) return;
element.scrollTo({ top: element.scrollHeight, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
},
[reduceMotion],
);
useEffect(() => {
if (view === "session") scrollToBottom(false);
}, [messages, view, scrollToBottom]);
const stopListening = useCallback(() => {
speechRef.current?.stop();
speechRef.current = null;
}, []);
const toggleListening = () => {
if (listening) {
stopListening();
return;
}
const handle = startSpeechInput({
lang: speechLocale(voiceLanguage, getLocale()),
onText: (text) =>
setDraft((current) => (current.trim() ? `${current.replace(/\s+$/, "")} ${text}` : text)),
onEnd: () => setListening(false),
});
if (handle) {
speechRef.current = handle;
setListening(true);
}
};
/* Session ------------------------------------------------------------- */
const start = () => {
const trimmed = topic.trim();
if (!trimmed) return;
const built = buildTeachCards({ decks, scope });
const id = chatMessageId("teach");
setConversationId(id);
loadedRef.current = id;
setCards(built.cards);
setMessages([]);
setSummary(null);
setSummaryError(null);
setDraft("");
setError(null);
setView("session");
window.history.replaceState(null, "", `/teach?c=${id}`);
};
const run = async (history: ChatMessage[]) => {
const lastUser = history[history.length - 1];
const context = buildChatContext({
decks,
scope,
question: lastUser.content,
history: history.slice(0, -1),
useNotes: settings.chatNotes,
});
const assistantId = chatMessageId("s");
const exchange = history.filter((message) => message.role === "user").length;
setMessages([...history, { id: assistantId, role: "assistant", content: "", at: Date.now() }]);
setBusy(true);
const controller = new AbortController();
abortRef.current = controller;
let produced = false;
let finalContent = "";
let failure: { message: string; kind?: string } | null = null;
try {
await streamChat({
model: chatModel.model || undefined,
provider: chatModel.provider,
mode: "teach",
teach: {
topic: topic.trim(),
difficulty,
exchange,
limit: TEACH_MAX_EXCHANGES,
cards: cardLines,
},
messages: history.map((message) => ({ role: message.role, content: message.content })),
context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
signal: controller.signal,
onToken: (text) => {
produced = true;
finalContent += text;
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, content: message.content + text } : message,
),
);
},
onThinking: () => {
/* the student does not show its thinking */
},
onDone: (modelName) => {
setMessages((previous) =>
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
);
},
});
} catch (caught) {
const apiError = caught instanceof ApiError ? caught : new ApiError(String(caught), "unknown");
if (apiError.kind === "aborted") {
// Stopped by the user: keep whatever arrived.
} else {
failure = { message: apiError.message, kind: apiError.kind };
setError(failure);
if (!produced) {
setMessages(history);
setDraft(lastUser.content);
} else {
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, error: failure ?? undefined } : message,
),
);
}
}
} finally {
abortRef.current = null;
setBusy(false);
const assistant: ChatMessage | null = produced
? { id: assistantId, role: "assistant", content: finalContent, at: Date.now(), ...(failure ? { error: failure } : {}) }
: null;
const next = assistant ? [...history, assistant] : history;
persist(next);
/* A reasonable number of exchanges: the student wraps up on its own. */
if (produced && exchange >= TEACH_MAX_EXCHANGES) {
showToast({ message: t("teach.limitReached") });
void runRecap(next);
}
}
};
const send = (text?: string) => {
const content = (text ?? draft).trim();
if (!content || busy || !online || !conversationId) return;
stopListening();
const userMessage: ChatMessage = { id: chatMessageId("u"), role: "user", content, at: Date.now() };
const history = [...messages, userMessage];
setError(null);
setMessages(history);
setDraft("");
void run(history);
};
const retryLast = useCallback(() => {
if (busy) return;
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
if (lastUserIndex < 0) return;
const history = messages.slice(0, lastUserIndex + 1);
setError(null);
setMessages(history);
void run(history);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [busy, messages]);
const editLast = useCallback(() => {
if (busy) return;
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
if (lastUserIndex < 0) return;
setDraft(messages[lastUserIndex].content);
setMessages(messages.slice(0, lastUserIndex));
}, [busy, messages]);
/* Recap --------------------------------------------------------------- */
const runRecap = async (source: ChatMessage[]) => {
const usable = source.filter((message) => message.role === "user" || message.content.trim());
if (usable.length === 0) {
setView("start");
return;
}
setView("summary");
setSummaryError(null);
setSummaryBusy(true);
const context = buildChatContext({
decks,
scope,
question: topic.trim() || "recap",
history: usable,
useNotes: settings.chatNotes,
budgetTokens: 6000,
});
const contextChunks = context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text }));
const base = usable.map((message) => ({ role: message.role, content: message.content }));
let repair: string | null = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
const turns = repair
? [
...base,
{ role: "user" as const, content: TEACH_SUMMARY_USER_INSTRUCTION },
{ role: "assistant" as const, content: repair.slice(0, 8000) },
{ role: "user" as const, content: TEACH_SUMMARY_REPAIR_INSTRUCTION },
]
: [...base, { role: "user" as const, content: TEACH_SUMMARY_USER_INSTRUCTION }];
let raw = "";
try {
await streamChat({
model: chatModel.model || undefined,
provider: chatModel.provider,
mode: "teach_summary",
teach: {
topic: topic.trim(),
difficulty,
exchange: userCount,
limit: TEACH_MAX_EXCHANGES,
cards: cardLines,
},
messages: turns,
context: contextChunks,
onToken: (text) => {
raw += text;
},
onThinking: () => {},
onDone: () => {},
});
} catch (caught) {
const apiError = caught instanceof ApiError ? caught : new ApiError(String(caught), "unknown");
if (apiError.kind !== "aborted") setSummaryError(apiError.message);
setSummaryBusy(false);
return;
}
const parsed = parseTeachSummary(raw, cards);
if (parsed) {
setSummary(parsed);
setSummaryBusy(false);
persist(usable, { summary: parsed });
return;
}
repair = raw;
}
setSummaryError(t("teach.summaryFailed"));
setSummaryBusy(false);
};
const finish = () => {
if (busy) return;
stopListening();
void runRecap(messages);
};
const resetSession = useCallback(() => {
abortRef.current?.abort();
stopListening();
loadedRef.current = null;
setConversationId(null);
setMessages([]);
setCards([]);
setSummary(null);
setSummaryError(null);
setDraft("");
setError(null);
setView("start");
window.history.replaceState(null, "", "/teach");
}, [stopListening]);
const openSession = (found: ChatConversation) => {
abortRef.current?.abort();
loadConversation(found);
window.history.replaceState(null, "", `/teach?c=${found.id}`);
};
const removeSession = (found: ChatConversation) => {
deleteConversation(found.id);
if (found.id === conversationId) resetSession();
showToast({
message: t("teach.deleted"),
actionLabel: t("toast.undo"),
onAction: () => saveConversation(found),
});
};
const modelLabel = chatModel.model
? chatModel.provider !== "ollama"
? `${providerInfo(chatModel.provider)?.label ?? chatModel.provider} · ${prettyModelName(chatModel.model)}`
: prettyModelName(chatModel.model)
: t("chat.modelTitle");
/* Render --------------------------------------------------------------- */
return (
<div className="flex h-full min-h-0 w-full" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
<div className="chat-column relative flex h-full min-h-0 min-w-0 flex-1 flex-col gap-[var(--layout-gutter)] p-[var(--layout-gutter)]">
<header className="glass-panel chat-header z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
<button
type="button"
onClick={() => (view === "start" ? router.push("/") : setView("start"))}
aria-label={t("teach.back")}
title={t("teach.back")}
className="grid size-11 shrink-0 place-items-center rounded-[var(--radius-control)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
>
<ChevronLeftIcon className="size-5" />
</button>
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">
{view === "session" ? topic : view === "summary" ? t("teach.summaryTitle") : t("teach.title")}
</h1>
<p className="truncate text-[11.5px] text-[var(--text-muted)]">
{view === "session"
? `${t("teach.youExplain")} · ${difficulty === "friendly" ? t("teach.friendly") : t("teach.skeptical")} · ${t("teach.exchangeCount", { n: userCount, max: TEACH_MAX_EXCHANGES })}`
: t("teach.subtitle")}
</p>
</div>
{view === "session" ? (
<Button size="sm" className="touch-target shrink-0" onClick={finish} disabled={busy || messages.length === 0} title={t("teach.finishHint")}>
{t("teach.finish")}
</Button>
) : (
<>
<ThemeToggle
plain
className="grid size-11 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)]"
/>
{view === "summary" ? (
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={resetSession}>
{t("teach.newSession")}
</Button>
) : null}
</>
)}
</header>
{view === "start" ? (
<StartView
topic={topic}
setTopic={setTopic}
difficulty={difficulty}
setDifficulty={setDifficulty}
scopeText={scopeText}
scopeEmpty={sessionDecks.length === 0}
scopeOpen={scopeOpen}
modelOpen={modelOpen}
modelLabel={modelLabel}
onOpenScope={() => {
setScopeOpen((open) => !open);
setModelOpen(false);
}}
onOpenModel={() => {
setModelOpen((open) => !open);
setScopeOpen(false);
}}
decks={decks}
folders={folders}
scope={scope}
onScopeChange={setScope}
onCloseScope={() => setScopeOpen(false)}
chatModels={chatModel.models}
activeModel={chatModel.model}
activeProvider={chatModel.provider}
onChooseModel={(model, provider) => chatModel.choose(model, provider)}
onCloseModel={() => setModelOpen(false)}
online={online}
sessions={sessions}
onStart={start}
onOpenSession={openSession}
onRemoveSession={removeSession}
/>
) : null}
{view === "session" ? (
<>
<div className="relative min-h-0 flex-1">
<div ref={scrollRef} className="chat-scroll h-full overflow-y-auto overscroll-contain">
<div
className={cn(
"mx-auto w-full max-w-6xl space-y-4 px-4 py-4 lg:px-10 lg:py-6",
messages.length === 0 && "flex min-h-full flex-col justify-center",
)}
>
{messages.length === 0 ? (
<div className="chat-empty space-y-5 py-2">
<div className="text-center">
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
<TeacherIcon className="size-6" />
</span>
<h2 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("teach.emptyTitle")}</h2>
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-muted)]">
{t("teach.emptyHint")}
</p>
<p className="mx-auto mt-2 max-w-sm text-[12px] text-[var(--text-subtle)]">
{t("teach.studentLabel")}: {scopeText}
</p>
</div>
</div>
) : (
messages.map((message, index) => (
<motion.div
key={message.id}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reduceMotion ? { duration: 0 } : springGentle}
>
<MessageRow
message={message}
streaming={busy && index === messages.length - 1 && message.role === "assistant"}
isLastAssistant={index === messages.length - 1 && message.role === "assistant"}
isLastUser={index === messages.length - 1 && message.role === "user"}
busy={busy}
onRegenerate={retryLast}
onEdit={editLast}
/>
</motion.div>
))
)}
<div className="h-2" aria-hidden />
</div>
</div>
</div>
{error ? (
<div
role="alert"
className="shrink-0 rounded-[var(--radius-control)] border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
>
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{error.message}</p>
{messages.some((message) => message.role === "user") ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button size="sm" variant="secondary" onClick={retryLast}>
<RefreshIcon className="size-4" /> {t("chat.retry")}
</Button>
</div>
) : null}
</div>
) : null}
<div className="chat-composer relative shrink-0">
<Composer
value={draft}
onChange={setDraft}
onSend={() => send()}
onStop={() => abortRef.current?.abort()}
busy={busy}
online={online}
modelLabel={modelLabel}
modelTitle={chatModel.model ? `${chatModel.model} · ${providerInfo(chatModel.provider)?.label ?? chatModel.provider}` : undefined}
modelOpen={modelOpen}
onOpenModel={() => setModelOpen((open) => !open)}
placeholder={t("teach.placeholder")}
onVoice={micSupported ? toggleListening : undefined}
listening={listening}
voiceTitle={listening ? t("teach.voiceStop") : t("teach.voiceStart")}
>
{listening ? (
<p role="status" className="mt-1.5 text-[11.5px] text-rose-600 dark:text-rose-300">
{t("teach.listening")}
</p>
) : null}
</Composer>
<ModelPicker
open={modelOpen}
onClose={() => setModelOpen(false)}
models={chatModel.models}
value={chatModel.model}
valueProvider={chatModel.provider}
onChange={(next, nextProvider) => chatModel.choose(next, nextProvider)}
/>
</div>
</>
) : null}
{view === "summary" ? (
<TeachRecap
topic={topic}
difficulty={difficulty}
cards={cards}
decks={decks}
summary={summary}
busy={summaryBusy}
error={summaryError}
onRetry={() => void runRecap(messages)}
onBackToSession={() => setView("session")}
/>
) : null}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Start view */
/* ------------------------------------------------------------------ */
function StartView({
topic,
setTopic,
difficulty,
setDifficulty,
scopeText,
scopeEmpty,
scopeOpen,
modelOpen,
modelLabel,
onOpenScope,
onOpenModel,
decks,
folders,
scope,
onScopeChange,
onCloseScope,
chatModels,
activeModel,
activeProvider,
onChooseModel,
onCloseModel,
online,
sessions,
onStart,
onOpenSession,
onRemoveSession,
}: {
topic: string;
setTopic: (value: string) => void;
difficulty: TeachDifficulty;
setDifficulty: (value: TeachDifficulty) => void;
scopeText: string;
scopeEmpty: boolean;
scopeOpen: boolean;
modelOpen: boolean;
modelLabel: string;
onOpenScope: () => void;
onOpenModel: () => void;
decks: Deck[];
folders: Folder[];
scope: ChatScope;
onScopeChange: (scope: ChatScope) => void;
onCloseScope: () => void;
chatModels: ChatModelChoice[];
activeModel: string;
activeProvider: string;
onChooseModel: (model: string, provider?: string) => void;
onCloseModel: () => void;
online: boolean;
sessions: ChatConversation[];
onStart: () => void;
onOpenSession: (conversation: ChatConversation) => void;
onRemoveSession: (conversation: ChatConversation) => void;
}) {
const { t } = useI18n();
const choice = (value: TeachDifficulty, title: string, note: string) => (
<button
type="button"
aria-pressed={difficulty === value}
onClick={() => setDifficulty(value)}
className={cn(
"flex w-full items-start gap-3 rounded-[var(--radius-control)] border px-3 py-3 text-left transition sm:flex-1",
difficulty === value
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
: "border-[var(--glass-border)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
)}
>
<span
aria-hidden
className={cn(
"mt-0.5 grid size-4 shrink-0 place-items-center rounded-full border",
difficulty === value ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
)}
>
{difficulty === value ? <span className="size-1.5 rounded-full bg-white" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-semibold text-[var(--text)]">{title}</span>
<span className="mt-0.5 block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
</span>
</button>
);
return (
<div className="chat-scroll relative min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="mx-auto w-full max-w-3xl space-y-5 px-4 py-4 lg:px-0 lg:py-6">
<div className="text-center">
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
<TeacherIcon className="size-6" />
</span>
<h2 className="text-[22px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("teach.title")}</h2>
<p className="mx-auto mt-1.5 max-w-md text-[13.5px] leading-relaxed text-[var(--text-muted)]">{t("teach.intro")}</p>
</div>
<section className="card space-y-4 p-4 sm:p-5">
<div>
<label htmlFor="teach-topic" className="mb-1.5 block text-[13px] font-semibold text-[var(--text)]">
{t("teach.topicLabel")}
</label>
<input
id="teach-topic"
data-teach-topic
value={topic}
onChange={(event) => setTopic(event.target.value)}
placeholder={t("teach.topicPlaceholder")}
maxLength={200}
autoComplete="off"
className="w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13.5px]"
/>
</div>
<div>
<span className="mb-1.5 block text-[13px] font-semibold text-[var(--text)]">{t("teach.difficultyLabel")}</span>
<div className="flex flex-col gap-2 sm:flex-row">
{choice("friendly", t("teach.friendly"), t("teach.friendlyNote"))}
{choice("skeptical", t("teach.skeptical"), t("teach.skepticalNote"))}
</div>
</div>
<div>
<span className="mb-1.5 block text-[13px] font-semibold text-[var(--text)]">{t("teach.studentLabel")}</span>
<div className="relative flex flex-wrap items-center gap-2">
<PickerChip icon={<TeacherIcon className="size-4 shrink-0" />} label={scopeText} open={scopeOpen} onClick={onOpenScope} />
<PickerChip
icon={<ChatIcon className="size-4 shrink-0" />}
label={modelLabel}
title={activeModel || modelLabel}
open={modelOpen}
onClick={onOpenModel}
/>
<ContextPicker
open={scopeOpen}
onClose={onCloseScope}
scope={scope}
onChange={onScopeChange}
decks={decks}
folders={folders}
/>
<ModelPicker
open={modelOpen}
onClose={onCloseModel}
models={chatModels}
value={activeModel}
valueProvider={activeProvider}
onChange={onChooseModel}
/>
</div>
{scopeEmpty ? <p className="mt-2 text-[12px] leading-snug text-amber-700 dark:text-amber-300">{t("teach.noDecksHint")}</p> : null}
</div>
<Button size="lg" className="w-full" disabled={!topic.trim() || !online} onClick={onStart} data-testid="teach-start">
{t("teach.start")}
</Button>
<AiDisclaimer className="pt-1 text-center" />
</section>
<section className="space-y-2">
<h3 className="px-1 text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{t("teach.recent")}</h3>
{sessions.length === 0 ? (
<p className="px-1 text-[12.5px] text-[var(--text-subtle)]">{t("teach.noSessions")}</p>
) : (
<ul className="space-y-1.5">
{sessions.slice(0, 8).map((session) => (
<li key={session.id}>
<div className="flex items-center gap-2 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 py-2.5">
<button type="button" onClick={() => onOpenSession(session)} className="min-w-0 flex-1 text-left">
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">
{session.title || t("teach.untitled")}
</span>
<span className="mt-0.5 block truncate text-[11.5px] text-[var(--text-subtle)]">
{formatDate(session.updatedAt)} ·{" "}
{session.teach?.summary ? t("teach.recap") : t("teach.inProgress")} ·{" "}
{session.teach?.difficulty === "skeptical" ? t("teach.skeptical") : t("teach.friendly")}
</span>
</button>
<button
type="button"
onClick={() => onRemoveSession(session)}
aria-label={t("teach.deleteSession")}
title={t("teach.deleteSession")}
className="grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
>
<XIcon className="size-4" />
</button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
}