Add keyboard shortcuts and a help sidebar

Global hotkey registry (src/keyboard) with chord support (g d, g c,
...) and a "?" help sidebar that lists whatever's registered on the
current screen. Wired up nav chords plus the primary create action on
each page (new novel/character/chapter/beat/scene, new conversation),
and moved the agent's mod+Enter send through the same registry.
This commit is contained in:
James Wampler
2026-08-11 20:35:23 -07:00
parent e7d89a6d41
commit 1ce526019f
9 changed files with 311 additions and 20 deletions
+18 -13
View File
@@ -8,21 +8,26 @@ import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage'
import SettingsPage from './pages/SettingsPage'
import { HotkeysProvider } from './keyboard/HotkeysContext'
import { HelpOverlay } from './keyboard/HelpOverlay'
export default function App() {
return (
<Routes>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Routes>
<HotkeysProvider>
<HelpOverlay />
<Routes>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Routes>
</HotkeysProvider>
)
}
@@ -0,0 +1,109 @@
import { useState } from 'react'
import { useHotkey, useHotkeysList } from './HotkeysContext'
const formatToken = (token: string) => {
if (token === 'mod') return '⌘/Ctrl'
if (token.length === 1) return token.toUpperCase()
return token
}
function KeyChip({ token }: { token: string }) {
return (
<kbd
className="rounded border px-1.5 py-0.5 font-mono text-xs"
style={{ borderColor: 'var(--line)', background: 'var(--surface-sunken)' }}
>
{formatToken(token)}
</kbd>
)
}
function KeySequence({ keys }: { keys: string }) {
const chords = keys.split(' ')
return (
<span className="flex items-center gap-1">
{chords.map((chord, chordIndex) => (
<span key={chordIndex} className="flex items-center gap-1">
{chordIndex > 0 && <span className="text-xs muted">then</span>}
{chord.split('+').map((token, tokenIndex, tokens) => (
<span key={tokenIndex} className="flex items-center gap-1">
<KeyChip token={token} />
{tokenIndex < tokens.length - 1 && <span className="text-xs muted">+</span>}
</span>
))}
</span>
))}
</span>
)
}
export function HelpOverlay() {
const [open, setOpen] = useState(false)
const shortcuts = useHotkeysList()
useHotkey('?', 'Toggle this help', () => setOpen((v) => !v), { group: 'Global' })
useHotkey('Escape', 'Close help', () => setOpen(false), { group: 'Global', enabled: open })
const groups = new Map<string, typeof shortcuts>()
for (const shortcut of shortcuts) {
if (!groups.has(shortcut.group)) groups.set(shortcut.group, [])
groups.get(shortcut.group)!.push(shortcut)
}
const orderedGroups = [...groups.entries()].sort(([a], [b]) =>
a === 'Global' ? -1 : b === 'Global' ? 1 : a.localeCompare(b),
)
return (
<>
<button
className="fixed top-3 right-4 z-40 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition hover:shadow-md"
style={{ background: 'var(--surface)', border: '1px solid var(--line)' }}
onClick={() => setOpen(true)}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
>
?
</button>
{open && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
<aside
className="flex h-full w-full max-w-sm flex-col overflow-y-auto p-5 shadow-xl"
style={{ background: 'var(--surface)', borderLeft: '1px solid var(--line)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
<button className="btn px-2 py-1" onClick={() => setOpen(false)} aria-label="Close">
</button>
</div>
<p className="mb-4 text-sm muted">
Shown here are the shortcuts available on the screen you're on. Shortcuts don't fire
while a text field is focused, except where noted.
</p>
{orderedGroups.length === 0 && <p className="text-sm muted">No shortcuts registered.</p>}
<div className="grid gap-5">
{orderedGroups.map(([group, groupShortcuts]) => (
<div key={group}>
<h3 className="label mb-2">{group}</h3>
<ul className="grid gap-2">
{groupShortcuts.map((shortcut) => (
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
<span>{shortcut.description}</span>
<KeySequence keys={shortcut.keys} />
</li>
))}
</ul>
</div>
))}
</div>
</aside>
</div>
)}
</>
)
}
@@ -0,0 +1,149 @@
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
export interface HotkeyEntry {
id: string
keys: string
description: string
group: string
enabled: boolean
allowInInputs: boolean
run: () => void
}
interface HotkeysActions {
register: (entry: HotkeyEntry) => void
unregister: (id: string) => void
}
// Split so registering a hotkey (stable actions) never invalidates every other
// hotkey's effect just because the entries list (read only by the help sidebar) changed.
const HotkeysActionsContext = createContext<HotkeysActions | null>(null)
const HotkeysEntriesContext = createContext<HotkeyEntry[]>([])
const isTypingTarget = (el: EventTarget | null) => {
if (!(el instanceof HTMLElement)) return false
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable
}
const normalizeKey = (e: KeyboardEvent) => (e.key.length === 1 ? e.key.toLowerCase() : e.key)
const withMods = (e: KeyboardEvent, base: string) => ((e.metaKey || e.ctrlKey) && base !== 'Escape' ? `mod+${base}` : base)
const CHORD_TIMEOUT_MS = 1500
export function HotkeysProvider({ children }: { children: ReactNode }) {
const registryRef = useRef(new Map<string, HotkeyEntry>())
const [entries, setEntries] = useState<HotkeyEntry[]>([])
const chordRef = useRef<{ prefix: string; timer: number } | null>(null)
const register = useCallback((entry: HotkeyEntry) => {
registryRef.current.set(entry.id, entry)
setEntries([...registryRef.current.values()])
}, [])
const unregister = useCallback((id: string) => {
registryRef.current.delete(id)
setEntries([...registryRef.current.values()])
}, [])
useEffect(() => {
const dispatch = (keys: string, onlyAllowInInputs = false) => {
const matches = [...registryRef.current.values()].filter(
(entry) => entry.keys === keys && entry.enabled && (!onlyAllowInInputs || entry.allowInInputs),
)
matches.forEach((entry) => entry.run())
return matches.length > 0
}
const clearChord = () => {
if (chordRef.current) window.clearTimeout(chordRef.current.timer)
chordRef.current = null
}
const onKeyDown = (e: KeyboardEvent) => {
const base = normalizeKey(e)
const typing = isTypingTarget(e.target)
if (base === 'Escape') {
clearChord()
dispatch('Escape')
return
}
const modded = withMods(e, base)
if (typing) {
if (dispatch(modded, true)) e.preventDefault()
return
}
if (chordRef.current) {
const candidate = `${chordRef.current.prefix} ${base}`
clearChord()
if (dispatch(candidate)) {
e.preventDefault()
return
}
}
if (dispatch(modded) || dispatch(base)) {
e.preventDefault()
return
}
const isChordPrefix = [...registryRef.current.values()].some((entry) => entry.keys.startsWith(`${base} `))
if (isChordPrefix) {
const timer = window.setTimeout(clearChord, CHORD_TIMEOUT_MS)
chordRef.current = { prefix: base, timer }
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
clearChord()
}
}, [])
const actions = useMemo(() => ({ register, unregister }), [register, unregister])
return (
<HotkeysActionsContext.Provider value={actions}>
<HotkeysEntriesContext.Provider value={entries}>{children}</HotkeysEntriesContext.Provider>
</HotkeysActionsContext.Provider>
)
}
/** Registers a keyboard shortcut and (while mounted) lists it in the help sidebar.
*
* `keys` is either a single token ("n", "?", "Escape", "mod+Enter") or a two-key
* chord ("g d"). Chords never fire while a text field is focused; single keys are
* ignored while typing unless `allowInInputs` is set.
*/
export function useHotkey(
keys: string,
description: string,
handler: () => void,
options?: { group?: string; enabled?: boolean; allowInInputs?: boolean },
) {
const actions = useContext(HotkeysActionsContext)
const id = useId()
const handlerRef = useRef(handler)
const enabled = options?.enabled ?? true
const group = options?.group ?? 'General'
const allowInInputs = options?.allowInInputs ?? false
useEffect(() => {
handlerRef.current = handler
})
useEffect(() => {
if (!actions) return
actions.register({ id, keys, description, group, enabled, allowInInputs, run: () => handlerRef.current() })
return () => actions.unregister(id)
}, [actions, id, keys, description, group, enabled, allowInInputs])
}
export function useHotkeysList() {
return useContext(HotkeysEntriesContext)
}
+8 -6
View File
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'
import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks'
import type { AgentMessage } from '../api/types'
import { ErrorNote, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
const starters = [
'Read the brief and tell me what the outline is missing.',
@@ -33,6 +34,13 @@ export default function AgentPage() {
)
}
useHotkey('n', 'New conversation', () => setConversationId(undefined), { group: 'Agent' })
useHotkey('mod+Enter', 'Send message', () => submit(draft), {
group: 'Agent',
allowInInputs: true,
enabled: draft.trim().length > 0 && !send.isPending,
})
return (
<div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
<aside className="grid content-start gap-2">
@@ -107,12 +115,6 @@ export default function AgentPage() {
value={draft}
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
submit(draft)
}
}}
/>
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
Send
@@ -18,6 +18,7 @@ import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
import { TagChip, TagEditor } from '../components/TagEditor'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams()
@@ -30,6 +31,9 @@ export default function ChapterPage() {
const createBeat = useCreateBeat(chapterId, projectId)
const createScene = useCreateScene(chapterId)
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
useHotkey('s', 'Add scene', () => createScene.mutate({ title: 'New scene' }), { group: 'Chapter' })
if (isPending) return <Spinner label="Loading chapter" />
if (error) return <ErrorNote error={error} />
if (!chapter) return null
@@ -274,6 +278,7 @@ function BeatTable({
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null)
}}
onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)}
>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
@@ -2,12 +2,15 @@ import { Link, useParams } from 'react-router-dom'
import { useChapters, useCreateChapter } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { TagChip } from '../components/TagEditor'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ChaptersPage() {
const { projectId = '' } = useParams()
const { data: chapters, isPending, error } = useChapters(projectId)
const create = useCreateChapter(projectId)
useHotkey('n', 'Add chapter', () => create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' })
if (isPending) return <Spinner label="Loading chapters" />
if (error) return <ErrorNote error={error} />
@@ -13,6 +13,7 @@ import { TagEditor } from '../components/TagEditor'
import { CharacterArc } from '../components/CharacterArc'
import { CharacterBeats } from '../components/CharacterBeats'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function CharactersPage() {
const { projectId = '' } = useParams()
@@ -20,6 +21,8 @@ export default function CharactersPage() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
useHotkey('n', 'Add character', () => setAdding(true), { group: 'Characters' })
if (isPending) return <Spinner label="Loading characters" />
if (error) return <ErrorNote error={error} />
+12 -1
View File
@@ -1,13 +1,24 @@
import { Outlet, useParams, Link } from 'react-router-dom'
import { Outlet, useParams, Link, useNavigate } from 'react-router-dom'
import { useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types'
import { ErrorNote, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectLayout() {
const { projectId = '' } = useParams()
const navigate = useNavigate()
const { data: project, isPending, error } = useProject(projectId)
const update = useUpdateProject(projectId)
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`)
useHotkey('g d', 'Go to dashboard', () => goTo(''), { group: 'Navigate' })
useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' })
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' })
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
return (
<div className="min-h-full">
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { useCreateProject, useProjects } from '../api/hooks'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectsPage() {
const { data: projects, isPending, error } = useProjects()
@@ -10,6 +11,9 @@ export default function ProjectsPage() {
const [importing, setImporting] = useState(false)
const navigate = useNavigate()
useHotkey('n', 'New novel', () => setCreating(true), { group: 'Novels' })
useHotkey('i', 'Import from outline', () => setImporting(true), { group: 'Novels' })
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<header className="mb-8 flex items-end justify-between gap-4">