practice: the next affordance IS the send button — wide green morph in the pause (round 16 refinement)
Owner correction (2026-10-06): the floating chip clipped inside the
composer band. Replaced with the button morph: an EMPTY composer in
the between-problems pause renders a wide green 'Next problem' button
(bg-green-600 — the answered-card correct register, mono-caps label,
arrow glyph); typing anything returns the regular white send; busy
(steer/stop) outranks the morph. Click and Enter share the one
act() lane, so the button also teaches the Enter-on-empty behavior.
The wrapper div + chip are gone from chat.tsx; inputAppends widens
its record param to { messages; kind? } (the practice gate reads it
without a cast).
This commit is contained in:
+20
-37
@@ -1541,43 +1541,26 @@ export function Chat({
|
||||
)
|
||||
}
|
||||
composer={
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
onSubmitAnswer={(output) => {
|
||||
if (!pending) return;
|
||||
|
||||
+34
-15
@@ -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). */}
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={busy && !canSend ? "Stop" : "Send"}
|
||||
disabled={!busy && !canSend}
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||||
busy && !canSend
|
||||
? "bg-secondary text-foreground hover:bg-accent"
|
||||
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{busy && !canSend ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||
</button>
|
||||
(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 ? (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="next-problem-button"
|
||||
aria-label="Next problem"
|
||||
className="flex h-10 shrink-0 items-center justify-center gap-1.5 rounded-[10px] bg-green-600 px-4 font-ui text-xs font-medium uppercase tracking-wide text-white transition-colors hover:bg-green-500"
|
||||
>
|
||||
Next problem
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={busy && !canSend ? "Stop" : "Send"}
|
||||
disabled={!busy && !canSend}
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||||
busy && !canSend
|
||||
? "bg-secondary text-foreground hover:bg-accent"
|
||||
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{busy && !canSend ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
<Composer
|
||||
busy={opts.busy ?? false}
|
||||
@@ -23,6 +25,7 @@ function render(opts: { busy?: boolean; canSend?: boolean; placeholder?: string
|
||||
onStop={() => {}}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user