From 573d89ae661e26aa7dc35305e05cf7dc525a1448 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 6 Oct 2026 03:47:05 +0300 Subject: [PATCH] =?UTF-8?q?practice:=20the=20between-problems=20pause=20?= =?UTF-8?q?=E2=80=94=20conclude=20ends=20the=20turn,=20the=20learner's=20g?= =?UTF-8?q?o-ahead=20paces=20the=20stream=20(round=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/chat.tsx | 88 +++++++++++++++++++---- components/composer.tsx | 38 +++++++--- components/session-core.ts | 5 ++ src/agent/prompts/practice/00-base.md | 6 +- src/agent/prompts/practice/10-doctrine.md | 11 +-- src/core/contracts/chat.ts | 15 +++- src/core/input-events.ts | 23 +++++- src/core/native-messages.ts | 20 ++++++ src/core/view.ts | 12 ++++ tests/practice-flow.test.ts | 4 +- tests/practice-record.test.ts | 45 ++++++++++++ tests/render-composer.test.tsx | 15 +++- 12 files changed, 246 insertions(+), 36 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 3530ab7..d71189a 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -33,7 +33,7 @@ import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia, planExists } from "@/components/plan-window"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog"; -import { DumbbellIcon, WaypointsIcon } from "lucide-react"; +import { ArrowRightIcon, DumbbellIcon, WaypointsIcon } from "lucide-react"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { PracticeDock } from "@/components/practice-dock"; @@ -634,6 +634,28 @@ export function Chat({ const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting; + /* THE PRACTICE PAUSE (owner round 16): between problems — practice + * kind, nothing active, nothing in flight, and at least one problem + * has ever been posed (the fresh session's opening turn is owed, not + * the move-on). Enter-on-empty and the Next chip both key on it. */ + const nextReady = + kind === "practice" && + !pending && + !activeProblem && + !compacting && + !busy && + nativeMessages.some( + (m) => (m as { role?: string }).role === "assistant" && + Array.isArray((m as { content?: unknown[] }).content) && + (m as { content: Array<{ type?: string; name?: string }> }).content.some( + (b) => b.type === "toolCall" && b.name === "problem", + ), + ); + const sendNext = useCallback(() => { + if (!nextReady) return; + void send({ type: "next" }); + }, [nextReady, send]); + const syncFromServer = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; @@ -1273,6 +1295,19 @@ export function Chat({ ); case "data-practice-thinking": return ; + case "data-practice-next": + // The learner's move-on signal (owner round 16): a compact + // centered mono-caps line — chrome, the dismissal register. + return ( +
+ + Next problem +
+ ); default: return null; } @@ -1506,20 +1541,43 @@ export function Chat({ ) } composer={ - +
+ {/* THE NEXT CHIP (owner round 16): the practice pause's + mouse path — hovering at the composer band's top-right, + the pick-pill chrome register. One click = the move-on + signal; gone the moment a problem is active again. */} + {nextReady && ( + + )} + +
} onSubmitAnswer={(output) => { if (!pending) return; diff --git a/components/composer.tsx b/components/composer.tsx index ad4cb01..3fb4558 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -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 (
{ e.preventDefault(); - const action = enterAction({ busy, canSend }); - if (action === "stop") onStop(); - else if (action === "send") onSend(); + act(); }} >