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:
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user