fix: adopt the shared menu keyboard contract in ChannelPage (#6269) - #6547
Conversation
The agents-panel listen-mode dropdown declares role="menu" — which promises WAI-ARIA arrow-key item navigation — but wired no keyboard handling at all: the defect class #6231 fixed on its five inventoried surfaces via the shared useMenuKeyboard hook; ChannelPage was outside that inventory (#6269, the ChannelPage analogue of #6266). Wire the menu onto the merged hook (arrow walk with wrap, Home/End, Tab containment, document IME latch), mark the rows role=menuitemradio with aria-checked so the current mode is perceivable programmatically rather than by colour alone, name the group (aria-label, new catalog key) so the announced radio state has a referent, and restore focus to the trigger on explicit dismissal (Escape / row activation), matching the MicSourceMenu posture from #6267. Outside-click dismissal is left alone, per the same posture. Focus entry is host-owned with preventScroll: this menu is not portalled and lives inside the agents rail's scroll container, so the hook's default entry would scroll the rail on every open. Co-authored-by: Kiro Crew <kirocrew@users.noreply.github.com>
UX Review (Fable 5) — ✅ PASSUX-level review of The diff is a scoped keyboard-contract adoption: shared UX-Verdict: PASS Keyboard and screen-reader users can now actually operate the listen-mode menu, wired identically to the established sibling surfaces — no new copy, flow, or focus risks. [UX-REVIEWED] bd9f818 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Mechanical adoption of the established [DESIGN-REVIEWED] bd9f818 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks complete. The change is a faithful adoption of the shared hook; the one undeclared bit is two ARIA attributes on the trigger, and the five unadopted sibling menus are exactly the set the description declares deferred. Final review: First-Principles-Verdict: PASS A declared symptom-class fix landed at cause level: the shared menu contract adopted, not re-spelled, with every divergence from the reference surface named and derived. What this change shipsIntent: let a keyboard or screen-reader user actually operate the agents-panel listen-mode menu — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] bd9f818 |
Problem / Motivation
The agents-panel listen-mode dropdown in
ChannelPage.tsxdeclaresrole="menu"— which promises the WAI-ARIA menu keyboard contract (arrow-key row navigation, Home/End, Tab containment) — but wired no keyboard handling at all. The rows were reachable only by mouse, and a screen-reader user who was just told "menu" got nothing from the arrow keys they reach for first. This is the defect class #6231 fixed on its five inventoried surfaces via the shareduseMenuKeyboardhook (PR #6267); ChannelPage was outside that inventory. Filed by the First Principles review lane on PR #6267 as the ChannelPage analogue of #6266.Why it matters
A keyboard-only or assistive-technology user cannot change an agent's listen mode at all: the trigger opens the menu, but focus never enters it, arrows do nothing, and Tab walks out behind the still-open menu. The current mode was also conveyed only by colour, which assistive tech cannot perceive.
What changed (motivation → approach → change)
Symptom:
role="menu"with zero keyboard wiring. Root cause: the surface predates the shared hook and was outside #6231's inventory. Change — a mechanical adoption of the merged hook, mirroring the closest #6267 surface (MicSourceMenu):role="menu"element (narrower than the wrapper ref, so item discovery never picks up the trigger) and wireuseMenuKeyboard({ enabled: menu, containerRef }): arrow walk with wrap, Home/End, Tab containment, document IME latch.focusFirstOnOpen: false+focus({ preventScroll: true })): this menu is not portalled and sits inside the agents rail's scroll container, so the hook's default entry would scroll the rail on every open, shifting the row the user just clicked out from under the pointer.role="menuitemradio"witharia-checked, and the menu gets an accessible name (aria-label, new catalog keypages.channelPage.listen_mode, translated in all 11 locales + regenerated pseudolocale) so the announced radio state has a referent.Deliberately NOT touched, per the issue's own scope guard: the deferred adoptions the issue inventories (WindowsTitlebarMenu, SessionFlyout, SlotTagPopover, BusySendButton, MarkdownPanel) each need their own judgment.
Tests
New
website/src/test/ChannelPage.menuKeyboard.test.tsx(9 tests, 8 verified red before the wiring):channelUpdateAgent) and hands focus back to the triggermenuitemradiowith the current modearia-checkedManual verification
N/A — unit coverage sufficient: the keyboard contract is real-DOM-focus asserted per keystroke, and the hook itself carries its own suite (
useMenuKeyboard.test.tsx). Focus-trace evidence below in place of screenshots.Screenshots / video
Why no screenshot: keyboard focus behaviour and non-rendering ARIA attributes only — no pixel renders differently, so a still frame cannot show the delta. Evidence is the focus-trace assertion dump (real DOM focus moves asserted per keystroke), same posture as PR #6267:
Related Issues
Fixes #6269
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)