Chat composer: always-Send with TUI-queued replies; relocate Interrupt out of the morph (supersedes ADR-0022) #61
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#61
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Replace the chat composer's working-state Send→Interrupt morph (ADR-0022)
with a composer whose Send is always available and always fires immediately,
and move Interrupt to a dedicated control next to the header Stop. This
resolves the operator lockout in #38: today a false
workingstate (astale / command-echo transcript tail — see #38's confirmed root cause) hides Send
and leaves only a no-op Interrupt, so the operator cannot send and must restart.
Pure frontend + CSS, the same class of change as ADR-0022. No backend / API
/ SSE / schema change — the server already accepts a mid-turn reply
(
internal/chat/chat.go:346Replyguards only ended-run + pending-dialog;Claude Code's own TUI queues a mid-turn bracketed-paste). This does not fix
the underlying false-
workingderivation — that root cause (shared with #39) istracked separately in the liveness umbrella issue.
Why (the root cause this deliberately works around)
deriveState(internal/provider/claudecode/chat.go:322) guesses state from thelast transcript block with no liveness signal: a trailing
tool_result/assistant:thinking/user:text→working. When the agent stops without afinal
assistant:text(or a local-command echo is the last line), the tailfreezes and
workingsticks. The tailer only recomputes on file change(
internal/chat/tailer.go:154), so nothing ever clears it. Under ADR-0022 thatpins the composer to a one-tap Interrupt that no-ops on an already-idle prompt →
lockout (#38). Instead of trusting that signal, this change decouples the
primary actions from it: Send never reads
working, and Interrupt is gated onlive(run is active), notworking.Scope
Applies to the idle / needs_input / working composer states only. The
dialog-pending and question locked states are unchanged — their
free-text lock is backed by the authoritative dialog spool + backend
ErrDialogPending, and free text there would corrupt a focused TUI picker.Decisions (settled via /grill-me 2026-07-09 — do not re-litigate)
sendpaper-plane — it never morphs. Enabled iff the textarea is non-empty andno send is in flight. Click always calls the existing
send()→replyRunpath. Remove the
working()-based action/label/glyph swap on the button(
RunChat.tsx:1660-1682) and the now-unusedworkinglocal(
RunChat.tsx:1434)./replyimmediately; if the agent isgenuinely mid-turn, Claude Code's TUI queues it (backend unchanged). No queue
UI and no optimistic echo — the reply echoes when the transcript reflects it
(immediately in the common false-
working/actually-idle case; when the turnends otherwise).
&& !working()gate(
RunChat.tsx:1535). Bare Enter still never sends; slash-command popover keysunchanged.
(
RunChat.tsx:1684-1686).live(RunChat.tsx:719), mirroring Stop'sresponsive placement:
.chat-desktop-actions(
RunChat.tsx:788-855), immediately left of Stop.•••ChatMenu(RunChat.tsx:885),listed above Stop.
createInterrupt(
RunChat.tsx:1370) →interruptRun(web/src/api.ts:626). Accessible name"Interrupt", title "Interrupt the current turn (keeps the session)".
share the
squareglyph:pauseglyph, accent, one-tap.square, danger-red, two-step verbal confirm, "Stop andend this instance" (
RunChat.tsx:827-854; menu item998-1030).Vendor Lucide's
pauseoutline (two rounded vertical bars) intoweb/src/components/Icon.tsx: add'pause'to theIconNameunion (:14)and a
GLYPHSentry (:42),fill="none" stroke="currentColor"like theothers.
canClearun-gate. Remove thestate() !== 'working'exclusion(
RunChat.tsx:477) and rewrite its comment (RunChat.tsx:470-474): its onlyjustification was the ADR-0022 send-block being deleted here. New conversation
now follows the composer — available whenever not
composerLocked()(ended / transcript-gone / dialog / question). Kills the secondary lockout
where stuck-
workingalso hid New-conversation.(
RunChat.tsx:1589) and the DialogPanel escape hatch (RunChat.tsx:1844),both via
InterruptButton(RunChat.tsx:2015) — but adopt the samepauseglyph for consistency.
stateBadge(
web/src/lib/conversation.ts) still shows "working" from the possibly-wrongsignal. Accepted — it no longer traps anyone; the honest fix is the liveness
umbrella issue.
Out of scope
deriveState/ the tailer / a liveness signal → the livenessumbrella issue (root cause of #38 + #39).
ADR
Write ADR-0028 superseding ADR-0022 (evolving ADR-0016 / ADR-0019): record
the always-Send + Interrupt-relocation model and the false-
workinglockout thatdrove it. ADR-0022 is pinned "do not re-litigate", so the reversal must be
formally recorded, not silent.
Tests (
web/src/routes/RunChat.test.tsx)morphs Send into a one-tap Interrupt while the agent is working(:368) andpreserves a compose-ahead draft and morphs Send back…(:415), plus the header doc comment (:10-17).
state:'working': Send is present and enabled (non-empty text),clicking it POSTs
/reply, and there is no "tap to interrupt" copy in thecomposer.
working.live(desktop inline + mobile menu), one tapPOSTs
/interrupt, and is visually distinct from Stop (pausevssquare).working.disables Send while the composer is empty(:395) still passes.Acceptance criteria
not in-flight; click / ⌘-Enter always POSTs
/replyimmediately.workingcan prevent sending, starting a New conversation, or reachingInterrupt.
pausecontrol gated onlive: inline (left of Stop)≥640px, menu item (above Stop) <640px; distinct from the danger two-step
squareStop.npm test(vitest) andnpm run build(tsc --noEmit) greenin
web/.Root-cause follow-up (the deferred liveness fix): #62 — replaces the transcript-tail
deriveStateguess with a real liveness signal. This issue (#61) only works around the false-workinglockout in the UI; #62 is where the wrong state itself gets fixed. Resolves the operator lockout reported in #38.Land-PR audit for PR #63 (
feat(chat): always-Send composer, relocate Interrupt to the header (ADR-0029), headafk/61@ae5545f) — verdict: PASS.Verification signal relied on: the required
ci / nativegate is green on the head SHA (combined commit statussuccess, 4m30s) — SPA eslint/prettier/vitest/vite buildplus theui-tagged Go build+test and golangci-lint. Not re-run locally. The path-gatedci-nixgate did not run, correctly: the diff touches onlyweb/,docs/adr/, andinternal/compat/compat.md— no*.nix/flake.lock/go.mod— so the native gate fully covers this diff's surface.Checked:
origin/main(single commit directly atop the current tip, no conflict).Closes #61(AFK contract satisfied — merge will release theafk/61claim).working), no lab queue/optimistic echo, ⌘/Ctrl+Enter un-gated, working hint deleted, Interrupt relocated to the header gated onlive(inline left of Stop ≥640px,•••item above Stop <640px, correct name/title), vendoredpauseglyph distinct from the dangersquareStop,canClearun-gated fromworking, both locked-state escape hatches kept onpause, badge gap accepted. All implemented as specified.mainby the answered-dialog-cards ADR — this record is ADR-0029, noted in both the PR body and the ADR's numbering note.Awaiting explicit human confirmation before merging.