Skip to content

feat(tui,app): queue user messages while the session is busy - #50

Merged
obukhovaa merged 8 commits into
mainfrom
feat/queue-user-messages-while-busy
Sep 6, 2026
Merged

feat(tui,app): queue user messages while the session is busy#50
obukhovaa merged 8 commits into
mainfrom
feat/queue-user-messages-while-busy

Conversation

@obukhovaa

@obukhovaa obukhovaa commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Problem

While the agent is running, pressing Enter in the TUI triggers the busy guard in editorCmp.send(), which returned a warning toast and did nothing else. The typed text was preserved in the textarea — this is not data loss — but the submission itself was discarded. The user had to notice when the run finished and re-press Enter manually. Claude Code queues this input instead; users expect the same behaviour.

What changed

Core queue (, )

A per-session in-memory FIFO queue lives on app.App (queues map[string][]QueuedMessage). When send() fires while the session is busy (or already has queued messages), it calls app.EnqueueMessage, resets the textarea, and returns nil — no warning toast. A lazily-started drain worker goroutine picks up messages one at a time after the current run releases the slot.

FIFO routing in send() ()

The routing condition is QueueLen(sessionID) > 0 || IsSessionBusy(sessionID). Direct dispatch is permitted only when both are false. Without the QueueLen branch, a submission arriving in the idle window between two drain deliveries (queue non-empty, slot momentarily free) would bypass already-queued messages and cause a FIFO inversion.

Drain worker lifecycle

  • ErrSessionBusy from Run is the only swallowed error: the message is re-prepended at the head and the worker backs off 100 ms. The authoritative exclusivity mechanism remains the atomic LoadOrStore inside RunWith; no check-then-act pattern is introduced.
  • Any other error is surfaced via the TUI error path with attribution ("queued message could not be delivered: …"), the worker halts, and the remaining queue is preserved (visible in the banner so the user can discard or trigger a fresh drain by sending a new message).
  • Workers are cancelled and joined on app.Shutdown() via ShutdownQueues().

TUI affordance (, )

A muted italic banner renders below the working spinner: "N messages queued — press ctrl+x to discard". ctrl+x calls app.DiscardQueue and the banner disappears. ErrSessionBusy from the idle direct-dispatch path is suppressed (it means the worker raced; it is retryable and not an error the user needs to see).

Drain notifier ()

app.SetDrainNotifier is called after tea.NewProgram so drain workers can push app.DrainEvent messages into the TUI loop via program.Send.

Scope limit — delivery is after the current run, not between tool calls

Queued messages are delivered only after the current run completes, not between tool calls. Tool results already occupy a user turn, so mid-run injection produces consecutive same-role turns. The direct Anthropic API silently combines them, but Bedrock has historically rejected them outright and VertexAI behaviour is unverified. Each is a distinct correctness risk. Mid-run turn-boundary injection is a documented follow-up (see design.md); this PR intentionally does not attempt it.

Design rationale

No-persist-before-delivery. Queued messages are never written to the DB before delivery. Agent-loop compaction (agent.go ~line 945) and the non-interactive reload (~line 1244) both re-List messages from the DB, so a persisted-but-undelivered message would be swept into the in-flight run non-deterministically.

Cancel semantics. Esc/Ctrl+C cancels the in-flight run; the queue survives by design — the most common interruption pattern is "I noticed a mistake; redirect me" and the queued message IS that redirection. ctrl+x is the explicit way to clear the queue.

Discard key — ctrl+x

Verified absent from:

  • editorMaps — Send: enter/ctrl+s, OpenEditor: ctrl+e
  • DeleteKeyMaps — AttachmentDeleteMode: ctrl+r, Escape: esc, DeleteAllAttachments: r
  • messageKeys — PageDown: pgdown, PageUp: pgup, HalfPageUp: ctrl+u, HalfPageDown: ctrl+d
  • bubbles v2 textarea default KeyMapctrl+d is DeleteCharacterForward there (and also HalfPageDown in messageKeys); ctrl+x is absent from all entries

Verification

  • make test — exit 0; 455 tests pass across 16 packages
  • go test -race ./internal/llm/agent/ — 246 tests including session_locks_test.go (run unmodified)
  • go test -race ./internal/app/ ./internal/tui/components/chat/ — 25 tests

Deliberate-breakage check (a) — FIFO routing:
Changed QueueLen > 0 || IsSessionBusyIsSessionBusy in send():

FAIL: TestEditor_send_QueueNonEmptySessionIdleEnqueues
  editor_busy_test.go:208: send() dispatched directly despite non-empty queue (FIFO violation)
  editor_busy_test.go:212: expected ≥2 queued messages (existing + new), got 1

Deliberate-breakage check (b) — drain error surfacing:
Swallowed non-busy error in drainLoop:

FAIL: TestDrain_NonBusyError_HaltsWorker
  drain_test.go:297: expected an attributed error notification, got none
FAIL: TestDrain_ErrorAttribution
  drain_test.go:460: timeout waiting for: error event received

Both restored; all tests green.

Expected conflict with #49

PR #49 (fix/chat-editor-input-wrapping) also modifies internal/tui/components/chat/editor.go's send(). It adds a syncTextareaHeight() call inside send(); this PR rewrites the busy guard. Resolution: keep BOTH the enqueue routing from this PR and the syncTextareaHeight() call from #49.

Sibling PR: #49 (fix/chat-editor-input-wrapping)


Bridge queue visibility and loss-path fixes (bridge-queue-visibility-and-loss-paths)

Note: this PR now contains two related but distinct changes — the TUI queue (above) and the bridge queue visibility+correctness work (this section). Both target the same root premise: messages are already queued; the gap was visibility and silent loss.

Corrected premise

The bridge already had a 16-slot d.inbound channel with never-drop semantics. This change does not add a new queue. It closes four loss paths and a cross-session starvation bug that the existing queue made reachable.

Four loss paths closed

1. ErrSessionBusy → message discarded (the critical bug)

The chat-bridge spec incorrectly asserted ErrSessionBusy can never surface in the bridge path. The session-run-exclusivity spec contradicts this — its scenario "Session-run-exclusivity for bridge inbound" names the bridge dispatcher explicitly as a caller that MUST handle it from cross-actor holders (flow steps, cron sentinel locks, task auto-resume). The old code discarded the message and told the user "please resend".

Fix: bounded retry (100 ms backoff, 5-minute budget); on budget expiry the message is re-queued via the per-session overflow path (see below), never dropped. Both the retry and the overflow fix had to ship together.

2. Cross-session starvation

runInboundLoop is the single shared goroutine for the whole service. The old pushInbound blocked when d.inbound (cap 16) was full. With the retry fix in place, handleInbound keeps the run goroutine in the retry loop longer, so d.inbound fills faster, and a full channel froze dispatch for every session on every adapter.

Fix: pushInbound is now non-blocking. A full channel spills to a per-session d.overflow slice (guarded by d.mu). run() drains overflow back into d.inbound under d.mu after each handleInbound returns, preserving FIFO: the channel-then-overflow ordering invariant guarantees overflow items are always older than new arrivals.

3. Silent drops made audible

  • No active agent: now replies to the sender instead of silently discarding.
  • Interactive-flow buffer drop-oldest: now notifies the evicted peer.
  • Shutdown with queued messages: close() drains before close(d.inbound) and logs one WARN per dropped message plus a per-session summary. In-chat notification is not attempted (context is cancelled by the time tearDownDispatchers runs). Durability across restart is a named non-goal.

4. POST /router/inbound 429 enrichment

Bare 429 "retry" replaced with Retry-After: 1 header plus machine-readable JSON body {error, retryAfterSeconds, dispatcherSaturated: true} so the orchestrator's single-retry policy is actionable.

New config flag: router.queueAcknowledgementsEnabled (default false)

When enabled, the bridge sends an in-place-editable ⏳ queued acknowledgement to a sender whose message is waiting behind an in-flight agent run. The ack is edited on each retry and resolved to ▶ Processing your message now… when the run starts. A 2-second threshold suppresses the ack for sub-second waits.

All three production adapters implement the new bridge.QueuedAcknowledger interface with platform-native edit primitives (Telegram EditMessageText, Slack UpdateMessageContext, Mattermost UpdatePost). No emoji-reaction primitive is used.

opencode-schema.json regenerated (+74 lines, new router property block). docs/bridge.md updated. Viper round-trip test added.

Still carrying the expected send() conflict with PR #49.

Current behavior: editorCmp.send() checks IsSessionBusy and returns a
warning toast, leaving the typed text in the textarea but discarding the
submission. The user has to notice when the run finishes and re-press Enter
manually. Text is not lost; the submission is.

This change adds a per-session in-memory FIFO queue on app.App so that
every Enter press while the session is busy (or already has queued messages)
enqueues rather than rejects. The textarea is reset immediately — text moves
into the queue, not into /dev/null.

Design decisions
----------------

No-persist-before-delivery: queued messages are held in memory only. Writing
to the DB before delivery would make agent-loop compaction (~agent.go:945) and
the non-interactive reload (~agent.go:1244), both of which re-List from the DB,
sweep an undelivered message into the in-flight run non-deterministically.

Exclusivity unchanged: the drain worker calls agent.Run and treats ErrSessionBusy
as the sole retryable signal. The authoritative exclusivity mechanism remains the
atomic LoadOrStore inside RunWith. No check-then-act pattern is introduced.

FIFO routing: send() enqueues when QueueLen > 0 OR IsSessionBusy, so a
submission arriving in the idle window between two drain deliveries cannot
bypass already-queued messages via direct dispatch.

Drain errors: ErrSessionBusy is silently retried with 100 ms back-off. Any
other error is surfaced with attribution ("queued message could not be
delivered: ..."), the worker halts, and the remaining queue is preserved so
the user can discard it or trigger a fresh drain.

Delivery timing: messages are delivered after the current run completes, not
between tool calls. Tool results already occupy a user turn, so mid-run
injection produces consecutive same-role turns — combined by the direct
Anthropic API but historically rejected by Bedrock, and unverified on
VertexAI. Mid-run turn-boundary injection is a documented follow-up.

Cancel semantics: Esc/Ctrl+C cancels the in-flight run; the queue survives
by default (interrupting to redirect via a queued instruction is the common
case). ctrl+x is the explicit discard binding, shown in the queue banner.

Discard key ctrl+x: verified absent from editorMaps (enter/ctrl+s, ctrl+e),
DeleteKeyMaps (ctrl+r, esc, r), messageKeys (pgdown, pgup, ctrl+u, ctrl+d),
and the bubbles v2 textarea default KeyMap (ctrl+d is DeleteCharacterForward
there, not InsertNewline as earlier noted — but it also binds HalfPageDown in
messageKeys, making it doubly off-limits).

Components added/changed
------------------------
- internal/app/queue.go: QueuedMessage, DrainEvent, EnqueueMessage,
  DequeueMessage, QueueLen, DiscardQueue, startDrainWorker, drainLoop,
  ShutdownQueues, NewForTest, SetDrainNotifier.
- internal/app/app.go: ctx/queues/queueMu/queueCancels/queueWg/drainNotify
  fields; ShutdownQueues called in Shutdown.
- internal/tui/components/chat/editor.go: send() rewritten with FIFO routing;
  OpenEditor guard comment added.
- internal/tui/components/chat/list.go: queueBanner() affordance; DrainEvent
  handling triggers re-render.
- internal/tui/page/chat.go: DrainEvent handling (error surfacing); ctrl+x
  DiscardQueue binding; ErrSessionBusy suppressed in sendMessage;
  CommandRunCustomMsg guard comment added.
- cmd/root.go: SetDrainNotifier wired after tea.NewProgram.

Tests
-----
- internal/app/queue_test.go: FIFO order, QueueLen, DiscardQueue, concurrent
  race-detector coverage, attachments preserved, ShutdownQueues termination.
- internal/app/drain_test.go: FIFO delivery, ErrSessionBusy retry, non-busy
  error halts/attributes/preserves queue/restarts on re-enqueue, worker
  terminates after drain, context cancellation, FIFO across idle window,
  error attribution format.
- internal/tui/components/chat/editor_busy_test.go: busy enqueues+resets+nil,
  empty-while-busy no-op, idle direct dispatch, queue-non-empty idle enqueues
  (FIFO), idle-path error returns non-nil cmd.
…nt preservation, audible drops, 429 enrichment

The bridge inbound channel (16-slot, never-drop) was already queuing messages;
this change closes four loss paths and a cross-session starvation bug that the
queue made reachable.

## ErrSessionBusy — message content was discarded (CVE of the change)

The chat-bridge spec incorrectly asserted ErrSessionBusy can never surface in
the bridge path. The session-run-exclusivity spec contradicts this: its scenario
'Session-run-exclusivity for bridge inbound' names the bridge dispatcher
explicitly as a caller that MUST handle ErrSessionBusy from cross-actor holders
(flow steps, cron sentinel locks, task auto-resume). The old code's response was
to discard the message and tell the user 'please resend' — a data-loss bug.

Fix: bounded retry (100 ms backoff, 5-minute budget); on budget expiry the
message is re-queued via the per-session overflow path, never dropped.

## Cross-session starvation — co-required with the retry fix

runInboundLoop is the single shared goroutine for the whole service. The old
pushInbound blocked when d.inbound (cap 16) was full. With the retry fix,
handleInbound keeps the run goroutine inside the retry loop, so d.inbound fills
faster; any push to a full channel from the shared loop then froze dispatch for
every session on every adapter.

Fix: pushInbound is now non-blocking. A full channel spills to a per-session
overflow slice (d.overflow, guarded by d.mu). run() drains overflow back into
d.inbound under d.mu after each handleInbound returns, preserving per-session
FIFO: the channel-then-overflow ordering invariant guarantees overflow items are
always older than new arrivals that reach pushInbound while the drain is in
flight.

## Three previously silent loss paths made audible

- No active agent: now replies to the sender instead of silently discarding
- Interactive-flow buffer drop-oldest: now notifies the evicted peer
- Shutdown with queued messages: close() drains d.overflow and d.inbound before
  close(d.inbound) and logs one WARN per dropped message plus a per-session
  summary count. In-chat notification is NOT attempted: Service.Stop() cancels
  the context before tearDownDispatchers(), so adapter API calls would return
  immediately. Durability across restart is a named non-goal; the WARN log is
  the hard requirement.

## POST /router/inbound 429 enrichment

Bare 429 'retry' replaced with Retry-After: 1 header plus machine-readable JSON
body {error, retryAfterSeconds, dispatcherSaturated: true} so the orchestrator's
single-retry policy is actionable.

## Tests

- TestHandleInbound_BusyRetryPreservesContent: proves old code discarded (Run
  called 1×, 'please resend' reply sent); with fix, called 3× and no reply.
- TestDispatch_NonBlockingPush_NoStarvation: proves old code blocked (2.5s
  timeout firing); with fix, completes in O(1).
- TestOverflowFIFO, TestSessionSerializationInvariant, TestHandleInbound_NilAgentReplies,
  TestBufferInbound_DropNotifiesEvictedPeer, TestShutdown_WarnsOnQueuedMessages,
  TestRouterInbound_BackpressureReturns429 (updated).
…d + QueuedAcknowledger

Adds opt-in user-visible acknowledgement for messages queued behind an in-flight
agent run. When QueueAcknowledgementsEnabled is true the sender receives an
in-place-editable '⏳ Your message is queued' note that is edited on each retry
and resolved to '▶ Processing your message now…' the moment their run starts.

The bridge was already queueing messages (16-slot inbound channel, never-drop);
this change adds visibility for the sender during the wait.

## Design decisions

Short-wait threshold (Decision 3): the ack is suppressed when the blocking run
completes within 2 seconds to avoid a pointless flash for sub-second waits. The
threshold is a package-level var (busyAckThreshold) so tests override it without
sleeping.

In-place edit (Decision 4): all three adapters support edit primitives. No new
message is posted per position change; the single ack message is edited, keeping
the channel thread clean.

Per-sender delivery (spec): in multi-peer sessions the ack goes only to the
specific peer whose inbound was queued, not broadcast.

Sentinel position 0 = 'resolved': UpdateQueuedAck(ctx, peer, token, 0) edits
the message to the 'Processing' text, so callers don't need a separate Delete
primitive. Documented in the QueuedAcknowledger interface comment.

Adapter tokens:
- Telegram: string-encoded int message ID ('1234')
- Slack:    channelID + '\x00' + ts (null-separated; neither field contains nulls)
- Mattermost: post ID string

## Config field (CLAUDE.md obligations fulfilled)

QueueAcknowledgementsEnabled bool (json: queueAcknowledgementsEnabled) on
bridge.Config. Default false — silent queuing as before.

1. Field added to internal/bridge/config.go
2. cmd/schema/main.go updated with full 'router' property block; opencode-schema.json
   regenerated (74 insertions, no unrelated churn)
3. docs/bridge.md: queueAcknowledgementsEnabled row added to router fields table
4. internal/config/config_router_test.go: Viper round-trip test confirms the field
   survives viper.ReadInConfig + viper.Unmarshal (pure json.Unmarshal passes but
   Viper's case-fold would mangle camelCase keys differently in production)

## Tests

Config-gate proof (both directions, deterministic — no sleep):
- DISABLED: TestQueuedAck_ConfigGate_Disabled — zero SendQueuedAck/UpdateQueuedAck calls
- ENABLED:  TestQueuedAck_ConfigGate_Enabled  — SendQueuedAck called once, final
  UpdateQueuedAck has position=0 (resolve)

TestHandleInbound_QueuedAckLifecycle: end-to-end lifecycle with busyAckThreshold=0;
asserts SendQueuedAck called once, UpdateQueuedAck called with position>0 then
position=0 on success.

TestBufferInbound_NoDrainWithoutQuestion (task 9.2): interactive-buffer FIFO
drain into the next Ask call; head of buffer ('first') auto-answers, remainder
('second','third') stays buffered, question not fanned out to adapter.

Per-adapter unit tests: Telegram queue_ack_test.go, Slack queue_ack_test.go,
Mattermost queue_ack_test.go — each verifies SendQueuedAck posts a message
returning the correct token format, and UpdateQueuedAck edits in-place with
position>0 and resolves with position=0.

Restart durability remains a named non-goal. Queued messages are in-memory;
shutdown loss is logged at WARN (Part 1). The visibility layer does not change
that non-goal.
…l, honest acks

Seven correctness fixes from review of the message-queue work. Each one is
covered by a test that was verified to fail when the fix is reverted.

## Silent message loss on the direct-dispatch path (tui/page/chat.go)

sendMessage swallowed ErrSessionBusy and returned. Nothing retried it: the
editor only reaches direct dispatch when QueueLen == 0 AND !IsSessionBusy, so
the message was never enqueued — and the textarea had already been reset, so
the submission was gone with no toast.

The race is reachable through ordinary queue use: the drain worker dequeues the
last message (QueueLen -> 0) but has not called Run yet (IsSessionBusy -> false),
the user presses Enter, and whichever Run loses acquireSessionSlot gets
ErrSessionBusy. proposal.md assumed this path was "retryable via the drain",
but the drain worker calls ag.Run directly and never goes through sendMessage.
It now enqueues instead of dropping. Non-busy errors still surface.

## Lost-wakeup race stalls the queue (app/queue.go)

drainLoop used two critical sections: DequeueMessage (empty -> false), then a
second Lock to delete(queueCancels, sessionID). An EnqueueMessage landing in
between sees the still-registered cancel, declines to start a worker, and its
message is stranded with nothing draining it — plus a QueueLen: 0 DrainEvent,
so the banner under-reports. Reproduced by widening the window with a 5 ms
sleep: 423/500 delivered, queueLen=77, workerRunning=false.

Dequeue and deregistration now happen in one critical section (dequeueOrRelease).

## Queued-ack edit storm (bridge/service/dispatch.go)

UpdateQueuedAck fired on every 100 ms retry with an unchanged position — up to
~3000 identical edits per queued message over the 5-minute budget. Telegram
rejects an unchanged editMessageText with "message is not modified"; Slack
chat.update and Mattermost UpdatePost are rate-limited. Now edits only when the
rendered position actually changes.

## Ack falsely resolved when the run never started

Both the budget-expiry branch and the non-busy-error branch called
resolveQueueAck, which renders "▶ Processing your message now…". On expiry the
message was re-queued, not processed; on error a failure reply lands right
after. Both now leave the "⏳ queued" text in place.

## Ack re-sent on every re-queue cycle

queueAckState was a handleInbound local, so each 5-minute cycle SENT a brand-new
"⏳ queued" message and orphaned the previous one, never resolved — a session
held for two hours accumulates ~24 dead acks. design.md:220 claims "the user
sees the ack update on each cycle"; the code did not do that. A per-peer token
memo on sessionDispatch (liveAcks) makes the token survive a re-queue; it is
cleared on resolve and on a failed edit so a deleted message falls back to a
fresh send.

## Send on a closed channel (bridge/service/dispatch.go)

close() closed d.inbound outside d.mu and pushInbound had no stop check, so a
push racing an unbind panics the dispatcher goroutine (recovered by
launchSupervised, but the message is lost). Newly reachable from handleInbound's
budget-expiry pushInbound. close(d.inbound) moved inside d.mu and pushInbound
re-checks stop under the same mutex, dropping audibly instead.

## Platform I/O on the shared inbound loop (bridge/service/question.go)

BufferInbound's eviction notice called replyToPeer synchronously on the single
orchestrator-inbound-dispatch goroutine, so one slow platform call stalls
inbound dispatch for every session and identity. Worst for the
orchestrator-mediated external channel, whose Send is an HTTP round-trip back
to the orchestrator with a 10 s timeout. Now fire-and-forget with recover,
mirroring emitToolUpdate. Uses context.WithoutCancel(ctx) rather than
r.svc.ctx — svc.ctx is only set in Service.Start and a nil ctx panics inside
replyToPeer.

## TUI affordances (tui/components/chat/editor.go, tui/page/chat.go)

- ctrl+e reported "Agent is working, please wait..." when the queue was
  non-empty but the session idle (drain halted on an error). The guard itself
  is correct — openEditor's result goes out via SendMsg -> sendMessage, which
  dispatches directly and would jump queued messages — so only the message
  changed: the queue-only case now says so and points at ctrl+x.
- A drain worker halting for a session the user is not viewing produced no
  signal at all. A halt is terminal, the banner looks identical to a healthy
  mid-drain queue, and the event is never re-emitted, so the error is now
  surfaced for any session, prefixed with its id.

## Tests

- app: dequeueOrRelease atomicity, enqueue-during-worker-exit stress
- bridge/service: no redundant same-position edits, update on position change,
  no false resolve on run failure or budget expiry, ack survives a re-queue
  cycle; the eviction-notice test now polls for the async send
- tui/page (first tests in this package): busy-race enqueues rather than
  drops, non-busy error still surfaces

busyRetryBudget becomes a var so tests can shrink it, matching busyAckThreshold.

No change to the /router/inbound wire contract, bridge.Inbound, or the 202/429
semantics — the orchestrator forwarder is untouched. liveAcks is per-dispatcher
and keyed channel|identity|peerID, and the external adapter does not implement
QueuedAcknowledger, so the ack changes are inert for orchestrator-mediated peers.
The drain notifier was wired as a bare `program.Send(e)`. `Program.Send`
writes to an unbuffered channel that only the Bubble Tea event loop reads,
and that loop is blocked inside `Model.Update` for the duration of the
update. Both queue entry points — the editor's enqueue path
(`EnqueueMessage`) and the ctrl+x discard path (`DiscardQueue`) — notify
synchronously from `Update`, so submitting a message while the agent was
busy blocked the update goroutine forever. Nothing could recover it:
`Quit` is also a `Send`, so even the quit path was wedged.

Forward DrainEvents through a buffered channel drained by a single
goroutine (same shape as `setupSubscriptions`), so notification never
blocks the caller and event order is preserved. On buffer overflow the
event is handed to a goroutine rather than blocking, keeping halted-drain
error events deliverable.

Tests: forwarder never blocks a stalled consumer, preserves FIFO order,
tolerates stop/idempotent stop, plus an end-to-end probe that notifies
from inside `Update` on a real `tea.Program` (hangs on the old wiring).
Auto-approve was enough to make the question tool answer itself with the
first (recommended) option. That silently stole the decision from users
who enable auto-approve in the TUI purely to skip permission dialogs —
auto-approve is about tool permissions, not about deciding for the human.

Gate the short-circuit on a new session-scoped mark instead:
permission.Service.MarkUnattendedSession, set only where no surface could
answer a question —

  - `opencode -p` headless runs (cmd/flow.go)
  - flow steps (flow.Service.runStep); interactive steps are marked too,
    their own session keeps the interactive marker the tool checks first
  - cron jobs firing on a session nothing is watching, re-evaluated per
    fire with the scheduler's existing TUI-active / bridge-bound predicate
    so a blocking question cannot wedge the session's agent lock

IsUnattendedSession resolves through the LinkSession chain, so task-tool
subagents inherit their caller's verdict and a subagent asking inside a
flow step still auto-answers rather than hanging on a prompt nobody sees.
The mark is cleared when a human attaches after the fact: the bridge's
`/session <prefix>` switch and the TUI selecting a session.

TUI (with or without auto-approve), chat bridge and API sessions now all
get the real prompt.
Two follow-ups on the queue feature:

Viewer (ctrl+g). The banner told users messages were queued but not WHAT
was queued, and the queue is in-memory only, so there was no way to check
before delivery. dialog.QueueDialog lists the queue in delivery order,
reading App.QueuedMessages at render time so it tracks an ongoing drain
instead of showing already-sent text. Previews are single-line, ANSI-
stripped and truncated so a large paste cannot break the layout, and the
dialog handles ctrl+x itself (it swallows key presses, so the chat page's
binding never fires while it is open). ctrl+g was picked because it is not
an alias of another key without the kitty keyboard protocol, unlike ctrl+m
(enter) and ctrl+i (tab).

Footer. The queue banner was rendered as an extra row between the working
spinner and the help bar, but the chat view's height budget is viewport +
2 rows, so the container's MaxHeight silently clipped the help bar — and
it stayed gone for the rest of the session. The banner and the help bar
now share one row via footer(), truncated rather than wrapped, with the
"esc to cancel" hint folded into the banner while a run is in flight.
Conflict in internal/tui/components/chat/editor.go: main's chat-editor
wrapping fix (#49) moved textarea sizing out of View into
syncTextareaHeight/promptColumnWidth, while this branch added the
enqueue-on-submit path to send() and the queue hint to the busy-editor
warning. Resolution keeps both: every branch that resets m.attachments —
including the new enqueue path — calls syncTextareaHeight, and View no
longer sizes the textarea.
@obukhovaa
obukhovaa merged commit 1d3d079 into main Sep 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant