From a67c73f312c5ee0842a01c3eb7e2a0bb95d8f9d0 Mon Sep 17 00:00:00 2001 From: Key KQ Date: Fri, 7 Aug 2026 21:06:45 +0700 Subject: [PATCH] feat(webui): one keyboard shortcut registry with a discoverable ? overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #137. The console already had real key handling — Enter/Shift+Enter in the composer, Escape's abort/recover chain, Cmd/Ctrl+Shift+O for New chat, arrow navigation in the slash menu and the session switcher — spread across five files as ad-hoc document listeners. Nothing could enumerate them, so nothing documented them, and each new binding re-litigated the same overlay and editable-target guards. Registry. Components declare a ShortcutSpec via useKeyboardShortcut; the provider owns the single document listener, the editable-target guard and the overlay guard. Keys that must stay bound to their own element (composer, slash menu, session switcher — they need the event before the document and have to consume it) register documentationOnly via useShortcutDocs, so the list is complete without relocating any behaviour. Guards. Overlay detection was a hardcoded selector list per handler, already stale: '.modal-backdrop' no longer exists outside a test fixture, and no list knew about the tokenised *-modal__overlay dialogs, the blocking approval prompt included. Layers now register themselves — ModalShell covers every tokenised dialog, and the session popover, which is not a ModalShell, opts in explicitly. Discoverability. '?' opens the sheet, and closes it again — the toggle has to be checked before the overlay guard, because the sheet is itself a layer. '?' stays inert in editable fields, and the composer autofocuses on desktop, so a key-only affordance would not be discoverable at all; the sidebar carries a visible entry point that also works on touch. Labels. comboParts is the only place a combo becomes a keycap, so the New chat tooltip and the sheet cannot drift. #131's hardcoded '⌘⇧O' was wrong on Windows and Linux. Layering. The sheet sits at z-index 60 with the other route dialogs, not on --z-critical-approval, which the approval prompt owns alone and must keep. Pinned by a CSS contract test alongside the existing one. Matching. Combos are matched against both e.key and e.code, so '?' works wherever the layout puts it while Cmd+Shift+O keeps the layout independence the previous e.code === 'KeyO' check gave it. Bundle. The sheet loads lazily: the provider mounts at boot, and pulling ModalShell + motion into the eager tree cost +44 KiB gzip of initial JS for a panel most sessions never open. Split out, the change is +2.0 KiB (132.8 -> 134.8 KiB, budget 180). Gate: tsc, eslint, prettier clean; 1758 frontend tests across 84 files; Control UI build within budget. --- frontend/src/app/AppShell.test.tsx | 42 +++ frontend/src/app/AppShell.tsx | 38 +++ frontend/src/app/providers.tsx | 5 +- frontend/src/components/KeyboardShortcuts.css | 126 ++++++++ .../src/components/KeyboardShortcuts.test.tsx | 297 ++++++++++++++++++ frontend/src/components/KeyboardShortcuts.tsx | 256 +++++++++++++++ frontend/src/components/ModalShell.tsx | 6 + frontend/src/components/ShortcutOverlay.tsx | 81 +++++ .../components/keyboard-shortcuts-css.test.ts | 28 ++ frontend/src/components/overlay-layer.ts | 39 +++ frontend/src/components/shortcut-combo.ts | 186 +++++++++++ frontend/src/styles/control-surface.css | 7 +- frontend/src/views/chat/ChatPage.test.tsx | 47 ++- frontend/src/views/chat/ChatPage.tsx | 70 +++-- frontend/src/views/chat/Composer.tsx | 23 ++ frontend/src/views/chat/SessionChip.tsx | 16 + frontend/src/views/chat/SlashMenu.tsx | 13 + 17 files changed, 1235 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/KeyboardShortcuts.css create mode 100644 frontend/src/components/KeyboardShortcuts.test.tsx create mode 100644 frontend/src/components/KeyboardShortcuts.tsx create mode 100644 frontend/src/components/ShortcutOverlay.tsx create mode 100644 frontend/src/components/keyboard-shortcuts-css.test.ts create mode 100644 frontend/src/components/overlay-layer.ts create mode 100644 frontend/src/components/shortcut-combo.ts diff --git a/frontend/src/app/AppShell.test.tsx b/frontend/src/app/AppShell.test.tsx index 56f32b0a..52e90a8e 100644 --- a/frontend/src/app/AppShell.test.tsx +++ b/frontend/src/app/AppShell.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { routeChildren, VIEWS } from './routes' import { AppProviders } from './providers' import { AppShell, SIDEBAR_COLLAPSED_STORAGE_KEY } from './AppShell' +import { KeyboardShortcutProvider } from '@/components/KeyboardShortcuts' import { useConnection } from '@/stores/connection' import { useApprovals } from '@/services/approval-monitor' import type { Bootstrap } from '@/lib/bootstrap' @@ -652,3 +653,44 @@ describe('AppProviders effect cleanup', () => { expect(() => second.unmount()).not.toThrow() }) }) + +// #137 — the cheat-sheet has to be reachable without a keyboard. '?' is +// deliberately inert while focus is in an editable field (the chat composer +// autofocuses on desktop), and touch devices have no keyboard at all, so the +// shell carries a visible entry point. +describe('keyboard shortcuts entry point', () => { + function renderShellAt(path: string) { + const router = createMemoryRouter([{ element: , children: routeChildren }], { + initialEntries: [path], + }) + return render( + + + + + , + ) + } + + it('opens the shortcut overlay from the sidebar', async () => { + renderShellAt('/overview') + const button = screen.getByRole('button', { name: 'Keyboard shortcuts' }) + expect(button).toHaveAttribute('title', expect.stringContaining('?')) + + fireEvent.click(button) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Show this list')).toBeInTheDocument() + }) + + it('renders nothing rather than a dead button when no registry is mounted', () => { + const router = createMemoryRouter([{ element: , children: routeChildren }], { + initialEntries: ['/overview'], + }) + render( + + + , + ) + expect(screen.queryByRole('button', { name: 'Keyboard shortcuts' })).toBeNull() + }) +}) diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx index 5c04c298..ac95f2c2 100644 --- a/frontend/src/app/AppShell.tsx +++ b/frontend/src/app/AppShell.tsx @@ -22,6 +22,7 @@ import { Moon, Network, KeyRound, + Keyboard, Puzzle, Radio, ScrollText, @@ -34,6 +35,12 @@ import { import { Toaster } from '@/components/ui/sonner' import { Button } from '@/components/ui/button' import { AsciiField } from '@/components/AsciiField' +import { + formatCombo, + HELP_COMBO, + useKeyboardShortcut, + useShortcutOverlay, +} from '@/components/KeyboardShortcuts' import { useTheme } from '@/stores/theme' import { useConnection } from '@/stores/connection' import { useApprovals } from '@/services/approval-monitor' @@ -137,6 +144,20 @@ export function AppShell() { const hasPendingApprovals = useApprovals((s) => s.pending.length > 0) const bootstrap = useBootstrap() const location = useLocation() + const shortcutOverlay = useShortcutOverlay() + + // Documented, not dispatched: the drawer binds Escape itself (below) because + // it has to run while focus is inside the drawer, which the global editable + // guard would otherwise skip. + useKeyboardShortcut( + { + combo: 'escape', + description: 'Close the navigation drawer (mobile)', + category: 'Global', + documentationOnly: true, + }, + () => {}, + ) // app.js:119-171 — mobile sidebar drawer: hamburger toggle, close on // nav-click / outside-click / Escape, aria-expanded + aria-hidden/inert sync. @@ -454,6 +475,23 @@ export function AppShell() { {version ? v{version} : null} + {/* #137: the overlay needs a way in that is not a keyboard shortcut. + '?' is deliberately inert while focus sits in the composer (which + autofocuses on desktop) and on touch devices there is no keyboard + at all, so a discoverability feature reachable only by key would + not be discoverable. */} + {shortcutOverlay.available ? ( + + ) : null}