- {/* 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 3fb4558..c5c7db4 100644
--- a/components/composer.tsx
+++ b/components/composer.tsx
@@ -1,6 +1,6 @@
"use client";
-import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
+import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint";
@@ -124,20 +124,39 @@ export function Composer({
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). */}
-
+ (owner, subtlety round 2026-09-25).
+
+ THE NEXT MORPH (owner round 16 refinement, 2026-10-06): an
+ empty composer in the practice pause morphs the button into
+ a wide green NEXT PROBLEM — the affordance IS the button
+ (click and Enter share one lane), and typing anything
+ returns the regular send. The green is the app's correct-
+ answer register (answered-card's green-500/600). */}
+ {nextProblem && !busy && !canSend ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/src/core/input-events.ts b/src/core/input-events.ts
index 35c25e7..42d6985 100644
--- a/src/core/input-events.ts
+++ b/src/core/input-events.ts
@@ -194,7 +194,10 @@ export function parseChatInput(input: unknown): ChatInput {
* else 400s honestly instead of silently interrupting a question or
* double-answering.
*/
-export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput): LhMessage[] {
+export function inputAppends(
+ record: { messages: LhMessage[]; kind?: string },
+ input: ChatInput,
+): LhMessage[] {
switch (input.type) {
case "text":
return [...dismissalConclusion(record), newUserMessage(input.text)];
@@ -219,7 +222,7 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
// between-problems affordance — practice sessions only, nothing
// active (mid-problem input rides the dock), and the client gates
// make violations unreachable in practice anyway.
- if ((record as { kind?: string }).kind !== "practice") {
+ if (record.kind !== "practice") {
throw new ChatInputError("next input is a practice-session affordance");
}
if (activeProblemOf(record.messages)) {
diff --git a/tests/render-composer.test.tsx b/tests/render-composer.test.tsx
index a42a04d..23b3714 100644
--- a/tests/render-composer.test.tsx
+++ b/tests/render-composer.test.tsx
@@ -13,7 +13,9 @@ import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Composer, enterAction } from "../components/composer.tsx";
-function render(opts: { busy?: boolean; canSend?: boolean; placeholder?: string } = {}) {
+function render(
+ opts: { busy?: boolean; canSend?: boolean; placeholder?: string; nextProblem?: boolean } = {},
+) {
return renderToStaticMarkup(
{}}
canSend={opts.canSend ?? true}
{...(opts.placeholder ? { placeholder: opts.placeholder } : {})}
+ {...(opts.nextProblem ? { nextProblem: true, onNext: () => {} } : {})}
/>,
);
}
@@ -58,6 +61,26 @@ describe("Composer", () => {
const html = render({ canSend: false });
assert.ok(/aria-label="Send"[^>]*disabled|disabled[^>]*aria-label="Send"/.test(html), "send disabled");
});
+
+ it("THE NEXT MORPH (owner round 16 refinement): empty composer in the pause → wide green Next problem", () => {
+ const html = render({ canSend: false, nextProblem: true });
+ assert.ok(html.includes('data-testid="next-problem-button"'), "the morphed button");
+ assert.match(html, /Next problem/, "the label");
+ assert.ok(html.includes("bg-green-600"), "green — the correct-answer register");
+ assert.ok(!html.includes('aria-label="Send"'), "the square send is gone in the pause");
+ });
+
+ it("typing anything returns the regular send button (canSend outranks the morph)", () => {
+ const html = render({ canSend: true, nextProblem: true });
+ assert.ok(html.includes('aria-label="Send"'), "regular send while there is text");
+ assert.ok(!html.includes('data-testid="next-problem-button"'), "no next morph with text typed");
+ });
+
+ it("busy outranks the morph — stop stays reachable", () => {
+ const html = render({ busy: true, canSend: false, nextProblem: true });
+ assert.ok(html.includes('aria-label="Stop"'), "stop wins the empty lane mid-turn");
+ assert.ok(!html.includes('data-testid="next-problem-button"'), "no next morph mid-turn");
+ });
});
// The Enter decision (composer steering, 2026-10-03): while a turn runs,