From 258984fcb9061e258014728f7ac856d5f118d387 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 11:47:33 -0500 Subject: [PATCH] feat(desktop): broaden hotkey coverage + fold in stray shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/sidebar/index.tsx | 14 ++++++- apps/desktop/src/app/desktop-controller.tsx | 36 ----------------- apps/desktop/src/app/hooks/use-keybinds.ts | 44 ++++++++++++++++++++- apps/desktop/src/i18n/en.ts | 11 ++++++ apps/desktop/src/i18n/zh.ts | 11 ++++++ apps/desktop/src/lib/keybinds/actions.ts | 18 ++++++++- apps/desktop/src/store/layout.ts | 16 ++++++++ 7 files changed, 110 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 33594448341..d46948165dc 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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([]) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) const [profileLoadMorePending, setProfileLoadMorePending] = useState>({}) + const searchInputRef = useRef(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({
{ - 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({ diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 3f8528a2929..dc25f42b77c 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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'), diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1f12134297c..1f604f3906b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 21f16f998aa..3472352b908 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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': '斜杠命令面板', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 313b48fd8ca..c1df542cd19 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -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'] } ] diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index c8f5386b6e7..e551d459a0f 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -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()) }