Skip to content
Open
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
4 changes: 3 additions & 1 deletion frontend/src/app/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ describe('index route renders the default view without changing the URL', () =>
const { router } = renderShellAt('/')
// The real ChatPage renders in place (its full-bleed thread region), not the
// Overview view — proving the mobile index default resolves to chat.
await waitFor(() => expect(document.querySelector('.chat-thread')).not.toBeNull())
await waitFor(() => expect(document.querySelector('.chat-thread')).not.toBeNull(), {
timeout: 5000,
})
expect(router.state.location.pathname).toBe('/')
// On mobile the closed drawer is aria-hidden/inert, so the nav link is not
// in the accessibility tree; assert the highlight via the DOM node instead.
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
128 changes: 128 additions & 0 deletions frontend/src/components/KeyboardShortcuts.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
@layer components {
.help-modal__overlay {
position: fixed;
inset: 0;
z-index: var(--z-critical-approval);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(3, 6, 10, 0.75);
backdrop-filter: blur(12px);
}

.help-modal {
width: 100%;
max-width: 520px;
max-height: calc(100dvh - 48px);
display: flex;
flex-direction: column;
background: var(--surface, #0e0e13);
border: 1px solid var(--hairline, rgba(204, 255, 0, 0.1));
border-radius: var(--radius-dialog, 18px);
box-shadow: 0 32px 80px -20px rgba(0, 0, 0, 0.8);
overflow: hidden;
}

.help-modal__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--hairline, rgba(204, 255, 0, 0.1));
}

.help-modal__title {
font-size: 1.125rem;
font-weight: 600;
color: var(--foreground, #ececef);
letter-spacing: -0.01em;
}

.help-modal__close {
background: transparent;
border: none;
color: var(--dim, #93939e);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-compact, 6px);
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}

.help-modal__close:hover {
color: var(--foreground, #ececef);
background: var(--elevated, #17171d);
}

.help-modal__body {
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
}

.help-modal__section {
display: flex;
flex-direction: column;
gap: 12px;
}

.help-modal__section-title {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--dim, #93939e);
margin-bottom: 4px;
}

.help-modal__shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
}

.help-modal__shortcut-row:last-child {
border-bottom: none;
}

.help-modal__shortcut-desc {
font-size: 0.875rem;
color: var(--foreground, #ececef);
}

.help-modal__kbd-list {
display: flex;
align-items: center;
gap: 4px;
}

.help-modal__kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 6px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
font-weight: 600;
background: var(--elevated, #17171d);
border: 1px solid var(--hairline, rgba(204, 255, 0, 0.1));
border-radius: 4px;
color: var(--foreground, #ececef);
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2);
}

.help-modal__plus {
font-size: 0.75rem;
color: var(--dim, #93939e);
font-weight: 500;
}
}
146 changes: 146 additions & 0 deletions frontend/src/components/KeyboardShortcuts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import {
formatShortcutKey,
getEventCombo,
KeyboardShortcutProvider,
useKeyboardShortcut,
} from './KeyboardShortcuts'

describe('KeyboardShortcuts Utilities', () => {
it('formats shortcut keys platform-awarely', () => {
// Mock userAgent for non-mac
const originalUserAgent = navigator.userAgent
Object.defineProperty(navigator, 'userAgent', {
value: 'Windows NT 10.0',
configurable: true,
})

expect(formatShortcutKey('mod+shift+o')).toBe('Ctrl+Shift+O')
expect(formatShortcutKey('alt+arrowup')).toBe('Alt+↑')
expect(formatShortcutKey('escape')).toBe('Esc')

// Mock userAgent for mac
Object.defineProperty(navigator, 'userAgent', {
value: 'Macintosh; Intel Mac OS X 10_15_7',
configurable: true,
})

expect(formatShortcutKey('mod+shift+o')).toBe('⌘⇧O')
expect(formatShortcutKey('alt+arrowup')).toBe('⌥↑')
expect(formatShortcutKey('escape')).toBe('Esc')

// Restore
Object.defineProperty(navigator, 'userAgent', {
value: originalUserAgent,
configurable: true,
})
})

it('determines the correct event combo key', () => {
const e1 = {
key: 'o',
code: 'KeyO',
ctrlKey: true,
shiftKey: true,
metaKey: false,
altKey: false,
} as KeyboardEvent
expect(getEventCombo(e1)).toBe('mod+shift+o')

const e2 = {
key: 'ArrowUp',
ctrlKey: false,
shiftKey: false,
metaKey: false,
altKey: true,
} as KeyboardEvent
expect(getEventCombo(e2)).toBe('alt+arrowup')

const e3 = {
key: '?',
ctrlKey: false,
shiftKey: true,
metaKey: false,
altKey: false,
} as KeyboardEvent
expect(getEventCombo(e3)).toBe('?')
})
})

function TestComponent() {
const [pressed, setPressed] = useState(false)

useKeyboardShortcut(
{
key: 'mod+k',
description: 'Test shortcut description',
category: 'Test Category',
},
(e) => {
e.preventDefault()
setPressed(true)
},
)

return <div>{pressed ? 'Pressed' : 'Not Pressed'}</div>
}

describe('KeyboardShortcutProvider', () => {
it('registers and triggers a global shortcut', async () => {
render(
<KeyboardShortcutProvider>
<TestComponent />
</KeyboardShortcutProvider>,
)

expect(screen.getByText('Not Pressed')).toBeInTheDocument()

// Trigger Mod+K (Ctrl+K or Cmd+K)
const event = new KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
bubbles: true,
})
document.dispatchEvent(event)

await waitFor(() => {
expect(screen.getByText('Pressed')).toBeInTheDocument()
})
})

it('toggles the help modal on "?" keydown', async () => {
render(
<KeyboardShortcutProvider>
<TestComponent />
</KeyboardShortcutProvider>,
)

// Initially modal is not open
expect(screen.queryByText('Keyboard Shortcuts')).not.toBeInTheDocument()

// Dispatch "?" keydown
const event = new KeyboardEvent('keydown', {
key: '?',
bubbles: true,
})
document.dispatchEvent(event)

// Modal should be open and display the title and test description
await waitFor(() => {
expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument()
})
expect(screen.getByText('Test shortcut description')).toBeInTheDocument()
expect(screen.getByText('Test Category')).toBeInTheDocument()

// Close the modal
const closeBtn = screen.getByRole('button', { name: 'Close dialog' })
fireEvent.click(closeBtn)

// Modal should be closed
await waitFor(() => {
expect(screen.queryByText('Keyboard Shortcuts')).not.toBeInTheDocument()
})
})
})
Loading
Loading