Repository navigation
fix(ui): let a focused Cancel own its Enter in the three list dialogs - #1088
Conversation
Fixes #867. Agent View Mode, Reorder Tabs and Pin Agents each handled Enter on `DialogContent`, called `preventDefault` — which cancels the focused button's native click — and ran the LIST's action instead. So Tab to Cancel, Enter, and the change being abandoned was applied: a view-mode override, a tab reorder, an unpinning. With nothing selected, Pin Agents committed an empty list, which is exactly what `DialogActions`' `confirmDisabled` exists to prevent. `dialog-actions.tsx` already documents the rule — a focused footer button owns its own Enter — and #860 exported `focusedControlOwnsEnter` for it. #862 fixed the same bug in Switch Provider; these are the three the audit of all 36 `DialogContent` consumers found afterwards. Reorder Tabs is the one that shows why the check is on the focused CONTROL rather than on the footer slot: its footer is a plain `<div>`, not a `DialogFooter`. Each dialog's list rows also leave the tab order. Enter handling cannot cover this half: Space clicks the FOCUSED control, so a Tab-focused row acts on a row other than the arrow-driven highlight the dialog is showing. Reorder Tabs' per-row Move up/down buttons stay focusable on purpose — they NAME their target ("Move B up"), so acting on the focused one is unambiguous, and the test says so rather than sweeping them in. Six tests: one per dialog for the bug, one per dialog for the tab order, and three controls proving the LIST still owns Enter when the list has focus — without them, "Enter never acts" would pass and every one of these dialogs would be broken. Reorder Tabs' control runs the whole two-phase model (Enter picks, arrows move, Enter commits) and asserts the reordered ids, because "entered move mode" is invisible from outside. `fireEvent.keyDown(...) === true` is asserted beside each: it means the default was NOT prevented, which is what lets the real browser deliver the button's click. happy-dom does not synthesize that click, so neither assertion alone is the property. Mutation-tested, 6 mutations, no survivors: each guard removed, and each row's `tabIndex` removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce, and the two consumers the audit missed
Review findings, most severe first.
1. Pin Sessions had no `onOpenAutoFocus`, unlike its two siblings. Radix's
FocusScope focuses the first TABBABLE node, and taking the rows out of the
tab order made that node CANCEL — so on a fresh open, the Enter this dialog
documents as "commit" was handed to Cancel and threw the pins away. Nothing
in the dialog moves DOM focus, so no arrow key could rescue it.
2. `tabIndex={-1}` takes a row out of the TAB order and nothing else: Chromium
focuses a button on click whatever its tabindex. A clicked row then held
focus and owned every following Enter, so the dialog's own Enter bowed out
for the rest of its life. In Pin Sessions, Enter re-clicked the row and
looked like it undid the click; in Reorder Tabs, move mode could never be
entered again, in the dialog built for mixed mouse and keyboard use. Rows
now cancel mousedown, which keeps focus on the dialog and still fires the
click.
3. Space had the identical bug, untouched: Pin Sessions `preventDefault`s it
and toggles the HIGHLIGHTED row, so with Cancel focused, Space did not press
Cancel and silently changed a pin the user was not looking at. Guarded with
a SEPARATE predicate, because the two keys do not activate the same
elements — Space presses a button and types into a text field, but on a link
it scrolls. Reusing the Enter predicate would swallow that scroll.
4. The #867 audit was incomplete. `ConversationsPicker` was called safe for
having no footer, but the rule is about the focused CONTROL: its scope and
provider chips are tabbable buttons inside the same `DialogContent`, so
Enter on a chip did not toggle it — it RESUMED the highlighted conversation,
replacing what ran in the focused pane. `AgentActivityModal`'s per-row close
button is inside the scroller that handles the keys and is `opacity-0`
unless its row is selected, so it was an invisible tab stop: Tab to row 2's
close, press Delete, and row 1 closed.
5. Two of the PR's own claims had no coverage, and a third assertion was
backwards. The predicate was never exercised AS a predicate — a far weaker
one (`target !== currentTarget`) passed the whole suite — so there is now a
case firing Enter from a non-control descendant. The Move-button carve-out
the body argued for at length could be deleted unnoticed; it is asserted
now. And the row filter subtracted footer labels by `startsWith`, carrying
two labels no dialog renders, so a row titled "Pin…" would have been
dropped from the assertion rather than failing it — rows are identified by
`role="option"` instead.
6. Rows left the tab order without the ARIA that replaces it. A screen-reader
user could Tab through them before; afterwards the highlight was a CSS class
and nothing else. All three lists are now `role="listbox"` with
`role="option"` rows and `aria-activedescendant`.
7. Enter on a ↑/↓ button only became reachable with #867's guard, so what
follows one is this PR's to own: the move now carries the picked marker with
the row (a row click already did this, the buttons did not), and focus
returns to the dialog, because a move can disable the button that performed
it and a disabled control keeps focus while dropping out of the event path —
arrows and Enter went dead mid-reorder.
Also: the misindented guard in AgentViewModePickerModal, and an unused
`useAppStore` import in AgentActivityModal.
Mutation results, whole features+components suites (954 tests): weaker
predicate 1 failed; tabIndex on the Move buttons 1; drop the autofocus 1; drop
the Space guard 1; drop either row's mousedown guard 1 each; drop
`aria-activedescendant` 1; drop the picker guard 1; drop the activity-row
tabIndex 1; drop the moving-marker carry 1; drop the focus restore 1. One
equivalent mutant: swapping the Space predicate for the Enter one is
undetectable inside these dialogs, since they contain no link — the
distinction is pinned on the predicates directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both HIGH findings are real and both are mine — 1. [HIGH] Pin Sessions auto-focuses Cancel → Enter discards. Accepted exactly as diagnosed: the two siblings had 2. [HIGH] Click focus. Accepted. Rows in all three dialogs now 3. [MEDIUM-HIGH] Space. Accepted, including that it made the comment false. Guarded with a separate 4. [MEDIUM] Incomplete audit. Both fixed.
I did not re-audit all 44 5. [MEDIUM] Three survivors. M-b is the one that mattered and you are right about why: every existing case fires Enter at Cancel or at the dialog root, which the weaker predicate answers identically. There is now a case firing Enter at the listbox — a descendant that is not a control — so the predicate is exercised as a predicate. M-d is asserted (the four Move buttons must have no Post-fix, whole features+components suites (954 tests):
The last one survives and I am reporting it rather than hiding it: inside these three dialogs the two predicates cannot differ, because none of them contains a link. The distinction is real and is pinned on the predicates themselves. Worth recording: my first version of the focus-restore test (finding 7) passed on the broken code. 6. [LOW-MEDIUM] ARIA. Accepted — this is a regression I caused, not a pre-existing gap: those rows were reachable and announced before. All three lists are now 7. [LOW] Move-button edges. Both fixed, and I agree these are this PR's to own since Enter only reaches those buttons because of the guard. 8, 9. Nits. Indentation fixed. The row filter is now
|
Brings in #1092's processLock test-race fix, which is what failed CI here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #867.
The bug
Agent View Mode, Reorder Tabs and Pin Agents each handled Enter on
DialogContent, calledpreventDefault— which cancels the focused button's native click — and ran the list's action instead.confirm()commits the reorder being abandonedThat last one is what
DialogActions'confirmDisabledexists to prevent.dialog-actions.tsxalready documents the rule and #860 exportedfocusedControlOwnsEnterfor exactly this; #862 fixed the same bug in Switch Provider. These are the three the audit of all 44DialogContentconsumers found afterwards.Reorder Tabs shows why the check is on the focused control rather than on the footer slot: its footer is a plain
<div>, not aDialogFooter.The other half
Each dialog's list rows now leave the tab order, because Enter handling cannot cover this: Space clicks the focused control, so a Tab-focused row acts on a row other than the arrow-driven highlight the dialog is showing, whatever the Enter branch does.
Reorder Tabs' per-row Move up / Move down buttons stay focusable on purpose — they name their target (
Move B up), so acting on the focused one is unambiguous. The test excludesaria-labelled buttons and says why, rather than sweeping them in.Tests
Six, plus three controls:
fireEvent.keyDown(...) === trueasserted beside "the handler did not act" — it means the default was not prevented, which is what lets the real browser deliver the button's click; happy-dom does not synthesize it, so neither assertion alone is the property);Mutation results
tabIndexremoved in Agent View ModetabIndexremoved in Reorder TabstabIndexremoved in Pin AgentsNo survivors.
npx tsc -bclean on Node 24.--project renderer --project unit src/renderer/src/features/ src/renderer/src/components/: 938 passed.🤖 Generated with Claude Code
Review round (reviewer requested changes; fixed in
62304098)Two HIGH findings, both mine:
tabIndex={-1}is half of the roving-focus pattern and this PR shipped only that half.onOpenAutoFocus, so with the rows out of the tab order Radix focused Cancel — the very first Enter cancelled and discarded the pins.tabIndex={-1}does not stop CLICK focus. A clicked row held focus and owned every following Enter, so the dialog's own Enter bowed out permanently: in Pin Sessions Enter re-clicked the row, in Reorder Tabs move mode could never be entered again. Rows now cancel mousedown.focusedControlOwnsSpace, because Space activates a button but only scrolls a link.ConversationsPicker(Enter on a filter chip resumed the highlighted conversation) andAgentActivityModal(anopacity-0per-row close button was a tab stop, so Delete closed the selected row while a different row's button had focus).aria-activedescendant; all three lists are now proper listboxes.role="option"instead of subtracting footer labels.Post-fix mutation battery (954 tests) is in the review reply; every previously surviving mutation now fails, with one equivalent mutant reported.