Skip to content

fix(panel): don't let a dismissed approval silence later requests (#309) - #311

Open
halindrome wants to merge 5 commits into
wxtsky:mainfrom
halindrome:fix/dismiss-silences-requests
Open

fix(panel): don't let a dismissed approval silence later requests (#309)#311
halindrome wants to merge 5 commits into
wxtsky:mainfrom
halindrome:fix/dismiss-silences-requests

Conversation

@halindrome

Copy link
Copy Markdown
Contributor

Fixes #309.

Cause

Dismissing an approval hides it but deliberately leaves it queued, so the CLI stays blocked and the prompt stays recoverable. The card-and-sound trigger in handlePermissionRequest was gated on the queue having been empty:

permissionQueue.append(request)
if permissionQueue.count == 1 { ...show card...; SoundManager.shared.handleEvent("PermissionRequest") }

A dismissed-but-queued entry keeps that count at ≥ 1 forever, so every later request — from any session — landed on count >= 2 and showed nothing, silently, until the dismissed one was resolved some other way.

Queue size was standing in for "is a card already showing". Dismissal is exactly the state where those two stop agreeing.

Change

Gate on the same predicate the display path already uses — nextVisiblePermissionIndex(), which skips dismissed sessions — evaluated before the append.

Showing the card through showNextPending() rather than pointing the surface at this session by hand matters: the approval card renders the head of the queue, so with a dismissed entry still leading it, a hand-set surface would show the dismissed request's content under the new session's name. showNextPending() promotes the first visible request and applies the session-list and Smart Suppress rules that were previously duplicated here.

Deliberately unchanged

The dismissed session's own next request stays hidden; dismissal is per-session and clears when that session's request resolves. That is existing behaviour, and it is now pinned by a test so this fix is not read as altering it. If it should instead re-show, that is a separate call worth its own change.

Tests

Two added to AppStatePermissionFlowTests: a later session's request still raises a card while a dismissed one sits in the queue, and the same-session case stays hidden. The regression test was confirmed to fail against the old gate, and it stops on that failure rather than continuing into an await that would hang.

swift test: 689 tests, 2 skipped, 0 failures.

Branches from main @ 9e3a1eb and is independent of #310.

shanemccarron-maker and others added 4 commits August 12, 2026 10:17
Dismissing an approval hides it but deliberately leaves it queued, so the CLI
stays blocked and the prompt stays recoverable. The card-and-sound trigger in
handlePermissionRequest was gated on `permissionQueue.count == 1`, which a
dismissed entry keeps false forever — so every later permission request, from
every session, arrived with no card and no sound until the dismissed one was
resolved some other way.

Queue size was standing in for "is a card already showing", and dismissal is
exactly the state where those two stop agreeing. The gate now asks the same
predicate the display path uses, nextVisiblePermissionIndex(), which already
skips dismissed sessions.

Showing the card via showNextPending() rather than pointing it at this session
by hand matters: with a dismissed entry still leading the queue, the approval
card renders the head, so a hand-set surface would show the dismissed request's
content under the new session's name.

The dismissed session's own next request stays hidden — dismissal is
per-session and clears when that session's request resolves. That behaviour is
unchanged and now pinned by a test so this fix isn't read as altering it.

Fixes wxtsky#309. 689 tests, 0 failures; the new regression test was confirmed to
fail against the old gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
QA found the first attempt incomplete. `nextVisiblePermissionIndex() != nil`
is "a non-dismissed request is queued", which is still not "a card is on
screen": handlePermissionRequest un-dismisses the session on entry ("session
needs user decision again"), so a dismissed session's NEXT request makes its
own still-queued earlier request count as visible while nothing is displayed.
The silencing then resumed exactly as wxtsky#309 described, one step later.

The gate now asks the surface directly. That also settles what the previous
commit's pinning test got backwards: a dismissal is cleared by the session's
next request arriving, not by the dismissed request resolving, so that
session's next request must bring its card back. The old test asserted the
broken state was correct and its doc comment stated an invariant the code does
not have; both are corrected.

Also strengthened, per QA:
- the cross-session regression test's anti-hang guard checked only the surface;
  an implementation that points the card at the arriving session by hand passes
  that while the dismissed request still leads the queue, so approve resolves
  the wrong one and the await hangs. It now checks the queue head too.
- the new "asking again doesn't silence others" test passed under the broken
  gate as originally written (resolving A's requests surfaces B either way).
  It now asserts a card is on screen at the moment B arrives, which is the
  state that actually differs.

Known limitation, unchanged from main and noted in the code: a card suppressed
by Smart Suppress also leaves a visible request undisplayed, so a second
session's request waits behind it. Closing that needs showNextPending to skip
un-openable entries; tracked separately.

690 tests, 0 failures; both new guards verified red against the incomplete gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
Round 1's surface-only gate had its own hole, found by two lenses
independently: drainPermissions (process exit, or a question arriving for the
session) empties the queue without clearing `surface`, and the card renders
nothing when there is no head request. A bare `.approvalCard` check therefore
blocked on a phantom card and swallowed the next request from every session —
the same wxtsky#309 defect, third variant. The gate now requires both an
.approvalCard surface and a non-empty queue.

Card and sound also stopped sharing a gate. They answer different questions:
the card asks "is one already on screen", the sound asks "does this request
start a new burst" — which is what `count == 1` used to approximate. Sharing
the surface-derived gate made the sound fire per request instead of per burst
whenever no card was open, e.g. under Smart Suppress. The sound now keys off
whether a visible request was already queued, which keeps burst behaviour and
still fixes the dismissed-request silence.

Tests, per QA:
- new: a request arriving under a stale .approvalCard surface must still raise
  a card (verified red against round 1's gate).
- the cross-session test asserted only "not collapsed", the weakest thing
  available; it now names the card it expects.
- the re-raise test could not tell showNextPending's promotion from a
  hand-pointed surface, both requests being from one session; it now asserts
  the card shows the earlier queued request, not the arriving one.

691 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
Round 3 finding. The assertion checks that the card renders the queue head, so
the user is asked about the earlier request — it pins queue order, not which
mechanism set the surface. With both requests from one session no assertion at
that level can tell showNextPending's promotion from a hand-pointed surface;
the cross-session test's head check is what covers that.

Comment only. 691 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Contributor Author

Heads-up on the history of this branch, in case you looked at it before now: the version I first pushed did not actually fix #309. It has since been corrected over three review rounds. Current head is c86fe89.

What was wrong with the first version

It gated on nextVisiblePermissionIndex() != nil — "a non-dismissed request is queued". That is still not "a card is on screen". handlePermissionRequest clears the session's dismissal on entry ("session needs user decision again"), so a dismissed session's next request un-dismisses it, its still-queued earlier request counts as visible again, nothing opens, and the silencing resumes exactly as reported. The bug moved one step; it did not go away.

Worse, the test I shipped alongside it asserted that broken state was correct: I had believed a dismissal was cleared when the dismissed request resolved, when in fact it is cleared when the session's next request arrives. That test is now inverted — a dismissed session's next request must bring its card back.

The second version gated on the surface alone, which had its own hole: drainPermissions (process exit, or a question arriving for that session) empties the queue without clearing surface, and the card renders nothing when there is no head request — so it blocked on a phantom card. The gate now requires both an .approvalCard surface and a non-empty queue.

Card and sound also no longer share a gate. They answer different questions — "is a card already up" versus "does this request start a new burst" — and folding them together silently changed the sound from once-per-burst to once-per-request whenever no card was open.

Each guard was verified by mutating the production condition it names and confirming the test fails without it.

Known behaviour change, flagged rather than buried

An arriving permission can now replace an open question card in states where main would have shown nothing at all. That is the same steal main already performs when the queue is empty, so it is consistent — but it is a real behaviour change in this PR, not a no-op.

One variant is genuinely surprising and I have left it unfixed deliberately: if an older permission is queued-but-undisplayed (Smart Suppress), the card that appears belongs to that older session rather than to the request that triggered it. It is self-healing and nothing is lost. The fix would be another condition on the card gate — which is the exact change shape that produced the defects in both earlier rounds — so I would rather it be a considered follow-up than a fourth patch to the same if.

Also unfixed, and filed separately

The new burst-sound predicate has no test coverage, because SoundManager.shared has no seam to assert against. Adding one is a larger change than this fix, so it is #312 rather than more unreviewed code here.

Happy to squash the four commits if you would prefer a single one.

Two defects found by reviewing this change merged with the wxtsky#308 answer-routing
fix. Both are present on this branch alone, so they belong here rather than in
the integration.

1. The gate asked `!permissionQueue.isEmpty` — a whole-queue question — while
   staleness is per-session. `drainPermissions` empties one SESSION's requests
   without clearing `surface` (a question arriving for that session does this),
   so a card can be left pointing at a session with nothing queued while others
   still wait. The whole-queue test reads that as "a card is up", and every
   later request queues silently behind a card for a session that has no
   pending request. Now asked per session.

2. `dismissedPermissionSessionIds.remove(sessionId)` ran before the replay-dedup
   early return, so a replayed tool_use_id for a dismissed session resurrected
   the request the user had hidden — taking the card the arriving session should
   have received, and counting as a burst already in progress so that session
   lost its sound too. A replay is the same decision arriving twice, not a new
   one. A same-id request with *differing* tool inputs is a distinct request
   (wxtsky#169) and still clears the dismissal; that path is pinned too.

This one is latent on main as well: a resurrected request leads
nextVisiblePermissionIndex(), so any later showNextPending() can raise the card
the user dismissed.

New AppStatePermissionGateTests covers all three cases. Both fixes verified by
mutating the production line each names and confirming the test fails — and the
replay test stops on that failure instead of hanging on the await that follows.

694 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Contributor Author

Updated to 2e497e6 with two more fixes. Both were found by reviewing this change merged with #310 (the #308 answer-routing fix) — but both are defects on this branch alone, so they belong here rather than in that merge.

1. The gate asked a whole-queue question about per-session state.

drainPermissions empties one session's requests without clearing surface — a question arriving for that session does exactly this. So a card can be left pointing at a session that has nothing queued while other sessions still wait. !permissionQueue.isEmpty reads that as "a card is up", and every later request then queues silently behind a card for a session with no pending request. The gate now asks whether that session still has a queued request.

2. A replayed tool_use_id un-dismissed the session.

dismissedPermissionSessionIds.remove(sessionId) ran before the replay-dedup early return, so replaying a request for a dismissed session resurrected the one the user had hidden — taking the card the arriving session should have received, and counting as a burst already in progress so that session lost its sound too. A replay is the same decision arriving twice, not a new one; the un-dismiss now happens only on the path that actually enqueues.

This second one is latent on main independently of this PR: a resurrected request leads nextVisiblePermissionIndex(), so any later showNextPending() can raise the card the user dismissed. It is fixed here because this is the PR that touches dismissal behaviour, but it is not caused by it.

A same-id request with differing tool inputs is a distinct request rather than a replay (#169), so it still clears the dismissal — that path is pinned by its own test.

AppStatePermissionGateTests covers all three cases. Both fixes were verified by mutating the production line each one names and confirming the test fails without it; 694 tests, 0 failures.

Note for reviewing order: #310 and this PR are independent and each stands alone, but the two interact once both are applied. I have a local merge of the pair that has been through two further review rounds — happy to share what that turned up if you plan to take both.

nguyenvanduocit pushed a commit to nguyenvanduocit/CodeIsland that referenced this pull request Aug 13, 2026
…ug added

Two new upstream bugs found (wxtsky/CodeIsland issues wxtsky#308/wxtsky#309, PRs wxtsky#310/wxtsky#311, Aug 12):
- T-083 (new, high, S): answer routing bug — approve/deny/answer always resolves
  queue.removeFirst() not the card's session; confirmed in our RequestQueueService.swift
- T-031 criteria updated: implementing dismiss button MUST fix the enqueuePermission
  count==1 gate simultaneously (PR wxtsky#311) to avoid silencing later permission requests
- Issue wxtsky#312 (SoundManager testability) noted, not separately tracked
- vibeisland issue wxtsky#216 (keychain dump) is vibe-island-specific, skip

Upstream v1.0.31 remains HEAD; PRs wxtsky#305, wxtsky#295, wxtsky#285 still open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDtfgMUiL8o81HJiWtqB2D
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.

Dismissing an approval card silences every later permission request, from all sessions

2 participants