feat(desktop): keyboard shortcuts registry + settings panel (#387)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-22 16:45:56 -06:00
committed by GitHub
parent 36c97a6e2e
commit f5b2e4f8b1
5 changed files with 344 additions and 4 deletions

View File

@@ -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<BrowseDialogType>(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,

View File

@@ -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(/(?<!\+)\+(?!\s*$)/)
.map((p) => p.trim())
.filter(Boolean);
return (
<span className="flex items-center gap-1">
{parts.map((part) => (
<kbd
className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border/70 bg-muted/60 px-1.5 font-mono text-xs text-muted-foreground"
key={part}
>
{part}
</kbd>
))}
</span>
);
}
export function KeyboardShortcutsCard() {
const categories = getShortcutsByCategory();
return (
<section className="min-w-0" data-testid="settings-shortcuts">
<div className="mb-3 min-w-0">
<div className="flex items-center gap-2">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold tracking-tight">
Keyboard Shortcuts
</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
All available keyboard shortcuts. Shortcuts are read-only.
</p>
</div>
<div className="space-y-4">
{[...categories.entries()].map(([category, shortcuts]) => (
<div key={category}>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{category}
</h3>
<div className="rounded-lg border border-border/70 bg-background/70">
{shortcuts.map((shortcut, i) => (
<div
className={`flex items-center justify-between px-3 py-2 text-sm ${i !== shortcuts.length - 1 ? "border-b border-border/50" : ""}`}
key={shortcut.id}
>
<div className="min-w-0 flex-1">
<span className="text-foreground">{shortcut.label}</span>
<span className="ml-2 text-muted-foreground">
{shortcut.description}
</span>
</div>
<KeyCombo shortcut={shortcut} />
</div>
))}
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -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 <ThemeSettingsCard />;
case "shortcuts":
return <KeyboardShortcutsCard />;
case "tokens":
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
case "mobile":

View File

@@ -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<CreateChannelKind | null>(null);

View File

@@ -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<ShortcutCategory, KeyboardShortcut[]>();
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;
}