feat(chat): always-Send composer, relocate Interrupt to the header (ADR-0029) #63

Merged
dominik.polakovics merged 1 commit from afk/61 into main 2026-07-09 01:13:12 +02:00

Replaces the chat composer's working-state Send→Interrupt morph (ADR-0022) with an always-available Send, and moves Interrupt to a dedicated live-gated control next to the header Stop. Resolves the operator lockout where a false `working` state (stale transcript tail — #38's confirmed root cause) hid Send and left only a no-op Interrupt.

Closes #61

What changed

  • Always-Send (`RunChat.tsx`): in idle/needs_input/working the composer button is always the `send` paper-plane — enabled iff non-empty and not in-flight, always POSTs `/reply` immediately. A genuinely mid-turn reply is queued by Claude Code's own TUI (backend `Reply` unchanged); no queue UI, no optimistic echo. The `working()` morph, its pulse cue, and the "tap to interrupt" hint are gone.
  • Cmd/Ctrl+Enter sends in every unlocked state (bare Enter stays a newline).
  • Interrupt relocated, gated on `live` not `working`: inline accent icon-button immediately left of Stop (≥640px) and a `•••` menu item above Stop (<640px). One-tap, no confirm; accessible name "Interrupt", title "Interrupt the current turn (keeps the session)".
  • New `pause` glyph (vendored Lucide, `Icon.tsx`) so Interrupt reads distinct from the danger two-step `square` Stop now that they sit adjacent; the two locked-state escape hatches (dialog-pending, degraded question) adopt it too.
  • `canClear` un-gated from `working` — New conversation follows `composerLocked()` alone, killing the secondary lockout.
  • Dialog-pending / question locks unchanged (authoritative spool + `ErrDialogPending`).
  • ADR-0029 records the reversal; ADR-0022 marked superseded (its ADR-0016 supersessions — no queued hint, one-tap interrupt — carry forward). `compat.md` §5/§6/§8 updated. Note: the issue asked for "ADR-0028", but 0028 was taken by the answered-dialog-cards ADR that landed first, so this record is 0029.
  • Known accepted gap: the state badge still shows the possibly-false `working` — it no longer traps anyone; the root fix (real liveness signal) is #62.

Tests

`RunChat.test.tsx` rewritten for the new contract (79 → 83 tests): Send present/enabled/sending while working, ⌘-Enter while working, live-gated header + menu Interrupt (pause vs square, one tap POSTs `/interrupt`, absent on ended runs), menu item ordered above Stop and closing on tap, New conversation available while working, escape-hatch tests re-scoped so they stop accidentally clicking the new header control.

Verification (all green locally)

  • `web/`: `npm run lint`, `npm run format:check`, `npm test` (497/497, 35 files), `npm run build`
  • Go: `CGO_ENABLED=0 go build -tags ui ./cmd/...` + `go test -tags ui ./...` against the embedded dist, plus the untagged build/test
  • Pure frontend + docs diff — no backend / API / SSE / schema / provider change

🤖 Generated with Claude Code

https://claude.ai/code/session_016pJUBAmtjzJfHyZNy3Fuyc

Replaces the chat composer's working-state Send→Interrupt morph (ADR-0022) with an always-available Send, and moves Interrupt to a dedicated live-gated control next to the header Stop. Resolves the operator lockout where a false \`working\` state (stale transcript tail — #38's confirmed root cause) hid Send and left only a no-op Interrupt. Closes #61 ## What changed - **Always-Send** (\`RunChat.tsx\`): in idle/needs_input/working the composer button is always the \`send\` paper-plane — enabled iff non-empty and not in-flight, always POSTs \`/reply\` immediately. A genuinely mid-turn reply is queued by Claude Code's own TUI (backend \`Reply\` unchanged); no queue UI, no optimistic echo. The \`working()\` morph, its pulse cue, and the "tap to interrupt" hint are gone. - **Cmd/Ctrl+Enter** sends in every unlocked state (bare Enter stays a newline). - **Interrupt relocated, gated on \`live\`** not \`working\`: inline accent icon-button immediately left of Stop (≥640px) and a \`•••\` menu item above Stop (<640px). One-tap, no confirm; accessible name "Interrupt", title "Interrupt the current turn (keeps the session)". - **New \`pause\` glyph** (vendored Lucide, \`Icon.tsx\`) so Interrupt reads distinct from the danger two-step \`square\` Stop now that they sit adjacent; the two locked-state escape hatches (dialog-pending, degraded question) adopt it too. - **\`canClear\` un-gated** from \`working\` — New conversation follows \`composerLocked()\` alone, killing the secondary lockout. - **Dialog-pending / question locks unchanged** (authoritative spool + \`ErrDialogPending\`). - **ADR-0029** records the reversal; ADR-0022 marked superseded (its ADR-0016 supersessions — no queued hint, one-tap interrupt — carry forward). \`compat.md\` §5/§6/§8 updated. Note: the issue asked for "ADR-0028", but 0028 was taken by the answered-dialog-cards ADR that landed first, so this record is 0029. - **Known accepted gap**: the state badge still shows the possibly-false \`working\` — it no longer traps anyone; the root fix (real liveness signal) is #62. ## Tests \`RunChat.test.tsx\` rewritten for the new contract (79 → 83 tests): Send present/enabled/sending while working, ⌘-Enter while working, live-gated header + menu Interrupt (pause vs square, one tap POSTs \`/interrupt\`, absent on ended runs), menu item ordered above Stop and closing on tap, New conversation available while working, escape-hatch tests re-scoped so they stop accidentally clicking the new header control. ## Verification (all green locally) - \`web/\`: \`npm run lint\`, \`npm run format:check\`, \`npm test\` (497/497, 35 files), \`npm run build\` - Go: \`CGO_ENABLED=0 go build -tags ui ./cmd/...\` + \`go test -tags ui ./...\` against the embedded dist, plus the untagged build/test - Pure frontend + docs diff — no backend / API / SSE / schema / provider change 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016pJUBAmtjzJfHyZNy3Fuyc
feat(chat): always-Send composer, relocate Interrupt to the header (ADR-0029)
All checks were successful
ci / native (pull_request) Successful in 4m30s
ae5545fd42
Reverse ADR-0022's working-state Send→Interrupt morph: a false 'working'
(stale transcript tail, issue #38) could pin the composer to a no-op
Interrupt with no Send and no New conversation — a hard operator lockout.
Send is now always available in idle/needs_input/working and POSTs /reply
immediately (a genuinely mid-turn reply rides Claude Code's own TUI queue;
backend unchanged). Cmd/Ctrl+Enter sends in every unlocked state and
canClear follows composerLocked() alone.

Interrupt relocates to the chat header, gated on the live run outcome
instead of the untrusted derived state: an inline accent icon-button left
of Stop on desktop, a ••• menu item above Stop on mobile. It wears a new
vendored Lucide 'pause' glyph so it reads distinct from the danger
two-step 'square' Stop; the two locked-state escape hatches adopt the
same glyph. The working-only "tap to interrupt" hint and pulse cue die
with the morph.

ADR-0029 records the reversal (ADR-0022 marked superseded; its ADR-0016
supersessions carry forward); compat.md §5/§6/§8 updated to match. The
state badge still trusts the possibly-false 'working' — accepted gap,
root fix tracked in #62.

Closes #61

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pJUBAmtjzJfHyZNy3Fuyc
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!63
No description provided.