feat(desktop): broaden hotkey coverage + fold in stray shortcuts

Add rebindable actions for the high-frequency gaps: focus composer, open
model picker, next/prev session, search sessions (⌘⇧F), show files/
terminal tab, and nav→artifacts. Reconcile the duplicate Shift+N new-
session listener into session.new's defaults, and surface the remaining
context-local shortcuts (⌘↵ steer, ⌘L terminal selection, ⌘W close
preview) as read-only rows so the panel is the honest source of truth.
This commit is contained in:
Brooklyn Nicholson 2026-06-06 11:47:33 -05:00
parent 5e2b83a8ad
commit 258984fcb9
7 changed files with 110 additions and 40 deletions

View file

@ -17,7 +17,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -49,6 +49,7 @@ import {
$sidebarRecentsOpen,
pinSession,
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
setSidebarAgentsGrouped,
setSidebarPinsOpen,
setSidebarRecentsOpen,
@ -263,8 +264,18 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = searchQuery.trim()
// Hotkey (session.focusSearch) → focus the field once it's mounted.
useEffect(() => {
const onFocus = () => searchInputRef.current?.focus({ preventScroll: true })
window.addEventListener(SESSION_SEARCH_FOCUS_EVENT, onFocus)
return () => window.removeEventListener(SESSION_SEARCH_FOCUS_EVENT, onFocus)
}, [])
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
// the shortcut visibly pings its affordance in the sidebar.
useEffect(() => {
@ -621,6 +632,7 @@ export function ChatSidebar({
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
inputRef={searchInputRef}
onChange={setSearchQuery}
placeholder={s.searchPlaceholder}
value={searchQuery}

View file

@ -432,42 +432,6 @@ export function DesktopController() {
updateSessionState
})
// Single-key "new session" convenience (Shift+N) when no input is focused.
// The rebindable accelerator (⌘N by default) is owned by the keybind runtime.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const editing =
target?.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
if (
event.defaultPrevented ||
event.repeat ||
event.altKey ||
event.metaKey ||
event.ctrlKey ||
editing ||
!event.shiftKey ||
event.code !== 'KeyN'
) {
return
}
event.preventDefault()
startFreshSessionDraft()
// Briefly light up the sidebar's ⌘N hint so the shortcut is discoverable.
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [startFreshSessionDraft])
// Single global listener for every rebindable hotkey (incl. profile switching)
// plus the on-screen keybind editor's capture mode.
useKeybinds({

View file

@ -1,11 +1,18 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import { toggleFileBrowserOpen, togglePanesFlipped, toggleSidebarOpen } from '@/store/layout'
import {
requestSessionSearchFocus,
setFileBrowserOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
toggleSidebarOpen
} from '@/store/layout'
import {
cycleProfile,
requestProfileCreate,
@ -13,13 +20,17 @@ import {
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
PROFILES_ROUTE,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE
} from '../routes'
@ -51,15 +62,41 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
}
// Move to the adjacent session in recency order, wrapping at the ends.
const cycleSession = (direction: 1 | -1) => {
const sessions = $sessions.get()
if (sessions.length < 2) {
return
}
const current = sessions.findIndex(session => session.id === $activeSessionId.get())
const start = current === -1 ? (direction === 1 ? -1 : 0) : current
const next = sessions[(start + direction + sessions.length) % sessions.length]
if (next) {
navigate(sessionRoute(next.id))
}
}
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
setFileBrowserOpen(true)
setRightSidebarTab(tab)
}
handlersRef.current = {
'keybinds.openPanel': toggleKeybindPanel,
'composer.focus': () => requestComposerFocus('main'),
'composer.modelPicker': () => setModelPickerOpen(true),
'nav.commandPalette': toggleCommandPalette,
'nav.commandCenter': deps.toggleCommandCenter,
'nav.settings': () => navigate(SETTINGS_ROUTE),
'nav.profiles': () => navigate(PROFILES_ROUTE),
'nav.skills': () => navigate(SKILLS_ROUTE),
'nav.messaging': () => navigate(MESSAGING_ROUTE),
'nav.artifacts': () => navigate(ARTIFACTS_ROUTE),
'nav.cron': () => navigate(CRON_ROUTE),
'nav.agents': () => navigate(AGENTS_ROUTE),
@ -67,10 +104,15 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.next': () => cycleSession(1),
'session.prev': () => cycleSession(-1),
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
'view.toggleSidebar': toggleSidebarOpen,
'view.toggleRightSidebar': toggleFileBrowserOpen,
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.flipPanes': togglePanesFlipped,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),

View file

@ -173,12 +173,22 @@ export const en: Translations = {
'nav.profiles': 'Open profiles',
'nav.skills': 'Open skills',
'nav.messaging': 'Open messaging',
'nav.artifacts': 'Open artifacts',
'nav.cron': 'Open scheduled jobs',
'nav.agents': 'Open agents',
'session.new': 'New session',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.focusSearch': 'Search sessions',
'session.togglePin': 'Pin / unpin current session',
'composer.focus': 'Focus composer',
'composer.modelPicker': 'Open model picker',
'view.toggleSidebar': 'Toggle sessions sidebar',
'view.toggleRightSidebar': 'Toggle file browser',
'view.showFiles': 'Show file browser',
'view.showTerminal': 'Show terminal',
'view.terminalSelection': 'Send terminal selection to composer',
'view.closePreviewTab': 'Close preview tab',
'view.flipPanes': 'Swap sidebar sides',
'appearance.toggleMode': 'Toggle light / dark',
'profile.default': 'Switch to default profile',
@ -206,6 +216,7 @@ export const en: Translations = {
'profile.create': 'Create profile',
'composer.send': 'Send message',
'composer.newline': 'Insert newline',
'composer.steer': 'Steer the running turn',
'composer.sendQueued': 'Send next queued turn',
'composer.mention': 'Reference files, folders, URLs',
'composer.slash': 'Slash command palette',

View file

@ -169,12 +169,22 @@ export const zh: Translations = {
'nav.profiles': '打开配置',
'nav.skills': '打开技能',
'nav.messaging': '打开消息',
'nav.artifacts': '打开制品',
'nav.cron': '打开定时任务',
'nav.agents': '打开智能体',
'session.new': '新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.focusSearch': '搜索会话',
'session.togglePin': '固定/取消固定当前会话',
'composer.focus': '聚焦输入框',
'composer.modelPicker': '打开模型选择器',
'view.toggleSidebar': '切换会话侧边栏',
'view.toggleRightSidebar': '切换文件浏览器',
'view.showFiles': '显示文件浏览器',
'view.showTerminal': '显示终端',
'view.terminalSelection': '将终端选区发送到输入框',
'view.closePreviewTab': '关闭预览标签',
'view.flipPanes': '交换侧边栏位置',
'appearance.toggleMode': '切换浅色/深色',
'profile.default': '切换到默认配置',
@ -202,6 +212,7 @@ export const zh: Translations = {
'profile.create': '创建配置',
'composer.send': '发送消息',
'composer.newline': '插入换行',
'composer.steer': '引导正在运行的回合',
'composer.sendQueued': '发送下一条排队消息',
'composer.mention': '引用文件、文件夹、网址',
'composer.slash': '斜杠命令面板',

View file

@ -44,6 +44,10 @@ const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE
}))
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Composer ─────────────────────────────────────────────────────────────
{ id: 'composer.focus', category: 'composer', defaults: [] },
{ id: 'composer.modelPicker', category: 'composer', defaults: [] },
// ── Profiles ─────────────────────────────────────────────────────────────
{ id: 'profile.default', category: 'profiles', defaults: ['mod+`'] },
...PROFILE_SWITCH_ACTIONS,
@ -53,7 +57,10 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'profile.create', category: 'profiles', defaults: [] },
// ── Session ──────────────────────────────────────────────────────────────
{ id: 'session.new', category: 'session', defaults: ['mod+n'] },
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
{ id: 'session.next', category: 'session', defaults: [] },
{ id: 'session.prev', category: 'session', defaults: [] },
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
{ id: 'session.togglePin', category: 'session', defaults: [] },
// ── Navigation ───────────────────────────────────────────────────────────
@ -63,12 +70,15 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'nav.profiles', category: 'navigation', defaults: [] },
{ id: 'nav.skills', category: 'navigation', defaults: [] },
{ id: 'nav.messaging', category: 'navigation', defaults: [] },
{ id: 'nav.artifacts', category: 'navigation', defaults: [] },
{ id: 'nav.cron', category: 'navigation', defaults: [] },
{ id: 'nav.agents', category: 'navigation', defaults: [] },
// ── View (layout + appearance + the shortcuts panel itself) ───────────────
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
{ id: 'view.showTerminal', category: 'view', defaults: [] },
{ id: 'view.flipPanes', category: 'view', defaults: [] },
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
{ id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] }
@ -101,10 +111,14 @@ export interface KeybindReadonly {
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
{ id: 'composer.steer', category: 'composer', keys: ['mod+enter'] },
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
{ id: 'composer.slash', category: 'composer', keys: ['/'] },
{ id: 'composer.help', category: 'composer', keys: ['?'] },
{ id: 'composer.history', category: 'composer', keys: ['up', 'down'] },
{ id: 'composer.cancel', category: 'composer', keys: ['escape'] }
{ id: 'composer.cancel', category: 'composer', keys: ['escape'] },
// Fixed, context-local shortcuts surfaced for discoverability.
{ id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] },
{ id: 'view.closePreviewTab', category: 'view', keys: ['mod+w'] }
]

View file

@ -81,6 +81,22 @@ export function toggleFileBrowserOpen() {
togglePane(FILE_BROWSER_PANE_ID)
}
export function setFileBrowserOpen(open: boolean) {
setPaneOpen(FILE_BROWSER_PANE_ID, open)
}
// Hotkey → focus the sessions search field. Opens the sidebar first, then lets
// the field (which only mounts when the sidebar is open) subscribe + focus.
export const SESSION_SEARCH_FOCUS_EVENT = 'hermes:focus-session-search'
export function requestSessionSearchFocus() {
setSidebarOpen(true)
if (typeof window !== 'undefined') {
window.setTimeout(() => window.dispatchEvent(new CustomEvent(SESSION_SEARCH_FOCUS_EVENT)), 0)
}
}
export function togglePanesFlipped() {
$panesFlipped.set(!$panesFlipped.get())
}