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
+73 -15
View File
@@ -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 <ThinkingCard key={pi} data={part.data as PracticeThinkingData} />;
case "data-practice-next":
// The learner's move-on signal (owner round 16): a compact
// centered mono-caps line — chrome, the dismissal register.
return (
<div
key={pi}
data-testid="practice-next-line"
className="my-4 flex items-center justify-center gap-1.5 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
<ArrowRightIcon className="size-3" />
Next problem
</div>
);
default:
return null;
}
@@ -1506,20 +1541,43 @@ export function Chat({
)
}
composer={
<Composer
// Stop aborts ANY in-flight turn (stream-reattach wave): the
// square renders while a reattached/wake turn runs too — the
// abort channel kills it server-side; stop() alone would
// only detach our view.
busy={busy}
value={text}
onChange={applyText}
inputRef={composerInputRef}
onSend={onSend}
onStop={abortInFlightTurn}
canSend={canSend}
onEscape={dictation.active ? dictation.cancel : undefined}
/>
<div className="relative">
{/* 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 && (
<button
type="button"
data-testid="next-problem-chip"
onClick={sendNext}
className="absolute -top-3.5 right-3 z-10 flex items-center gap-1 rounded-md border border-border bg-card px-2 py-0.5 font-ui text-[10px] font-medium uppercase tracking-wider text-foreground shadow-sm transition-colors hover:border-muted-foreground/50 hover:text-foreground"
>
Next problem
<ArrowRightIcon className="size-3" />
</button>
)}
<Composer
// Stop aborts ANY in-flight turn (stream-reattach wave): the
// square renders while a reattached/wake turn runs too — the
// abort channel kills it server-side; stop() alone would
// only detach our view.
busy={busy}
value={text}
onChange={applyText}
inputRef={composerInputRef}
onSend={onSend}
onStop={abortInFlightTurn}
canSend={canSend}
onEscape={dictation.active ? dictation.cancel : undefined}
// The pause affordance (owner round 16): the placeholder
// IS the hint — empty state, exactly where Enter-on-empty
// applies.
nextProblem={nextReady}
onNext={sendNext}
placeholder={nextReady ? "Ask about this problem — or press Enter for the next one…" : undefined}
/>
</div>
}
onSubmitAnswer={(output) => {
if (!pending) return;