diff --git a/components/chat.tsx b/components/chat.tsx index b284b84..a66bd8b 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -920,17 +920,20 @@ export function Chat({ void send({ type: "text", text: t }); }; - /* STEER VS SEND (owner arc 2026-10-03): Enter while a turn runs steers + /* STEER VS SEND (owner arc 2026-10-03): a send while a turn runs steers * it — the quiet queue + the live agent's steering rail; the turn's own - * stream lands the card when pi drains it. Any other state (idle, - * submitted microtask window, a wake turn between our renders) takes the - * plain send — a steer POST with no live turn would 409 and fall back - * to exactly that anyway, so we only branch on the state we trust. */ + * stream lands the card when pi drains it. The branch is BUSY, not + * turnInFlight: the own-stream case CLEARS turnInFlight (the local + * dedup, chat.tsx ~370), so branching there swallowed Enter entirely + * during our own turn (owner live report, fixed same day). busy covers + * own stream AND foreign/wake turns — both have a live agent by the + * time the send's POST resolved, so the steer lands. Idle takes the + * plain send; a raced settle 409s and falls back to exactly that. */ const doSteerOrSend = () => { if (pending) return; const t = textRef.current.trim(); if (!t) return; - if (turnInFlight) { + if (busy) { applyText(""); void steer(t); return; diff --git a/components/composer.tsx b/components/composer.tsx index 861fc92..ad4cb01 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -98,21 +98,25 @@ export function Composer({ rows={1} className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" /> - {/* No tooltip on the send square — the ↵ glyph is the - affordance and Enter is obvious (owner, subtlety - round 2026-09-25). */} + {/* The button follows the text (owner report 2026-10-03): while + a turn runs WITH text typed it stays the SEND affordance + (click = steer — steering must be clickable, not Enter-only); + 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). */} ); diff --git a/tests/render-composer.test.tsx b/tests/render-composer.test.tsx index 900079c..8ed36db 100644 --- a/tests/render-composer.test.tsx +++ b/tests/render-composer.test.tsx @@ -40,9 +40,12 @@ describe("Composer", () => { assert.ok(!html.includes("Ask, answer"), "the session placeholder must not ride along"); }); - it("busy: send button becomes stop", () => { - const html = render({ busy: true }); - assert.ok(html.includes('aria-label="Stop"'), "stop replaces send while streaming"); + it("busy with text: the button STAYS Send (click = steer); busy and empty: it becomes Stop", () => { + const steering = render({ busy: true, canSend: true }); + assert.ok(steering.includes('aria-label="Send"'), "the steer affordance is clickable mid-turn"); + assert.ok(!steering.includes('aria-label="Stop"'), "no morph while there is text to steer with"); + const idleStop = render({ busy: true, canSend: false }); + assert.ok(idleStop.includes('aria-label="Stop"'), "empty composer: stop replaces send while streaming"); }); it("send button radius is 10px = 25% of the 40px button (concentric with the shell)", () => {