Chat composer always-Send: reverse the working-state morph, relocate Interrupt to the header¶
deriveState (internal/provider/claudecode/chat.go) guesses the
chat's conversational state from the last transcript block, with no
liveness signal of its own: a stalled tail — a trailing tool_result,
an assistant:thinking, even a bare user:text — makes working stick
forever, because the tailer only recomputes on file change. Under ADR-0022's
morph, a false working pinned the composer to a no-op Interrupt with no
Send at all, and a mirrored gate on canClear blocked New conversation
too — a hard operator lockout with no way out short of the transcript
itself moving (issue #38). Issue #61 deliberately does not fix the
wrong state; that deeper liveness fix is issue #62's umbrella. Instead it
decouples the composer's primary actions from a signal that cannot be trusted.
This ADR records that decoupling. It is a pure frontend/CSS diff within
the existing design language — no colour/type/radius/shadow redesign,
and no backend/API/SSE/message-schema/provider/migration change —
the same class of change as ADR-0019 and ADR-0022. It supersedes ADR-0022
wholesale: the morph it pinned (Send↔Interrupt swapping on working)
is reversed end to end. ADR-0022's own two supersessions of ADR-0016 —
dropping the queued-hint copy and dropping the confirm-tap interrupt —
are not reopened; both carry forward in force, now attached to the
controls this ADR relocates.
The decisions, pinned (settled with the maintainer 2026-07-09 via /grill-me — do not re-litigate):
-
Always-Send — the composer button never morphs. In
idle,needs_input, andworkingalike the composer's primary button is always thesendpaper-plane, never asquare/interrupt glyph. Enabled iff the textarea is non-empty and no send is already in flight; a click always POSTs/replyimmediately through the existingreplyRunpath. Send never readsworking— this supersedes ADR-0022's morphing-button decision outright. -
No lab-managed queue. A genuinely mid-turn reply is queued by Claude Code's own TUI, exactly as ADR-0016 originally shipped: the backend
Reply(tmux bracketed-paste +Enter) is unchanged and guards only an ended run and a pending dialog (internal/chat/chat.go'sReply). There is no lab-side queue UI and no optimistic echo — the reply becomes visible when the transcript reflects it, not before. -
Cmd/Ctrl+Enter always sends in
idle/needs_input/workingalike; bare Enter stays a newline in every state (the composer is phone-first, ADR-0005) [overturned on fine-pointer devices by ADR-0031, 2026-07-09 — bare Enter now sends there too; touch devices keep this rule unchanged]. The slash-command popover's own keys are unchanged. -
The working-only cue is deleted. "The agent is working — tap to interrupt." described the morph's square; with no morph left to describe, the line is removed rather than repurposed.
-
Interrupt relocates to the chat header, gated on
live(the run row's own outcome signal — active means the session is alive, so an interrupt can land; on an idle prompt it is a harmless no-op — never onworking, the untrusted signal this ADR stops trusting): desktop (≥640px) gets an inline icon-button immediately left of Stop; mobile (<640px) gets a•••menu item above Stop. One-tap, no confirm, accent colour; accessible name "Interrupt", title "Interrupt the current turn (keeps the session)"; it calls the sameinterruptRun(the tmuxEscape) that ADR-0022 wired. -
Interrupt and Stop sit adjacent now, so they stop sharing a glyph. Interrupt becomes a new
pauseglyph — Lucide's outline, two rounded bars, vendored intoweb/src/components/Icon.tsx— accent, one-tap. Stop keeps its unchangedsquare, danger-red, two-step verbal confirm ("Stop and end this instance"). ADR-0019's "icon-only never applies to a destructive confirmation" still holds: the destructive control keeps its confirm, the non-destructive one stays one-tap. -
canClearis un-gated fromworking. New conversation follows the composer's own lock: available whenever the composer is notcomposerLocked(ended, transcript-gone, dialog-pending, or a degraded question) — the oldworkingexclusion existed only to mirror ADR-0022's send-block, and it dies with that block. This kills the lockout's second half. -
The two locked-state escape hatches stay, adopting the
pauseglyph for visual consistency with the header: the dialog-pending waiting note and the degraded-question locked state both keep theirInterruptButton, unchanged in behaviour otherwise. -
The state badge still shows a possibly-wrong "working." Accepted as a cosmetic gap: a stuck
workingbadge no longer traps anyone downstream of it, and the honest fix — teachingderiveStatea real liveness signal — is issue #62's job, not this ADR's.
Scope guard. This ADR reaches only the idle / needs_input /
working composer states. The dialog-pending and question free-text
locks are unchanged: the authoritative dialog spool and the backend
ErrDialogPending still lock the composer while a dialog is pending,
and free text still cannot answer a focused TUI picker (only the dialog's
own Other row can).
Status¶
Accepted. Supersedes ADR-0022 — which morphed Send↔Interrupt on
working — while keeping ADR-0022's own supersessions of ADR-0016 in
force: the queued-hint removal and the one-tap-no-confirm interrupt carry
forward unchanged, now attached to the relocated header control.
Partially superseded by ADR-0031 (issue #70): bare Enter now sends on fine-pointer devices; Shift+Enter is the newline. Always-Send, no-queue, and the header Interrupt remain in force.
(Numbering note: issue #61's text asked for "ADR-0028"; that number was already taken by the answered-dialog-cards ADR, which landed first — this record is ADR-0029.)
A pure SPA diff: web/src/routes/RunChat.tsx, web/src/components/Icon.tsx,
web/src/base.css, and web/src/routes/RunChat.test.tsx, plus this ADR
and the three compat.md edits below. No Go, API, SSE, message-schema,
provider, or migration change — a future Codex/Gemini provider inherits
the reversed behaviour for free, same as ADR-0022 did.
Considered options¶
- Keep the ADR-0022 morph and fix
deriveStatefirst. Rejected: the liveness fix is a materially deeper backend change (issue #62, the umbrella for teachingderiveStatea real liveness signal), and every falseworkingbetween now and then is a lockout. Even a correctworkingneedlessly blocks the TUI-supported mid-turn queueing ADR-0016 originally shipped — the morph's cost was never only its bugs. - A lab-managed queue with an optimistic echo. Rejected: lab cannot honestly show the TUI's own queue state — the same "a promise lab can't show being kept" reasoning that killed ADR-0016's "queued" hint applies just as hard to a lab-drawn optimistic bubble for a reply the TUI has not actually accepted yet.
- Keep Interrupt in the composer, alongside Send, as a second button.
Rejected: two adjacent primary actions in the input row invite mis-taps,
and keeping Interrupt composer-side re-introduces a control whose placement
implies it is gated on
working; the header, next to Stop, groups the two turn/run-level interventions where they read as a pair instead of input-row clutter. - Gate the relocated Interrupt on
workinginstead oflive. Rejected:workingis the untrusted signal this whole ADR exists to stop trusting;livederives from the run row's own outcome, which cannot false-stick the way a stalled transcript tail can.
Consequences¶
web/src/routes/RunChat.test.tsxis rewritten: the morph assertions are replaced with always-Send (idle/needs_input/working all rendersend, enabled purely on non-empty text plus no in-flight send), Cmd/Ctrl+Enter sending whileworking, alive-gated Interrupt rendering distinctly in the header icon-button (desktop) and the•••menu (mobile) — never merged with Stop — and New conversation un-gated fromworking.internal/compat/compat.md§6 (Reply send-keys) and §8 (Interrupt keystroke) are rewritten for the new surfacing; the §5 state-neutrality bullet's present tense is corrected so a falseworkingis documented as reaching only the state badge.- The pulse keyframe and the working-cue CSS (the "tap to interrupt" line
and its
@media (prefers-reduced-motion: reduce)-gated pulse) die along with the morph they animated. - A false
workingcan no longer block Send, Clear, or Interrupt — the residual harm is purely the cosmetic state badge; issue #62's honest fix is still owed, but nothing downstream of the badge locks up anymore.