From f5b2e4f8b1f17ad7bfa20bcba87603600018dcca Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 22 Apr 2026 16:45:56 -0600 Subject: [PATCH] feat(desktop): keyboard shortcuts registry + settings panel (#387) Co-authored-by: Claude Opus 4.6 (1M context) --- desktop/src/app/AppShell.tsx | 34 ++- .../settings/ui/KeyboardShortcutsCard.tsx | 75 ++++++ .../features/settings/ui/SettingsPanels.tsx | 10 + .../src/features/sidebar/ui/AppSidebar.tsx | 8 +- desktop/src/shared/lib/keyboard-shortcuts.ts | 221 ++++++++++++++++++ 5 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx create mode 100644 desktop/src/shared/lib/keyboard-shortcuts.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b7d1736433..9e3742e9c7 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -28,7 +28,10 @@ import { } from "@/features/notifications/lib/desktop"; import { usePresenceSession } from "@/features/presence/hooks"; import { useProfileQuery } from "@/features/profile/hooks"; -import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; +import { + DEFAULT_SETTINGS_SECTION, + type SettingsSection, +} from "@/features/settings/ui/SettingsPanels"; import { HuddleBar, HuddleProvider } from "@/features/huddle"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { relayClient } from "@/shared/api/relayClient"; @@ -45,7 +48,6 @@ import { } from "@/shared/ui/sidebar"; type AppView = "home" | "channel" | "agents" | "workflows" | "pulse"; -const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); @@ -120,6 +122,7 @@ export function AppShell() { const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [browseDialogType, setBrowseDialogType] = React.useState(null); + const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); const location = useLocation(); const queryClient = useQueryClient(); const { goAgents, goChannel, goHome, goPulse, goWorkflows, openSearchHit } = @@ -324,6 +327,10 @@ export function AppShell() { }; }, []); + const handleOpenNewDm = React.useCallback(() => { + setIsNewDmOpen(true); + }, []); + React.useLayoutEffect(() => { if (settingsOpen) { return; @@ -341,9 +348,22 @@ export function AppShell() { return; } + if (key === "k" && event.shiftKey) { + event.preventDefault(); + handleOpenNewDm(); + return; + } + if (key === "o" && event.shiftKey) { event.preventDefault(); handleOpenBrowseChannels(); + return; + } + + if (key === "a" && event.shiftKey) { + event.preventDefault(); + void goHome(); + return; } } @@ -351,7 +371,13 @@ export function AppShell() { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [handleOpenBrowseChannels, handleOpenSearch, settingsOpen]); + }, [ + handleOpenBrowseChannels, + handleOpenNewDm, + handleOpenSearch, + goHome, + settingsOpen, + ]); React.useLayoutEffect(() => { function handleKeyDown(event: KeyboardEvent) { @@ -432,7 +458,9 @@ export function AppShell() { isCreatingForum={createForumMutation.isPending} isLoading={channelsQuery.isLoading} isOpeningDm={openDmMutation.isPending} + isNewDmOpen={isNewDmOpen} isPresencePending={presenceSession.isPending} + onNewDmOpenChange={setIsNewDmOpen} selfPresenceStatus={presenceSession.currentStatus} onCreateChannel={async ({ description, diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx new file mode 100644 index 0000000000..a2449ce397 --- /dev/null +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -0,0 +1,75 @@ +import { Keyboard } from "lucide-react"; + +import { + getShortcutsByCategory, + getPlatformKeys, + type KeyboardShortcut, +} from "@/shared/lib/keyboard-shortcuts"; + +function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { + const keys = getPlatformKeys(shortcut); + // Split on "+" but keep "+" as a standalone key (e.g. for zoom-in "⌘+") + const parts = keys + .split(/(? p.trim()) + .filter(Boolean); + + return ( + + {parts.map((part) => ( + + {part} + + ))} + + ); +} + +export function KeyboardShortcutsCard() { + const categories = getShortcutsByCategory(); + + return ( +
+
+
+ +

+ Keyboard Shortcuts +

+
+

+ All available keyboard shortcuts. Shortcuts are read-only. +

+
+ +
+ {[...categories.entries()].map(([category, shortcuts]) => ( +
+

+ {category} +

+
+ {shortcuts.map((shortcut, i) => ( +
+
+ {shortcut.label} + + {shortcut.description} + +
+ +
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 869b2ea747..05e6833631 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useRef } from "react"; import { BellRing, Check, + Keyboard, KeyRound, MonitorCog, Moon, @@ -21,6 +22,7 @@ import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider"; import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; +import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MobilePairingCard } from "./MobilePairingCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; @@ -29,6 +31,7 @@ export type SettingsSection = | "profile" | "notifications" | "appearance" + | "shortcuts" | "tokens" | "mobile" | "doctor"; @@ -70,6 +73,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Appearance", icon: MonitorCog, }, + { + value: "shortcuts", + label: "Shortcuts", + icon: Keyboard, + }, { value: "tokens", label: "Tokens", @@ -234,6 +242,8 @@ export function renderSettingsSection( ); case "appearance": return ; + case "shortcuts": + return ; case "tokens": return ; case "mobile": diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 325c8fbadf..6b1f302050 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -92,6 +92,8 @@ type AppSidebarProps = { onSelectSettings: () => void; onSetPresenceStatus?: (status: "online" | "away" | "offline") => void; isPresencePending?: boolean; + isNewDmOpen?: boolean; + onNewDmOpenChange?: (open: boolean) => void; }; // --------------------------------------------------------------------------- @@ -231,9 +233,13 @@ export function AppSidebar({ onSelectSettings, onSetPresenceStatus, isPresencePending, + isNewDmOpen: isNewDmOpenProp, + onNewDmOpenChange, }: AppSidebarProps) { const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; - const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); + const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false); + const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal; + const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const [createDialogKind, setCreateDialogKind] = React.useState(null); diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts new file mode 100644 index 0000000000..1853fbb377 --- /dev/null +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -0,0 +1,221 @@ +export type ShortcutCategory = + | "Navigation" + | "Messages" + | "Formatting" + | "Zoom"; + +export type KeyboardShortcut = { + id: string; + label: string; + description: string; + keys: string; + keysWindows: string; + category: ShortcutCategory; +}; + +export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ + // Navigation + { + id: "quick-search", + label: "Quick search", + description: "Open the search dialog", + keys: "⌘K", + keysWindows: "Ctrl+K", + category: "Navigation", + }, + { + id: "browse-channels", + label: "Browse channels", + description: "Open the channel browser", + keys: "⇧⌘O", + keysWindows: "Shift+Ctrl+O", + category: "Navigation", + }, + { + id: "browse-dms", + label: "New direct message", + description: "Open the new DM dialog", + keys: "⇧⌘K", + keysWindows: "Shift+Ctrl+K", + category: "Navigation", + }, + { + id: "open-settings", + label: "Settings", + description: "Open or close settings", + keys: "⌘,", + keysWindows: "Ctrl+,", + category: "Navigation", + }, + { + id: "go-back", + label: "Go back", + description: "Navigate to the previous page", + keys: "⌘[", + keysWindows: "Alt+←", + category: "Navigation", + }, + { + id: "go-forward", + label: "Go forward", + description: "Navigate to the next page", + keys: "⌘]", + keysWindows: "Alt+→", + category: "Navigation", + }, + { + id: "go-home", + label: "Home", + description: "Navigate to the home feed", + keys: "⇧⌘A", + keysWindows: "Shift+Ctrl+A", + category: "Navigation", + }, + { + id: "toggle-sidebar", + label: "Toggle sidebar", + description: "Show or hide the sidebar", + keys: "⌘S", + keysWindows: "Ctrl+S", + category: "Navigation", + }, + + // Zoom + { + id: "zoom-in", + label: "Zoom in", + description: "Increase the zoom level", + keys: "⌘+", + keysWindows: "Ctrl+=", + category: "Zoom", + }, + { + id: "zoom-out", + label: "Zoom out", + description: "Decrease the zoom level", + keys: "⌘-", + keysWindows: "Ctrl+-", + category: "Zoom", + }, + { + id: "zoom-reset", + label: "Reset zoom", + description: "Reset zoom to default level", + keys: "⌘0", + keysWindows: "Ctrl+0", + category: "Zoom", + }, + + // Messages + { + id: "send-message", + label: "Send message", + description: "Send the current message", + keys: "Enter", + keysWindows: "Enter", + category: "Messages", + }, + { + id: "new-line", + label: "New line", + description: "Insert a line break in the composer", + keys: "Shift+Enter", + keysWindows: "Shift+Enter", + category: "Messages", + }, + { + id: "publish-note", + label: "Publish note", + description: "Publish a Pulse note", + keys: "⌘Enter", + keysWindows: "Ctrl+Enter", + category: "Messages", + }, + { + id: "close-dialog", + label: "Close dialog", + description: "Close the current dialog or settings", + keys: "Escape", + keysWindows: "Escape", + category: "Messages", + }, + { + id: "push-to-talk", + label: "Push to talk", + description: "Hold to unmute in a huddle", + keys: "Ctrl+Space", + keysWindows: "Ctrl+Space", + category: "Messages", + }, + + // Formatting + { + id: "format-bold", + label: "Bold", + description: "Toggle bold formatting", + keys: "⌘B", + keysWindows: "Ctrl+B", + category: "Formatting", + }, + { + id: "format-italic", + label: "Italic", + description: "Toggle italic formatting", + keys: "⌘I", + keysWindows: "Ctrl+I", + category: "Formatting", + }, + { + id: "format-strikethrough", + label: "Strikethrough", + description: "Toggle strikethrough formatting", + keys: "⌘⇧X", + keysWindows: "Ctrl+Shift+X", + category: "Formatting", + }, + { + id: "format-code", + label: "Inline code", + description: "Toggle inline code formatting", + keys: "⌘E", + keysWindows: "Ctrl+E", + category: "Formatting", + }, + { + id: "format-link", + label: "Insert link", + description: "Insert or edit a link in the composer", + keys: "⌘K", + keysWindows: "Ctrl+K", + category: "Formatting", + }, +]; + +const CATEGORY_ORDER: ShortcutCategory[] = [ + "Navigation", + "Messages", + "Formatting", + "Zoom", +]; + +export function getShortcutsByCategory(): Map< + ShortcutCategory, + KeyboardShortcut[] +> { + const map = new Map(); + for (const cat of CATEGORY_ORDER) { + map.set( + cat, + KEYBOARD_SHORTCUTS.filter((s) => s.category === cat), + ); + } + return map; +} + +function isMac(): boolean { + return /mac|iphone|ipad|ipod/i.test(navigator.platform); +} + +export function getPlatformKeys(shortcut: KeyboardShortcut): string { + return isMac() ? shortcut.keys : shortcut.keysWindows; +}