Approval card ignores the first click, buttons only respond after the poll re-syncs the approval_id
#7,091 opened on Aug 16, 2026
Repository metrics
- Stars
- (17,426 stars)
- PR merge metrics
- (Avg merge 14h 31m) (314 merged PRs in 30d)
Description
Summary
On the local in-process (non-gateway) backend, an approval card ignores the first click on any action button. The first click does nothing and shows an "Approval response not accepted." toast. The button only works after a short delay (about 1.5 to 2 seconds). If the user clicks rapidly, several clicks are needed before one registers. A single click made after that delay works on the first try. The issue still happens after a hard refresh, so it is not a stale cached bundle.
Environment
- Version: exp-v0.52.228 (commit dc3bf44e, 2026-08-14)
- Backend: default local in-process WebUI chat runtime, no gateway
- Browser: Firefox 153.0.4 (also happens after a hard refresh)
- Config: approvals.mode: manual
To reproduce
- Trigger any guarded command so an approval card appears. For example, run a destructive command like
rm -rf <throwaway-dir>withapprovals.mode: manual. - Click "Allow once" (or Allow session / Always allow / Deny) immediately.
- Expected: the click resolves the approval and the agent resumes.
- Actual: the first click does nothing and a toast appears with "Approval response not accepted."
- Click the button rapidly. Several clicks are needed before one works, usually after about 1.5 to 2 seconds.
- A single click made after that delay works on the first try.
Secondary repro: multiple queued approvals
When more than one approval is queued for the same session (the card shows a "1 of N pending" counter), the same stale-id problem gets worse. Approving the currently shown head can leave the card re-rendered with the next entry's id, and a click that lands during that re-render window is rejected again, so it needs another click. This makes fast workflow on queued approvals error-prone.
Behavior observed in server logs
During fast clicking, the server receives a burst of POST /api/approval/respond requests within the same second (for example, four requests at 17:46:06-07). Each returns HTTP 200. A 200 status is not a success here. On the local path the server sends the rejection as a JSON body of {ok:false} with an HTTP 200 status. The client only treats a click as accepted when result.ok is truthy, so these are all rejections. This shows the click handler is firing and the network round-trip is fast (about 4 to 10 ms). The clicks are rejected because the submitted approval_id is stale, not because the handler failed to load.
Root cause analysis
The approval card's id is fed by two independent sources that can carry different approval_id values, and the client does not reconcile them before the click.
- The SSE
approvalevent handler atstatic/messages.js(about line 5921) callsshowApprovalForSession(activeSid, d, d.pending_count || 1). - The 1.5 second fallback poll at
static/messages.js(about line 7684) callsshowApprovalForSession(sid, data.pending, ...). Both end inshowApprovalCard(static/messages.js, about line 7469), which sets_approvalCurrentId = pending.approval_id. When the two sources deliver a different id, or the card's id has not yet been reconciled to the server's current pending head, the card's_approvalCurrentIdis stale at click time.respondApproval(static/messages.js, about line 7594) then posts that stale id to/api/approval/respond. On the local path,_handle_approval_respond(api/routes.py) returnsok:falsewith HTTP 200 whenever the submitted id does not match the live pending head while a different approval is still pending. This is the deliberate #527 guard, so a stale click can never approve the wrong command. The failed click invokes_restoreFailedApprovalResponse, which re-renders the card via_renderPendingApprovalForActiveSession. The fallback poll then corrects_approvalCurrentIdto the authoritative id, so the next click succeeds. The about 1.5 second poll interval (plus render) is what the user perceives as the "about 2 seconds until the button responds."
Expected behavior
- A single click on any approval button should resolve the approval on the first try, no matter how quickly it is made after the card appears.
- The card must not require a wasted, guaranteed-rejected first click to fix itself. The client should reconcile
_approvalCurrentIdagainst the authoritative pending head (for example/api/approval/pending) at or before click time, so the value submitted byrespondApprovalalways matches what the server expects. - Ideally, the SSE event path and the fallback poll should deliver a consistent, server-authoritative
approval_idso the card never renders with a stale id in the first place. - For the queued case, advancing to the next approval should update the card's id synchronously so the next click is never rejected during the re-render window.
Suggested area to investigate
static/messages.js:showApprovalCard/showApprovalForSession: ensure a single, authoritativeapproval_idis used, and reconcile it against/api/approval/pendingbefore the click handler reads it.respondApproval/_restoreFailedApprovalResponse: avoid depending on a post-rejection re-render to correct the id.
api/routes.py_handle_approval_respond: consider whether the local path can surface the stale-id case as something the client can fix immediately, without requiring a second user click, while keeping the #527 guard.