freezmo/components/chat/Composer.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

270 lines
9.4 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { ChatIcon, LayersIcon, MicIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
import { Spinner } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import type { ChatAttachment } from "@/lib/chat";
import { cn } from "@/lib/utils";
export type PendingAttachment = { attachment: ChatAttachment; text: string };
/**
* The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
* context + model chips. Enter sends on desktop, newline on phones; the
* keyboard inset is handled by the screen around it.
*
* Teach it back reuses it with the scope chip and the attach button off and
* the mic on (when the browser can dictate), so the two screens share one
* input, one disclaimer and one chip language.
*/
export function Composer({
value,
onChange,
onSend,
onStop,
busy,
online,
scopeLabel,
modelLabel,
modelTitle,
scopeOpen,
modelOpen,
onOpenScope,
onOpenModel,
onAttachFile,
attachBusy,
attachments,
onRemoveAttachment,
placeholder,
onVoice,
listening,
voiceTitle,
children,
}: {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
busy: boolean;
online: boolean;
/** Shown on the context chip; the chip is hidden without it. */
scopeLabel?: string;
modelLabel: string;
/** The raw model name, shown as the chip's tooltip. */
modelTitle?: string;
scopeOpen?: boolean;
modelOpen: boolean;
onOpenScope?: () => void;
onOpenModel: () => void;
/** Omitted → no attach button (Teach it back takes no attachments). */
onAttachFile?: (file: File) => void;
attachBusy?: boolean;
attachments?: PendingAttachment[];
onRemoveAttachment?: (index: number) => void;
placeholder?: string;
/** Dictation (Teach it back): shown only when the browser supports it. */
onVoice?: () => void;
listening?: boolean;
voiceTitle?: string;
/** Extra row above the chips (e.g. the teach mode's hint). */
children?: React.ReactNode;
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
const textarea = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const pending = attachments ?? [];
useEffect(() => {
const element = textarea.current;
if (!element) return;
element.style.height = "auto";
element.style.height = `${Math.min(element.scrollHeight, 180)}px`;
}, [value]);
const canSend = online && !busy && value.trim().length > 0;
return (
<div className="chat-composer-band glass-panel px-4 pb-safe pt-2 lg:px-10">
{pending.length > 0 ? (
<ul className="mb-2 flex flex-wrap gap-1.5">
{pending.map((entry, index) => (
<li
key={`${entry.attachment.name}-${index}`}
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
>
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
<button
type="button"
aria-label={t("chat.attachRemove")}
title={t("chat.attachRemove")}
onClick={() => onRemoveAttachment?.(index)}
className="grid size-6 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-3.5" />
</button>
</li>
))}
</ul>
) : null}
<div className="flex items-end gap-2">
{onAttachFile ? (
<>
<button
type="button"
aria-label={t("chat.attach")}
title={t("chat.attach")}
disabled={attachBusy}
onClick={() => fileInput.current?.click()}
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
>
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
</button>
<input
ref={fileInput}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onAttachFile(file);
event.target.value = "";
}}
/>
</>
) : null}
{onVoice ? (
<button
type="button"
data-voice-button
aria-pressed={listening ?? false}
aria-label={listening ? t("chat.voiceStop") : t("chat.voiceStart")}
title={voiceTitle ?? (listening ? t("chat.voiceStop") : t("chat.voiceStart"))}
onClick={onVoice}
className={cn(
"touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border transition",
listening
? "border-rose-400/70 bg-rose-500/15 text-rose-600 dark:text-rose-300"
: "border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
)}
>
<MicIcon className={cn("size-5", listening && "animate-pulse motion-reduce:animate-none")} />
</button>
) : null}
<textarea
ref={textarea}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || isPhone) return;
if (event.nativeEvent.isComposing) return;
event.preventDefault();
if (canSend) onSend();
}}
rows={1}
placeholder={placeholder ?? t("chat.placeholder")}
aria-label={placeholder ?? t("chat.placeholder")}
className="no-scrollbar max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
/>
{busy ? (
<button
type="button"
onClick={onStop}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<StopIcon className="size-5" />
</button>
) : (
<button
type="button"
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.send")}
title={t("chat.send")}
className="touch-target grad-ice grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] text-[#06111f] transition disabled:opacity-45"
>
<SendIcon className="size-5" />
</button>
)}
</div>
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
{scopeLabel && onOpenScope ? (
<PickerChip
icon={<LayersIcon className="size-4 shrink-0" />}
label={scopeLabel}
open={scopeOpen ?? false}
onClick={onOpenScope}
/>
) : null}
<PickerChip
icon={<ChatIcon className="size-4 shrink-0" />}
label={modelLabel}
title={modelTitle ?? modelLabel}
open={modelOpen}
onClick={onOpenModel}
/>
{!online ? (
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
{t("chat.offlineNote")}
</span>
) : null}
</div>
{children}
<AiDisclaimer plain className="mt-1.5" />
</div>
);
}
/**
* The chip that opens a picker (context, model): the shared chip language,
* read as pressed while its own popover is open. Exported so screens that are
* not the full chat composer (Teach it back's start screen) offer the exact
* same trigger.
*/
export function PickerChip({
icon,
label,
title,
open,
onClick,
}: {
icon: React.ReactNode;
label: string;
title?: string;
open: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
data-picker-trigger
aria-haspopup="dialog"
aria-expanded={open}
onClick={onClick}
title={title ?? label}
className={cn(
"chip min-w-0 px-3 py-2 text-[12.5px] font-medium",
open && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
)}
>
{icon}
<span className="max-w-[190px] truncate">{label}</span>
</button>
);
}