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:
+73
-15
@@ -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;
|
||||
|
||||
+29
-9
@@ -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}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
needsContinuation,
|
||||
newAnswerResult,
|
||||
newNextMessage,
|
||||
newProblemDismissedMarker,
|
||||
newThinkingMessage,
|
||||
newUserMessage,
|
||||
@@ -532,6 +533,10 @@ export class SessionModel {
|
||||
// optimistically — the card renders from its details the
|
||||
// moment the button fires.
|
||||
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.text), id: localId() });
|
||||
} else if (input.type === "next") {
|
||||
// The move-on signal (owner round 16): the NEXT PROBLEM line
|
||||
// renders the instant the chip/Enter fires.
|
||||
optimistic.push({ ...newNextMessage(), id: localId() });
|
||||
} else if (input.type === "answer") {
|
||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
||||
|
||||
Reference in New Issue
Block a user