Chat composer: always-Send with TUI-queued replies; relocate Interrupt out of the morph (supersedes ADR-0022) #61

Closed
opened 2026-07-09 00:23:17 +02:00 by dominik.polakovics · 2 comments

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 working state (a
stale / 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:346 Reply guards only ended-run + pending-dialog;
Claude Code's own TUI queues a mid-turn bracketed-paste). This does not fix
the underlying false-working derivation — that root cause (shared with #39) is
tracked separately in the liveness umbrella issue.

Why (the root cause this deliberately works around)

deriveState (internal/provider/claudecode/chat.go:322) guesses state from the
last transcript block with no liveness signal: a trailing tool_result /
assistant:thinking / user:textworking. When the agent stops without a
final assistant:text (or a local-command echo is the last line), the tail
freezes and working sticks. The tailer only recomputes on file change
(internal/chat/tailer.go:154), so nothing ever clears it. Under ADR-0022 that
pins 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 on
live (run is active), not working.

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)

  1. Always-Send. In idle/needs_input/working the composer button is always the
    send paper-plane — it never morphs. Enabled iff the textarea is non-empty and
    no send is in flight. Click always calls the existing send()replyRun
    path. Remove the working()-based action/label/glyph swap on the button
    (RunChat.tsx:1660-1682) and the now-unused working local
    (RunChat.tsx:1434).
  2. No lab-managed queue. Each send POSTs /reply immediately; if the agent is
    genuinely 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 turn
    ends otherwise).
  3. Cmd/Ctrl+Enter always sends in these states — drop the && !working() gate
    (RunChat.tsx:1535). Bare Enter still never sends; slash-command popover keys
    unchanged.
  4. Delete the working-only cue "The agent is working — tap to interrupt."
    (RunChat.tsx:1684-1686).
  5. Interrupt relocates, gated on live (RunChat.tsx:719), mirroring Stop's
    responsive placement:
    • Desktop (≥640px): an inline icon-button in .chat-desktop-actions
      (RunChat.tsx:788-855), immediately left of Stop.
    • Mobile (<640px): a menu item in the ••• ChatMenu (RunChat.tsx:885),
      listed above Stop.
    • One-tap, no confirm, accent colour; reuses createInterrupt
      (RunChat.tsx:1370) → interruptRun (web/src/api.ts:626). Accessible name
      "Interrupt", title "Interrupt the current turn (keeps the session)".
  6. Differentiate Interrupt vs Stop. They now sit adjacent, so they must not
    share the square glyph:
    • Interrupt → new pause glyph, accent, one-tap.
    • Stop → unchanged: square, danger-red, two-step verbal confirm, "Stop and
      end this instance" (RunChat.tsx:827-854; menu item 998-1030).
      Vendor Lucide's pause outline (two rounded vertical bars) into
      web/src/components/Icon.tsx: add 'pause' to the IconName union (
      :14)
      and a GLYPHS entry (:42), fill="none" stroke="currentColor" like the
      others.
  7. canClear un-gate. Remove the state() !== 'working' exclusion
    (RunChat.tsx:477) and rewrite its comment (RunChat.tsx:470-474): its only
    justification 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-working also hid New-conversation.
  8. The two other one-tap Interrupt squares stay — the question-locked state
    (RunChat.tsx:1589) and the DialogPanel escape hatch (RunChat.tsx:1844),
    both via InterruptButton (RunChat.tsx:2015) — but adopt the same pause
    glyph for consistency.
  9. Badge unchanged (known cosmetic gap). stateBadge
    (web/src/lib/conversation.ts) still shows "working" from the possibly-wrong
    signal. Accepted — it no longer traps anyone; the honest fix is the liveness
    umbrella issue.

Out of scope

  • Any change to deriveState / the tailer / a liveness signal → the liveness
    umbrella issue (root cause of #38 + #39).
  • The dialog-pending / question free-text locks.
  • Any backend / API / SSE / schema / provider change.

ADR

Write ADR-0028 superseding ADR-0022 (evolving ADR-0016 / ADR-0019): record
the always-Send + Interrupt-relocation model and the false-working lockout that
drove 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)

  • Rewrite the morph tests: morphs Send into a one-tap Interrupt while the agent is working (:368) and preserves a compose-ahead draft and morphs Send back…
    (:415), plus the header doc comment (:10-17).
  • Add / update coverage:
    • While state:'working': Send is present and enabled (non-empty text),
      clicking it POSTs /reply, and there is no "tap to interrupt" copy in the
      composer.
    • Cmd/Ctrl+Enter sends while working.
    • Interrupt appears gated on live (desktop inline + mobile menu), one tap
      POSTs /interrupt, and is visually distinct from Stop (pause vs square).
    • New conversation is available while working.
    • disables Send while the composer is empty (:395) still passes.

Acceptance criteria

  • In idle/needs_input/working, Send is always visible; enabled iff non-empty &
    not in-flight; click / ⌘-Enter always POSTs /reply immediately.
  • No false-working can prevent sending, starting a New conversation, or reaching
    Interrupt.
  • Interrupt is a one-tap pause control gated on live: inline (left of Stop)
    ≥640px, menu item (above Stop) <640px; distinct from the danger two-step
    square Stop.
  • Dialog-pending / question locks unchanged.
  • ADR-0028 added; npm test (vitest) and npm run build (tsc --noEmit) green
    in web/.
## 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* `working` state (a stale / 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:346` `Reply` guards only *ended-run* + *pending-dialog*; Claude Code's own TUI queues a mid-turn bracketed-paste). This does **not** fix the underlying false-`working` derivation — that root cause (shared with #39) is tracked separately in the liveness umbrella issue. ## Why (the root cause this deliberately works around) `deriveState` (`internal/provider/claudecode/chat.go:322`) guesses state from the **last transcript block** with no liveness signal: a trailing `tool_result` / `assistant:thinking` / `user:text` → `working`. When the agent stops without a final `assistant:text` (or a local-command echo is the last line), the tail freezes and `working` sticks. The tailer only recomputes on file change (`internal/chat/tailer.go:154`), so nothing ever clears it. Under ADR-0022 that pins 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 on `live` (run is active), not `working`. ## 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) 1. **Always-Send.** In idle/needs_input/working the composer button is always the `send` paper-plane — it never morphs. Enabled iff the textarea is non-empty and no send is in flight. Click always calls the existing `send()` → `replyRun` path. Remove the `working()`-based action/label/glyph swap on the button (`RunChat.tsx:1660-1682`) and the now-unused `working` local (`RunChat.tsx:1434`). 2. **No lab-managed queue.** Each send POSTs `/reply` immediately; if the agent is genuinely 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 turn ends otherwise). 3. **Cmd/Ctrl+Enter always sends** in these states — drop the `&& !working()` gate (`RunChat.tsx:1535`). Bare Enter still never sends; slash-command popover keys unchanged. 4. **Delete the working-only cue** "The agent is working — tap to interrupt." (`RunChat.tsx:1684-1686`). 5. **Interrupt relocates, gated on `live`** (`RunChat.tsx:719`), mirroring Stop's responsive placement: - **Desktop (≥640px):** an inline icon-button in `.chat-desktop-actions` (`RunChat.tsx:788-855`), immediately **left of** Stop. - **Mobile (<640px):** a menu item in the `•••` `ChatMenu` (`RunChat.tsx:885`), listed **above** Stop. - One-tap, no confirm, accent colour; reuses `createInterrupt` (`RunChat.tsx:1370`) → `interruptRun` (`web/src/api.ts:626`). Accessible name **"Interrupt"**, title **"Interrupt the current turn (keeps the session)"**. 6. **Differentiate Interrupt vs Stop.** They now sit adjacent, so they must not share the `square` glyph: - **Interrupt** → new **`pause`** glyph, accent, one-tap. - **Stop** → unchanged: `square`, danger-red, two-step verbal confirm, "Stop and end this instance" (`RunChat.tsx:827-854`; menu item ~`998-1030`). Vendor Lucide's `pause` outline (two rounded vertical bars) into `web/src/components/Icon.tsx`: add `'pause'` to the `IconName` union (~`:14`) and a `GLYPHS` entry (`:42`), `fill="none" stroke="currentColor"` like the others. 7. **`canClear` un-gate.** Remove the `state() !== 'working'` exclusion (`RunChat.tsx:477`) and rewrite its comment (`RunChat.tsx:470-474`): its only justification 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-`working` also hid New-conversation. 8. **The two other one-tap Interrupt squares stay** — the question-locked state (`RunChat.tsx:1589`) and the DialogPanel escape hatch (`RunChat.tsx:1844`), both via `InterruptButton` (`RunChat.tsx:2015`) — but adopt the same **`pause`** glyph for consistency. 9. **Badge unchanged (known cosmetic gap).** `stateBadge` (`web/src/lib/conversation.ts`) still shows "working" from the possibly-wrong signal. Accepted — it no longer traps anyone; the honest fix is the liveness umbrella issue. ## Out of scope - Any change to `deriveState` / the tailer / a liveness signal → the liveness umbrella issue (root cause of #38 + #39). - The dialog-pending / question free-text locks. - Any backend / API / SSE / schema / provider change. ## ADR Write **ADR-0028** superseding **ADR-0022** (evolving ADR-0016 / ADR-0019): record the always-Send + Interrupt-relocation model and the false-`working` lockout that drove 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`) - Rewrite the morph tests: `morphs Send into a one-tap Interrupt while the agent is working` (:368) and `preserves a compose-ahead draft and morphs Send back…` (:415), plus the header doc comment (:10-17). - Add / update coverage: - While `state:'working'`: **Send is present and enabled** (non-empty text), clicking it POSTs `/reply`, and there is **no** "tap to interrupt" copy in the composer. - Cmd/Ctrl+Enter sends while `working`. - **Interrupt** appears gated on `live` (desktop inline + mobile menu), one tap POSTs `/interrupt`, and is visually distinct from Stop (`pause` vs `square`). - **New conversation** is available while `working`. - `disables Send while the composer is empty` (:395) still passes. ## Acceptance criteria - In idle/needs_input/working, Send is always visible; enabled iff non-empty & not in-flight; click / ⌘-Enter always POSTs `/reply` immediately. - No false-`working` can prevent sending, starting a New conversation, or reaching Interrupt. - Interrupt is a one-tap `pause` control gated on `live`: inline (left of Stop) ≥640px, menu item (above Stop) <640px; distinct from the danger two-step `square` Stop. - Dialog-pending / question locks unchanged. - ADR-0028 added; `npm test` (vitest) and `npm run build` (`tsc --noEmit`) green in `web/`.
Author
Owner

Root-cause follow-up (the deferred liveness fix): #62 — replaces the transcript-tail deriveState guess with a real liveness signal. This issue (#61) only works around the false-working lockout in the UI; #62 is where the wrong state itself gets fixed. Resolves the operator lockout reported in #38.

Root-cause follow-up (the deferred liveness fix): #62 — replaces the transcript-tail `deriveState` guess with a real liveness signal. This issue (#61) only works around the false-`working` lockout in the UI; #62 is where the wrong state itself gets fixed. Resolves the operator lockout reported in #38.
Author
Owner

This was generated by AI while landing a PR.

Land-PR audit for PR #63 (feat(chat): always-Send composer, relocate Interrupt to the header (ADR-0029), head afk/61 @ ae5545f) — verdict: PASS.

Verification signal relied on: the required ci / native gate is green on the head SHA (combined commit status success, 4m30s) — SPA eslint/prettier/vitest/vite build plus the ui-tagged Go build+test and golangci-lint. Not re-run locally. The path-gated ci-nix gate did not run, correctly: the diff touches only web/, docs/adr/, and internal/compat/compat.md — no *.nix/flake.lock/go.mod — so the native gate fully covers this diff's surface.

Checked:

  • Open, not a draft; merges clean against origin/main (single commit directly atop the current tip, no conflict).
  • Title is Conventional Commits; body carries a working Closes #61 (AFK contract satisfied — merge will release the afk/61 claim).
  • Full diff reviewed against issue #61's nine settled decisions: always-Send (never reads working), no lab queue/optimistic echo, ⌘/Ctrl+Enter un-gated, working hint deleted, Interrupt relocated to the header gated on live (inline left of Stop ≥640px, ••• item above Stop <640px, correct name/title), vendored pause glyph distinct from the danger square Stop, canClear un-gated from working, both locked-state escape hatches kept on pause, badge gap accepted. All implemented as specified.
  • Tests rewritten to lock the new contract (morph tests replaced; new coverage for Send-while-working, ⌘-Enter while working, live-gated header+menu Interrupt incl. ordering above Stop, ended-run absence, New-conversation-while-working); compat.md §5/§6/§8 updated coherently; ADR-0022 marked superseded with its ADR-0016 supersessions carried forward.
  • One divergence from the issue text, intentional and recorded: the issue asked for "ADR-0028", but 0028 was already taken on main by 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.

> *This was generated by AI while landing a PR.* **Land-PR audit for PR #63** (`feat(chat): always-Send composer, relocate Interrupt to the header (ADR-0029)`, head `afk/61` @ `ae5545f`) — **verdict: PASS**. **Verification signal relied on:** the required `ci / native` gate is green on the head SHA (combined commit status `success`, 4m30s) — SPA eslint/prettier/vitest/`vite build` plus the `ui`-tagged Go build+test and golangci-lint. Not re-run locally. The path-gated `ci-nix` gate did not run, correctly: the diff touches only `web/`, `docs/adr/`, and `internal/compat/compat.md` — no `*.nix`/`flake.lock`/`go.mod` — so the native gate fully covers this diff's surface. **Checked:** - Open, not a draft; merges clean against `origin/main` (single commit directly atop the current tip, no conflict). - Title is Conventional Commits; body carries a working `Closes #61` (AFK contract satisfied — merge will release the `afk/61` claim). - Full diff reviewed against issue #61's nine settled decisions: always-Send (never reads `working`), no lab queue/optimistic echo, ⌘/Ctrl+Enter un-gated, working hint deleted, Interrupt relocated to the header gated on `live` (inline left of Stop ≥640px, `•••` item above Stop <640px, correct name/title), vendored `pause` glyph distinct from the danger `square` Stop, `canClear` un-gated from `working`, both locked-state escape hatches kept on `pause`, badge gap accepted. All implemented as specified. - Tests rewritten to lock the new contract (morph tests replaced; new coverage for Send-while-working, ⌘-Enter while working, live-gated header+menu Interrupt incl. ordering above Stop, ended-run absence, New-conversation-while-working); compat.md §5/§6/§8 updated coherently; ADR-0022 marked superseded with its ADR-0016 supersessions carried forward. - One divergence from the issue text, intentional and recorded: the issue asked for "ADR-0028", but 0028 was already taken on `main` by 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.
Sign in to join this conversation.
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#61
No description provided.