"use client"; import { useI18n } from "@/components/i18n"; import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Hint } from "@/components/ui/hint"; import { PathInput } from "@/components/path-input"; /** * The chat composer — content row ONLY (the persistent rounded shell lives in * the chat view's overlay, so the shell can later morph chat → question * without remounting). * * Corner-radius math (concentric with the shell): shell rounded-2xl (16px) − * row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button. */ /** What one Enter press does, in precedence order: stop a busy stream, * otherwise send the message, otherwise — practice sessions only — fire * the next-problem signal while the between-problems pause is live * (owner round 16: Enter on an EMPTY composer is the go-ahead; typing * anything makes Enter a send again). Pure so the static-render harness * can pin the decision (key events can't be simulated in * renderToStaticMarkup). */ export function enterAction(state: { busy: boolean; canSend: boolean; /** The between-problems pause is live: practice session, nothing * active, no turn/compaction in flight (the caller's fold). */ nextProblem?: boolean; }): "stop" | "send" | "next" | "none" { // Composer steering (owner arc 2026-10-03): while a turn runs, typed // text makes Enter a STEER (the queue-and-redirect send); Enter with // an empty composer stays the stop shortcut. The button itself keeps // the square — Stop stays one click away at all times. if (state.busy) return state.canSend ? "send" : "stop"; if (state.canSend) return "send"; if (state.nextProblem) return "next"; return "none"; } export function Composer({ busy, value, onChange, onSend, onStop, canSend, autoFocus, inputRef, onEscape, placeholder = "Ask, answer, or steer the lesson…", nextProblem = false, onNext, }: { busy: boolean; value: string; onChange: (v: string) => void; onSend: () => void; onStop: () => void; canSend: boolean; autoFocus?: boolean; /** The page's handle to the textarea — the dictation-end focus (owner, * 2026-10-01): a run ending hands the cursor to the bare composer. */ inputRef?: React.RefObject; /** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op * when absent — Esc otherwise means nothing in the composer. */ onEscape?: () => void; /** The textarea's placeholder (owner refinement round, 2026-09-24): * active sessions read "Ask, answer, or steer the lesson…"; the fresh * new-session page asks the graph view's opening question instead. */ placeholder?: string; /** The practice pause is live (owner round 16): Enter on the empty * composer fires the next-problem signal instead of doing nothing — * the keyboard-first move-on. */ nextProblem?: boolean; /** The signal's dispatcher (send({type:"next"})) — the chip and the * empty-Enter share it. */ onNext?: () => void; }) { const { t } = useI18n(); const act = () => { const action = enterAction({ busy, canSend, nextProblem }); if (action === "stop") onStop(); else if (action === "send") onSend(); else if (action === "next") onNext?.(); }; return (
{ e.preventDefault(); act(); }} > { // Esc ends dictation before anything else Esc might someday mean // here (the dictate decisions: click-again or Esc to stop). // stopPropagation claims the Esc (keyboard wave A): the // window-level chain must not also abort a turn under us. if (e.key === "Escape" && onEscape) { e.preventDefault(); e.stopPropagation(); onEscape(); return; } // Chat convention: Enter sends, Shift+Enter is a newline. // ⌘/Ctrl+Enter also sends — the universal submit shortcut // across every input in the app (thinking field, answers). if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) { e.preventDefault(); act(); } }} placeholder={t(placeholder)} autoFocus={autoFocus} className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" /> {/* The button follows the text (owner report 2026-10-03): while a turn runs WITH text typed it stays the SEND affordance (click = steer — steering must be clickable, not Enter-only); only an empty composer morphs it to Stop. ESC keeps its confirm-popup abort in every state, so stop is never unreachable. No tooltip — the ↵ glyph is the affordance (owner, subtlety round 2026-09-25). THE NEXT MORPH (owner round 16 refinement, 2026-10-06): an empty composer in the practice pause morphs the button into a wide green NEXT PROBLEM — the affordance IS the button (click and Enter share one lane), and typing anything returns the regular send. The green is the app's correct- answer register (answered-card's green-500/600). */} {nextProblem && !busy && !canSend ? ( ) : ( )} ); }