Skip to content

feat(composer): keep the composer usable while an approval is pending - #845

Merged
astyfx merged 7 commits into
mainfrom
feat/composer-redirect-while-approval-pending
Oct 28, 2026
Merged

astyfx merged 7 commits into
mainfrom
feat/composer-redirect-while-approval-pending

Conversation

@astyfx

@astyfx astyfx commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Summary

While a running turn waits on approvals, the composer now stays usable. Sending a message declines every pending approval and then steers the message into the same turn. Before this change, any pending approval disabled the composer until each request was answered on the card.

Behavior

  • Composer: stays enabled while approvals wait on a running turn. The placeholder reads "Send to decline pending approvals and redirect the agent…". The send button reads Decline and steer. When the turn's provider cannot take a steer, it reads Decline and queue next turn and the placeholder says the message will be queued.
  • Send: declines every pending approval in the task one at a time, then steers the message into the turn. This ignores the Enter/Tab queue-or-steer setting and Mid-Turn Steering: the turn is waiting on the user, so the message answers it. The Settings copy for Mid-Turn Steering now says so (en/ko).
  • Decline fails: nothing is sent, the draft stays, and a toast shows the reason.
  • Draft edited while declines are in flight: the older text is not sent, and a toast says so.
  • Approval the provider can no longer answer (no live session for the turn, or an unknown request id): closed as approval-interrupted and skipped, so it does not block the send. Main reports this as unanswerable: true on provider:respond-approval. Timeouts stay failures because the provider may still accept the answer.
  • Things that leave approvals alone: /aside, queued-message edits (the queue shelf stays disabled, as before), and messages the send path refuses. The steer path calls the decline callback only after every other steer check has passed. The queue path checks the Stave Auto refusal before declining.
  • Steer refused after the declines (for example, the turn finished meanwhile): the message is queued, or it starts the next turn. It is never dropped.
  • Unchanged: the approval card's Allow once, Deny, and Deny & guide, its Enter and Tab window shortcuts, and pending questions. Enter and Tab in an empty composer also act on the card, so the card's "↵ to allow once" hint stays true when focus is in the composer.

Provider coverage

Provider Decline (existing runtime path) Then How verified
Claude canUseTool resolves deny without interrupt, so the turn continues Steer pushed into the SDK input queue Code read (claude-sdk-runtime.ts approval responder and steer responder). Store test with the bridge mocked
Codex decision: "decline" / action: "decline" (not cancel, which ends the turn); the JSON-RPC response is written before turn/steer turn/steer on the same turn Code read (codex-app-server-runtime.ts). Store test with the bridge mocked
Kiro ACP reject_once _session/steer Code read (acp-provider-runtime.ts). Capability-to-mode test
Cursor ACP reject_once Queued for the next turn (no steer method in ACP) Code read. Capability-to-mode test. Store test of the queue path
OpenCode ACP reject_once Queued for the next turn (no steering) Code read. Capability-to-mode test

No live provider run was done; the provider side is verified by code reading and mocked-bridge tests only.

Verification

  • bun run lint: pass (warnings only, none in touched code)
  • bun run typecheck: pass
  • bun run check:i18n: pass
  • bun run check:max-lines-ratchet: pass. ChatInputComposer.tsx went from 1898 to 1874 lines, prompt-input.tsx from 900 to 885, and app-store-send-user-message.ts is at 1667 lines (limit 1669). The ratchet was not raised.
  • bun run check:design-system, check:style-channel, check:switch-exhaustiveness, check:reliability-gates, check:doc-paths: pass
  • bun run build:pages: pass
  • Focused tests, all pass:
    • tests/approval-redirect-send.test.ts (15): success, decline failure, draft change, unanswerable skip, refusal before decline, steer unavailable → queue, steer rejected after decline → queue, non-steering provider, queue refusal, unconfirmed steer, no pending approvals, mode per provider
    • tests/approval-redirect-store.test.ts (8): real store, mocked bridge. Covers the order of declines before the steer, enabled: true with steering off, failed decline, draft change, an unanswerable request closed as interrupted, the queue path, a rejected steer that gets queued, the card's Decline still reporting failures, and a plain send still blocked
    • tests/prompt-input-submit-labels.test.ts (4)
    • tests/provider-runtime-respond-miss.test.ts (4)
    • Existing tests still pass: bridge-persistence-regression, fleet-inline-interaction, approval-request-routing, chat-input-approval-queue, provider-runtime-steer
  • Playwright tests/e2e/composer-approval-request.e2e.ts: 6/6 pass, including 3 new tests on the real app composer (decline both, then steer; /aside leaves approvals waiting; Enter in the empty composer allows)
  • bun run test:isolated: pass (1042 files: 965 shared, 77 isolated)
  • Headless Chrome on the composer-frame preview (case=needs-input): checked light, dark, Korean, the queue variant and a 420px composer. Before the change the editor had contenteditable=false. After it, the editor is editable and the send button reads "Decline and steer".
  • Late-approval race fix (1bfcab3): the redirect re-reads pending approvals after each decline (bounded at 32, each request tried once) and fails with nothing sent if one stays pending; a send refused by a waiting approval now reports approval-pending and the composer explains it. New tests: 6 in approval-redirect-send.test.ts (21 total), 3 in approval-redirect-store.test.ts (11 total), 8 of them fail on the previous code. lint, typecheck, check:i18n, check:max-lines-ratchet, check:switch-exhaustiveness, check:doc-paths, build:pages: pass.
  • Stop-cancels-redirect fix (b86e8f4): Stop on the targeted turn while a redirect is declining or steering now ends it with nothing sent, the draft kept and no toast. The stop is read from retainedTurnActivityByTask (outcome stopped) for the turn captured when the redirect starts. A turn that ends on its own still gets the message as the next turn. New tests: 4 in approval-redirect-send.test.ts (25 total) and 2 in approval-redirect-store.test.ts (13 total). The real-store test for Stop during the decline (steer and queue) fails on the previous code with a second turn started. lint, typecheck, check:i18n, check:max-lines-ratchet, check:switch-exhaustiveness, check:doc-paths, build:pages: pass.
  • Stop-latch fix (20f1eaa): while a redirect runs, it subscribes to the store and latches a Stop on the targeted turn as soon as it is recorded, so evicting the 8-task retained-activity cache no longer loses the stop. The subscription is disposed in a finally. New real-store test: Stop during the decline, then 8 other tasks retire turns (the stopped record is evicted). Steer and queue both send nothing, start no new turn and keep the draft. On b86e8f4 this test fails: a second turn starts. Every store test now asserts no store subscription is left after the redirect. approval-redirect-store.test.ts 14 pass, approval-redirect-send.test.ts 25 pass. lint, typecheck, check:i18n, check:max-lines-ratchet, check:switch-exhaustiveness, check:doc-paths, build:pages: pass.

Risks

  • Steering with Mid-Turn Steering off is a deliberate exception, limited to sends that decline approvals. Maintainers may prefer to honor the switch, which would mean decline and then queue.
  • Unanswerable requests are detected only on the direct provider path. Approvals answered through the host (host-owned turns) still treat any failure as fatal, which is the conservative choice.
  • The window Enter shortcut no longer fires while the focused composer holds text: Enter then sends the redirect.

🤖 Generated with Claude Code

astyfx and others added 3 commits October 28, 2026 09:10
…swer

A respond-approval call now carries `unanswerable: true` when the turn has
no live session or the runtime no longer knows the request id, so the
renderer can tell a stale request from a delivery failure. Timeouts stay
plain failures because the provider may still accept the answer. The steer
response keeps its existing shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`resolveApproval` now resolves with how the answer ended (delivered,
skipped or failed), and with `onUnanswerable: "close"` it closes a request
the provider can no longer answer as interrupted instead of failing.

`sendUserMessage` accepts `declinePendingApprovals`: a steer into a running
turn may pass the pending-approval guard, and the callback runs only after
every other steer check has passed, so a refused message leaves the
approvals alone. Such a redirect steers even with Mid-Turn Steering off.

`sendWithApprovalRedirect` declines the approvals one at a time, stops on
the first failure, drops text edited meanwhile, and queues the message
when the turn cannot take a steer instead of dropping it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a running turn waits on approvals, the composer stays enabled. Its
placeholder says that sending declines the pending approvals and redirects
the agent, and the send button reads "Decline and steer" (or "Decline and
queue next turn" when the turn's provider cannot take a steer). Sending
declines every pending approval, then steers the message into the same
turn; a failed decline sends nothing and keeps the draft.

`/aside`, queued-message edits and messages the send path refuses leave the
approvals alone. The approval card and its Enter/Tab shortcuts behave as
before, and Enter/Tab in an empty composer act on the card too. The window
shortcuts move into `useApprovalWindowShortcuts`, and the placeholder and
send labels into `prompt-input-submit-labels.ts`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@astyfx
astyfx requested a review from a team as a code owner October 28, 2026 00:11
@astyfx
astyfx requested review from heath-s and png-1105 and removed request for a team October 28, 2026 00:11
@upwind-code-us

upwind-code-us Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Upwind Upwind IaC Scan - ✅ Passed

0 newly introduced misconfigurations · 0 resolved · 0 total in this PR vs main

View full analysis in Upwind Console →

Scan completed in 4s

Scan history (5 scans)
Commit Scanned at New Resolved Net
9d287c0 2026-10-28 00:11 UTC 0 0 0
1bfcab3 2026-10-28 00:50 UTC 0 0 0
b86e8f4 2026-10-28 00:56 UTC 0 0 0
20f1eaa 2026-10-28 01:15 UTC 0 0 0
9351395 < 2026-10-28 01:42 UTC 0 0 0

Last scanned: 9351395 · 2026-10-28 01:42 UTC

@heath-s heath-s left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

astyfx and others added 3 commits October 28, 2026 09:50
The redirect read the pending approvals once, so an approval that arrived
while an earlier one was being declined (a parallel tool call, a helper)
stayed pending while the message was steered and the draft cleared. On the
queue path the send was refused with no explanation.

`declinePendingApprovals` now reads the list again after each decline and
declines every request it has not tried yet, at most 32. A request still
pending after its decline, or approvals that keep arriving past the bound,
fail the redirect: nothing is sent and the draft stays.

A send refused because an approval waits now reports
`reason: "approval-pending"`, and the composer explains it instead of
staying silent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the user pressed Stop while a redirect was declining approvals, the
steer into the stopped turn was refused and the redirect fell back to the
queue path. With no turn running, that started a new turn and cleared the
draft.

The redirect now captures the turn it targets when it starts. After the
declines, it checks whether that turn was recorded as stopped
(`retainedTurnActivityByTask` with outcome `stopped`, which Stop writes).
If so, it ends with `turn-stopped`: nothing is steered, queued or started,
the draft stays, and no toast is shown. A turn that ends on its own is
recorded as completed, so its redirect still delivers the message as the
next turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The redirect read the stop from the retained turn activity cache when it
needed it. That cache keeps the latest eight tasks only. If enough other
tasks finished turns between the Stop and the end of the decline, the
`stopped` record was evicted, the stop went unseen, and the redirect started
a new turn and cleared the draft.

While a redirect runs, `submitApprovalRedirect` now subscribes to the store
and latches the stop as soon as the targeted turn is recorded as stopped.
Per update the listener compares only this task's retained entry reference.
A stop already recorded before the subscription still counts, and the
subscription is always disposed in a `finally`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@astyfx
astyfx enabled auto-merge (squash) October 28, 2026 01:32
Bring in #839, #840, #841, #842, #844 and #846.

- Placeholder: one resolver. Main's `resolvePromptPlaceholder` now also
  takes `approvalRedirect` and returns the decline-and-steer or
  decline-and-queue keys. The duplicate `resolvePromptInputPlaceholder` is
  removed, and its tests now go through `resolvePromptPlaceholder`.
- Approval keys: main's `useComposerApprovalShortcuts` now returns the
  Enter/Tab handler the empty editor uses. `use-approval-window-shortcuts.ts`
  is removed.
- The composer's redirect-mode derivation moves into
  `resolveComposerApprovalRedirectMode`, which keeps `ChatInputComposer.tsx`
  and `prompt-input.tsx` within main's lowered line limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@astyfx
astyfx merged commit 4f97c9b into main Oct 28, 2026
6 checks passed
@astyfx
astyfx deleted the feat/composer-redirect-while-approval-pending branch October 28, 2026 01:42
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.

2 participants