Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions frontend/src/app/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: <AppShell />, children: routeChildren }], {
initialEntries: [path],
})
return render(
<QueryClientProvider client={new QueryClient()}>
<KeyboardShortcutProvider>
<RouterProvider router={router} />
</KeyboardShortcutProvider>
</QueryClientProvider>,
)
}

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: <AppShell />, children: routeChildren }], {
initialEntries: ['/overview'],
})
render(
<QueryClientProvider client={new QueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
expect(screen.queryByRole('button', { name: 'Keyboard shortcuts' })).toBeNull()
})
})
38 changes: 38 additions & 0 deletions frontend/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Moon,
Network,
KeyRound,
Keyboard,
Puzzle,
Radio,
ScrollText,
Expand All @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -454,6 +475,23 @@ export function AppShell() {
</span>
{version ? <span className="shell-sidebar__version ml-auto">v{version}</span> : null}
</div>
{/* #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 ? (
<Button
variant="ghost"
size="icon-sm"
className="shell-sidebar__shortcuts"
onClick={shortcutOverlay.open}
title={`Keyboard shortcuts (${formatCombo(HELP_COMBO)})`}
aria-label="Keyboard shortcuts"
>
<Keyboard className="size-4" />
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useConnection } from '@/stores/connection'
import { initTheme } from '@/stores/theme'
import { approvalMonitor } from '@/services/approval-monitor'
import type { RpcState } from '@/lib/ws-rpc'
import { KeyboardShortcutProvider } from '@/components/KeyboardShortcuts'

const WS_URL_KEY = 'agentos.wsUrl'
const WS_TOKEN_KEY = 'agentos.wsToken'
Expand Down Expand Up @@ -89,7 +90,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
return (
<BootstrapContext.Provider value={bootstrap}>
<RpcContext.Provider value={rpc}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
<KeyboardShortcutProvider>{children}</KeyboardShortcutProvider>
</QueryClientProvider>
</RpcContext.Provider>
</BootstrapContext.Provider>
)
Expand Down
126 changes: 126 additions & 0 deletions frontend/src/components/KeyboardShortcuts.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
@layer components {
/* The shortcut cheat-sheet is a route-owned dialog, so it sits on the same
layer as the agents / sessions / skills / env dialogs (60) — NOT on
--z-critical-approval, which is reserved for the blocking approval prompt
and must stay above everything, this sheet included. */
.shortcut-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: color-mix(in srgb, var(--background) 72%, transparent);
backdrop-filter: blur(2px);
}

.shortcut-sheet {
width: 100%;
max-width: 520px;
max-height: calc(100dvh - 48px);
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
}

.shortcut-sheet__head {
flex: none;
}

.shortcut-sheet__title {
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
}

.shortcut-sheet__close {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--dim);
cursor: pointer;
transition:
color 150ms ease,
background-color 150ms ease;
}

.shortcut-sheet__close:hover {
color: var(--foreground);
background: var(--elevated);
}

.shortcut-sheet__body {
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}

.shortcut-sheet__group {
display: flex;
flex-direction: column;
gap: 2px;
}

.shortcut-sheet__group-title {
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--dim);
}

.shortcut-sheet__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 6px 0;
}

.shortcut-sheet__row + .shortcut-sheet__row {
border-top: 1px solid var(--hairline);
}

.shortcut-sheet__desc {
min-width: 0;
font-size: 0.8125rem;
color: var(--foreground);
}

.shortcut-sheet__keys {
display: inline-flex;
flex: none;
align-items: center;
gap: 3px;
}

.shortcut-sheet__kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border: 1px solid var(--hairline);
border-radius: var(--radius-sm);
background: var(--elevated);
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 650;
color: var(--foreground);
}

.shortcut-sheet__plus {
font-size: 0.6875rem;
color: var(--dim);
}
}
Loading
Loading