Files
learnharness/components/composer.tsx
T
amosblomqvist 573d89ae66 practice: the between-problems pause — conclude ends the turn, the learner's go-ahead paces the stream (round 16)
Owner ruling (2026-10-06): every concluded problem opens a pause. The
agent may speak its closing words about the learner's last thinking
after the conclude result, then ENDS ITS TURN — a system-prompt law
deliberately, per owner correction, NOT tool-level terminate. The next
problem is never posed until the learner explicitly moves on; the pause
is the session's teaching space (repairs, clarifications, follow-ups —
still surgical, never re-teaching).

The move-on signal is a first-class input: { type: "next" } appends a
practice-next marker (content = single-sourced NEXT_PROBLEM_TEXT — the
model's unambiguous go-ahead: pose per the selection rules or wrap if
the arc is complete). Honest 400s: practice sessions only, nothing
active (mid-problem input rides the dock), riding after any pending
dismissal synthesis exactly like text.

The fast path (owner approved: composer-band chip + Enter-on-empty):
- Composer.enterAction gains "next" — Enter on an EMPTY composer in
  the pause fires the signal; typing anything makes Enter a send again;
  busy (steer/stop) outranks the pause.
- The Next chip hovers at the composer band's top-right (pick-pill
  chrome register) whenever the pause is live and a problem has ever
  been posed; the placeholder becomes the hint ("Ask about this
  problem — or press Enter for the next one…", exact);
- the transcript renders the signal as a compact centered NEXT PROBLEM
  line (mono-caps chrome); session-core appends it optimistically.

CONCLUDE_ACK_TEXT rewritten to carry the law; doctrine §VIII gains the
pause law (owner-sanctioned edit #3); 00-base's conclude/dismissal
bullets rewritten + the between-problems section added.
2026-10-06 03:47:05 +03:00

144 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint";
/**
* 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<HTMLTextAreaElement | null>;
/** 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 act = () => {
const action = enterAction({ busy, canSend, nextProblem });
if (action === "stop") onStop();
else if (action === "send") onSend();
else if (action === "next") onNext?.();
};
return (
<form
data-testid="composer"
className="flex items-end gap-1.5 p-1.5"
onSubmit={(e) => {
e.preventDefault();
act();
}}
>
<textarea
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
// 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={placeholder}
autoFocus={autoFocus}
rows={1}
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). */}
<button
type="submit"
aria-label={busy && !canSend ? "Stop" : "Send"}
disabled={!busy && !canSend}
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
busy && !canSend
? "bg-secondary text-foreground hover:bg-accent"
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
)}
>
{busy && !canSend ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
</button>
</form>
);
}