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:
amosblomqvist
2026-10-06 03:55:14 +03:00
parent 573d89ae66
commit d1f085550b
4 changed files with 83 additions and 55 deletions
+20 -37
View File
@@ -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
View File
@@ -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>
);
}