From ca6b5e080743656252001073b40d30ac03b784bb Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 22:45:15 -0700 Subject: [PATCH] web: keyboard shortcuts --- web/README.md | 4 ++++ web/src/App.tsx | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/web/README.md b/web/README.md index ebbc8f6..6871f84 100644 --- a/web/README.md +++ b/web/README.md @@ -40,6 +40,10 @@ Default dev URL: `http://localhost:5173` - Composer adapts to the active item: - Chat sends `POST /v1/chat-completions/stream` (SSE). - Search sends `POST /v1/searches/:searchId/run/stream` (SSE). +- Keyboard shortcuts: + - `Cmd/Ctrl+J`: start a new chat. + - `Shift+Cmd/Ctrl+J`: start a new search. + - `Cmd/Ctrl+Up/Down`: move through the sidebar list. Client API contract docs: - `../docs/api/rest.md` diff --git a/web/src/App.tsx b/web/src/App.tsx index 3c03f58..c65efe9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1008,6 +1008,10 @@ export default function App() { if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`; return "Sybil"; }, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]); + const primaryShortcutModifier = useMemo(() => { + if (typeof navigator === "undefined") return "Ctrl"; + return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? "Cmd" : "Ctrl"; + }, []); useEffect(() => { document.title = pageTitle; @@ -1035,6 +1039,56 @@ export default function App() { setIsMobileSidebarOpen(false); }; + const selectAdjacentSidebarItem = (direction: -1 | 1) => { + if (!filteredSidebarItems.length) return; + + setError(null); + setContextMenu(null); + setDraftKind(null); + setIsMobileSidebarOpen(false); + setSelectedItem((current) => { + const currentIndex = current + ? filteredSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id) + : -1; + const fallbackIndex = direction > 0 ? 0 : filteredSidebarItems.length - 1; + const nextIndex = + currentIndex < 0 + ? fallbackIndex + : Math.min(filteredSidebarItems.length - 1, Math.max(0, currentIndex + direction)); + const nextItem = filteredSidebarItems[nextIndex]; + return { kind: nextItem.kind, id: nextItem.id }; + }); + }; + + useEffect(() => { + if (!isAuthenticated) return; + + const handleKeyDown = (event: KeyboardEvent) => { + const hasPrimaryModifier = event.metaKey || event.ctrlKey; + if (!hasPrimaryModifier || event.altKey) return; + + const key = event.key.toLowerCase(); + if (key === "j") { + event.preventDefault(); + if (event.shiftKey) { + handleCreateSearch(); + } else { + handleCreateChat(); + } + focusComposer(); + return; + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + selectAdjacentSidebarItem(event.key === "ArrowUp" ? -1 : 1); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [filteredSidebarItems, isAuthenticated]); + const openContextMenu = (event: MouseEvent, item: SidebarSelection) => { event.preventDefault(); const menuWidth = 160; @@ -1632,10 +1686,16 @@ export default function App() {