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.
This commit is contained in:
amosblomqvist
2026-10-06 03:47:05 +03:00
parent a2db80b72d
commit 573d89ae66
12 changed files with 246 additions and 36 deletions
+29 -9
View File
@@ -13,17 +13,26 @@ import { Hint } from "@/components/ui/hint";
* 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. (The plan-verdict accept branch died
* 2026-09-26 with the verdict itself.) Pure so the static-render harness
* 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 }): "stop" | "send" | "none" {
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";
}
@@ -38,6 +47,8 @@ export function Composer({
inputRef,
onEscape,
placeholder = "Ask, answer, or steer the lesson…",
nextProblem = false,
onNext,
}: {
busy: boolean;
value: string;
@@ -56,16 +67,27 @@ export function Composer({
* 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();
const action = enterAction({ busy, canSend });
if (action === "stop") onStop();
else if (action === "send") onSend();
act();
}}
>
<textarea
@@ -88,9 +110,7 @@ export function Composer({
// across every input in the app (thinking field, answers).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
e.preventDefault();
const action = enterAction({ busy, canSend });
if (action === "stop") onStop();
else if (action === "send") onSend();
act();
}
}}
placeholder={placeholder}