steering demoable: the Enter branch keys on BUSY (own-stream clears turnInFlight — Enter was swallowed during our own turn), and the button follows the text (owner live report 2026-10-03)
Root cause: doSteerOrSend branched on turnInFlight, but the local dedup (chat.tsx ~370) clears that flag the moment the own stream takes over — during our own turn busy was true and turnInFlight false, so Enter fell to the plain-send path whose busy guard returned silently. Branch on busy: own stream AND foreign/wake turns both have a live agent registered by the time the send's POST resolved, so the steer lands (a raced settle 409s and falls back to the plain send). The button now follows the text: busy + typed text = Send (click steers — steering must be clickable, not Enter-only); busy + empty = Stop. ESC keeps its confirm-popup abort in every state, so stop is never unreachable.
This commit is contained in:
+9
-6
@@ -920,17 +920,20 @@ export function Chat({
|
|||||||
void send({ type: "text", text: t });
|
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
|
* 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,
|
* stream lands the card when pi drains it. The branch is BUSY, not
|
||||||
* submitted microtask window, a wake turn between our renders) takes the
|
* turnInFlight: the own-stream case CLEARS turnInFlight (the local
|
||||||
* plain send — a steer POST with no live turn would 409 and fall back
|
* dedup, chat.tsx ~370), so branching there swallowed Enter entirely
|
||||||
* to exactly that anyway, so we only branch on the state we trust. */
|
* 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 = () => {
|
const doSteerOrSend = () => {
|
||||||
if (pending) return;
|
if (pending) return;
|
||||||
const t = textRef.current.trim();
|
const t = textRef.current.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
if (turnInFlight) {
|
if (busy) {
|
||||||
applyText("");
|
applyText("");
|
||||||
void steer(t);
|
void steer(t);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+10
-6
@@ -98,21 +98,25 @@ export function Composer({
|
|||||||
rows={1}
|
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"
|
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
|
{/* The button follows the text (owner report 2026-10-03): while
|
||||||
affordance and Enter is obvious (owner, subtlety
|
a turn runs WITH text typed it stays the SEND affordance
|
||||||
round 2026-09-25). */}
|
(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). */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label={busy ? "Stop" : "Send"}
|
aria-label={busy && !canSend ? "Stop" : "Send"}
|
||||||
disabled={!busy && !canSend}
|
disabled={!busy && !canSend}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||||||
busy
|
busy && !canSend
|
||||||
? "bg-secondary text-foreground hover:bg-accent"
|
? "bg-secondary text-foreground hover:bg-accent"
|
||||||
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
{busy && !canSend ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ describe("Composer", () => {
|
|||||||
assert.ok(!html.includes("Ask, answer"), "the session placeholder must not ride along");
|
assert.ok(!html.includes("Ask, answer"), "the session placeholder must not ride along");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("busy: send button becomes stop", () => {
|
it("busy with text: the button STAYS Send (click = steer); busy and empty: it becomes Stop", () => {
|
||||||
const html = render({ busy: true });
|
const steering = render({ busy: true, canSend: true });
|
||||||
assert.ok(html.includes('aria-label="Stop"'), "stop replaces send while streaming");
|
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)", () => {
|
it("send button radius is 10px = 25% of the 40px button (concentric with the shell)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user