diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 8c4310385e36..53075c5d49bb 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -117,6 +117,22 @@ that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro` (status-stack/table-footers), and the icon family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`. +**Icon-only buttons must have a tooltip.** Every button with an `icon*` size +carries no visible text label, so it must be wrapped in `` +with a descriptive label (matching the button's `aria-label`). Never use the +native HTML `title=` attribute — it's unstyled, delayed (~500ms OS default), +and visually inconsistent with the instant themed `Tip`. An enforcement test +(`src/components/ui/__tests__/no-native-title.test.ts`) fails on any ` - - ) : ( - - {onConvertBranch ? ( - - ) : ( - - )} -
- - -
-
- )} - - + {resolvedRepoPath && onOpenWorktree && ( + + )} ) }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index fd360764da45..e8107764d881 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -8,6 +8,7 @@ import { composerDockCard } from '@/components/chat/composer-dock' import { StatusSection } from '@/components/chat/status-section' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { @@ -129,15 +130,17 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro - {t.statusStack.agents} - + }> + + ) : undefined } defaultCollapsed={group.type !== 'todo'} diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 46afb0ede38f..13a20f61d623 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -19,6 +19,7 @@ import { CodeEditor } from '@/components/chat/code-editor' import { FileDiffPanel } from '@/components/chat/diff-lines' import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' import { PageLoader } from '@/components/page-loader' +import { Tip } from '@/components/ui/tooltip' import { translateNow, useI18n } from '@/i18n' import { desktopFileDiff, @@ -947,15 +948,16 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar onSelect={setUserMode} trailing={ canEdit ? ( - + + + ) : null } /> diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index e6fb6fda71b9..3e8514381ad1 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -201,12 +201,12 @@ function CronJobSidebarRow({ so the cron dots line up with the sessions above; the caret sits next to the label (matching the other sidebar disclosures) and the whole label area toggles the run peek. */} + + {/* Trailing cluster: countdown by default, quick actions on hover. */}
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 9a3fbe3a2748..262c2ead987e 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -21,6 +21,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' +import { TipKeybindLabel } from '@/components/ui/tooltip' import { useContributions } from '@/contrib/react/use-contributions' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' @@ -30,6 +31,7 @@ import { sessionMatchesSearch } from '@/lib/session-search' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' import { $cronJobs } from '@/store/cron' +import { $bindings } from '@/store/keybinds' import { $dismissedAutoProjectIds, $panesFlipped, @@ -138,23 +140,35 @@ import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' const NON_SESSION_INITIAL_ROWS = 3 const NON_SESSION_LOAD_STEP = 10 -const NEW_SESSION_KBD = comboTokens('mod+n') - const SIDEBAR_NAV: SidebarNavItem[] = [ { id: 'new-session', label: '', icon: props => , - action: 'new-session' + action: 'new-session', + keybindActionId: 'session.new' }, { id: 'skills', label: '', icon: props => , - route: SKILLS_ROUTE + route: SKILLS_ROUTE, + keybindActionId: 'nav.skills' }, - { id: 'messaging', label: '', icon: props => , route: MESSAGING_ROUTE }, - { id: 'artifacts', label: '', icon: props => , route: ARTIFACTS_ROUTE } + { + id: 'messaging', + label: '', + icon: props => , + route: MESSAGING_ROUTE, + keybindActionId: 'nav.messaging' + }, + { + id: 'artifacts', + label: '', + icon: props => , + route: ARTIFACTS_ROUTE, + keybindActionId: 'nav.artifacts' + } ] // Two modes via the `compact` height variant (styles.css): @@ -313,6 +327,8 @@ export function ChatSidebar({ const currentCwd = useStore($currentCwd) const gatewayState = useStore($gatewayState) const dismissedAutoProjects = useStore($dismissedAutoProjectIds) + const newSessionCombo = useStore($bindings)['session.new']?.[0] + const newSessionKbd = newSessionCombo ? comboTokens(newSessionCombo) : [] const [searchQuery, setSearchQuery] = useState('') const [serverMatches, setServerMatches] = useState([]) const [searchPending, setSearchPending] = useState(false) @@ -1123,7 +1139,15 @@ export function ChatSidebar({ onNavigate(item) }} - tooltip={s.nav[item.id] ?? item.label} + tooltip={ + item.keybindActionId + ? { + children: ( + + ) + } + : (s.nav[item.id] ?? item.label) + } type="button" > @@ -1131,7 +1155,7 @@ export function ChatSidebar({ {isNewSession && ( )} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index e184ac871a0d..0446800d25ab 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -1,17 +1,7 @@ import type * as React from 'react' -import { useCallback, useState } from 'react' +import { useState } from 'react' -import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { DropdownMenu, @@ -20,17 +10,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { SanitizedInput } from '@/components/ui/sanitized-input' -import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' -import { gitRef } from '@/lib/sanitize' import { cn } from '@/lib/utils' -import { notifyError } from '@/store/notifications' -import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchInRepo } from '@/store/projects' +import { copyPath, revealPath } from '@/store/projects' import { SidebarCount, SidebarRowLead } from '../chrome' -import { BaseBranchPicker } from './base-branch-picker' +import { WorktreeDialog } from './worktree-dialog' // Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`), // so plain end-truncation (`truncate`) hides exactly the suffix that tells two @@ -50,20 +36,6 @@ function LaneLabel({ label, title }: { label: string; title?: string }) { ) } -interface BranchActionCopy { - branchCreateWorktree: string - branchOpenExisting: string - branchSwitchHome: string -} - -const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => { - if (branch.checkedOut) { - return copy.branchOpenExisting - } - - return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree -} - // "+" affordance shared by repo and worktree headers — reveals on header hover. export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) { return ( @@ -148,203 +120,20 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov // pick any local or remote-tracking branch via a filterable combobox. export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) { const { t } = useI18n() - const s = t.sidebar - const p = s.projects + const p = t.sidebar.projects const [open, setOpen] = useState(false) - const [name, setName] = useState('') - const [pending, setPending] = useState(false) - const [convertMode, setConvertMode] = useState(false) - const [branches, setBranches] = useState([]) - const [branchesLoading, setBranchesLoading] = useState(false) - const [selectedBase, setSelectedBase] = useState('') - - const loadBranches = useCallback(async () => { - if (!repoPath) { - return - } - - setBranchesLoading(true) - - try { - setBranches(await listRepoBranches(repoPath)) - } catch { - setBranches([]) - } finally { - setBranchesLoading(false) - } - }, [repoPath]) - - const submit = async () => { - const branch = name.trim() - - if (pending || !repoPath || !branch) { - return - } - - setPending(true) - - try { - // Pass the typed value as both the dir slug source and the branch, so the - // branch is exactly what the user named (the dir is slugified git-side). - const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch }) - - if (result) { - onStarted(result.path) - setOpen(false) - setName('') - } - } catch (err) { - notifyError(err, p.startWorkFailed) - } finally { - setPending(false) - } - } - - const convert = async (branch: HermesGitBranch) => { - if (pending || !repoPath || !branch) { - return - } - - setPending(true) - - try { - let result: null | { branch: string; path: string } - - if (branch.worktreePath) { - result = { branch: branch.name, path: branch.worktreePath } - } else if (branch.isDefault) { - await switchBranchInRepo(repoPath, branch.name) - result = { branch: branch.name, path: repoPath } - } else { - result = await startWorkInRepo(repoPath, { existingBranch: branch.name }) - } - - if (result) { - onStarted(result.path) - setOpen(false) - } - } catch (err) { - notifyError(err, p.startWorkFailed) - } finally { - setPending(false) - } - } - - const enterConvert = () => { - setConvertMode(true) - void loadBranches() - } return ( <> - !pending && setOpen(next)} open={open}> - - - {convertMode ? p.convertBranchTitle : p.newWorktreeTitle} - {convertMode ? p.convertBranchDesc : p.newWorktreeDesc} - - - {convertMode ? ( - (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} - > - - - {branchesLoading ? p.branchesLoading : p.noBranches} - - {branches.map(branch => ( - void convert(branch)} - value={branch.name} - > - - {branch.name} - - {branchActionLabel(branch, p)} - - - ))} - - - - ) : ( - <> - { - if (event.key === 'Enter') { - event.preventDefault() - void submit() - } else if (event.key === 'Escape') { - setOpen(false) - } - }} - onValueChange={setName} - placeholder={p.branchPlaceholder} - sanitize={gitRef} - value={name} - /> - - - )} - - {convertMode ? ( - - - - ) : ( - - -
- - -
-
- )} -
-
+ ) } diff --git a/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx b/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx new file mode 100644 index 000000000000..4cb4093bba11 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { SanitizedInput } from '@/components/ui/sanitized-input' +import type { HermesGitBranch } from '@/global' +import { useI18n } from '@/i18n' +import { gitRef } from '@/lib/sanitize' +import { notifyError } from '@/store/notifications' +import { listRepoBranches, startWorkInRepo, switchBranchInRepo } from '@/store/projects' + +import { BaseBranchPicker } from './base-branch-picker' + +interface BranchActionCopy { + branchCreateWorktree: string + branchOpenExisting: string + branchSwitchHome: string +} + +const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => { + if (branch.checkedOut) { + return copy.branchOpenExisting + } + + return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree +} + +export interface WorktreeDialogProps { + /** Repo root path for git operations. */ + repoPath: string + /** Called with the new/converted worktree path on success. */ + onStarted: (path: string) => void + /** Controlled open state. */ + open: boolean + /** Called when the user requests the dialog to close (cancel, Esc, backdrop). */ + onOpenChange: (open: boolean) => void + /** Pre-select a base branch when opening (from "branch off from X" menus). */ + initialBase?: string +} + +/** + * Shared "new worktree" dialog — used by the sidebar's StartWorkButton and the + * composer's ⌘⇧B shortcut. Features: + * - Branch name input (sanitized as a git ref) + * - Base branch picker (filterable combobox — the sidebar's BaseBranchPicker) + * - Convert mode: check out an existing branch into a worktree + * + * The caller owns the open state so both the sidebar button and the global + * hotkey can trigger the same dialog instance. + */ +export function WorktreeDialog({ repoPath, onStarted, open, onOpenChange, initialBase }: WorktreeDialogProps) { + const { t } = useI18n() + const p = t.sidebar.projects + const [name, setName] = useState('') + const [pending, setPending] = useState(false) + const [convertMode, setConvertMode] = useState(false) + const [branches, setBranches] = useState([]) + const [branchesLoading, setBranchesLoading] = useState(false) + const [selectedBase, setSelectedBase] = useState('') + + // Reset to a fresh state each time the dialog opens, applying any pre-selected + // base branch from the caller (e.g. "branch off from main" in the coding row's + // dropdown menu). When `initialBase` changes while open (shouldn't happen in + // practice), the effect re-syncs. + useEffect(() => { + if (open) { + setName('') + setConvertMode(false) + setSelectedBase(initialBase ?? '') + } + }, [open, initialBase]) + + const loadBranches = useCallback(async () => { + if (!repoPath) { + return + } + + setBranchesLoading(true) + + try { + setBranches(await listRepoBranches(repoPath)) + } catch { + setBranches([]) + } finally { + setBranchesLoading(false) + } + }, [repoPath]) + + const submit = async () => { + const branch = name.trim() + + if (pending || !repoPath || !branch) { + return + } + + setPending(true) + + try { + const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch }) + + if (result) { + onStarted(result.path) + onOpenChange(false) + setName('') + } + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setPending(false) + } + } + + const convert = async (branch: HermesGitBranch) => { + if (pending || !repoPath || !branch) { + return + } + + setPending(true) + + try { + let result: null | { branch: string; path: string } + + if (branch.worktreePath) { + result = { branch: branch.name, path: branch.worktreePath } + } else if (branch.isDefault) { + await switchBranchInRepo(repoPath, branch.name) + result = { branch: branch.name, path: repoPath } + } else { + result = await startWorkInRepo(repoPath, { existingBranch: branch.name }) + } + + if (result) { + onStarted(result.path) + onOpenChange(false) + } + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setPending(false) + } + } + + const enterConvert = () => { + setConvertMode(true) + void loadBranches() + } + + return ( + !pending && onOpenChange(next)} open={open}> + + + {convertMode ? p.convertBranchTitle : p.newWorktreeTitle} + {convertMode ? p.convertBranchDesc : p.newWorktreeDesc} + + + {convertMode ? ( + (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} + > + + + {branchesLoading ? p.branchesLoading : p.noBranches} + + {branches.map(branch => ( + void convert(branch)} + value={branch.name} + > + + {branch.name} + + {branchActionLabel(branch, p)} + + + ))} + + + + ) : ( + <> + { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } else if (event.key === 'Escape') { + onOpenChange(false) + } + }} + onValueChange={setName} + placeholder={p.branchPlaceholder} + sanitize={gitRef} + value={name} + /> + + + )} + + {convertMode ? ( + + + + ) : ( + + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 67b45607cf3c..9b15d388222c 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -130,7 +130,6 @@ export function SidebarSessionRow({ aria-label={r.actionsFor(title)} className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!" size="icon" - title={r.sessionActions} variant="ghost" > diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 5eb2cf8a5b99..1c9f1dd7fd7a 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { SearchField } from '@/components/ui/search-field' import { SegmentedControl } from '@/components/ui/segmented-control' import { ResponsiveTabs } from '@/components/ui/tab-dropdown' +import { Tip } from '@/components/ui/tooltip' import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes' import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes' import { useI18n } from '@/i18n' @@ -94,17 +95,18 @@ function RowIconButton({ title: string }) { return ( - + + + ) } diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index c82ac082db4f..7d798e7ad849 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -36,7 +36,6 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' import { LayoutDashboard } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { Codecs, persistentAtom } from '@/lib/persisted' -import { toggleKeybindPanel } from '@/store/keybinds' import { $fileBrowserOpen, $panesFlipped, @@ -299,7 +298,7 @@ registry.registerMany([ id: 'keybinds.panel', label: 'Keyboard shortcuts', keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'], - run: toggleKeybindPanel + run: () => window.dispatchEvent(new CustomEvent('hermes:open-keybinds')) } satisfies PaletteContribution } ]) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 5d071ba2f0ee..3f7529fffe1c 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -88,7 +88,6 @@ import { useSessionListActions } from '../session/hooks/use-session-list-actions import { useSessionStateCache } from '../session/hooks/use-session-state-cache' import { useOverlayRouting } from '../shell/hooks/use-overlay-routing' import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width' -import { KeybindPanel } from '../shell/keybind-panel' import { titlebarControlsPosition } from '../shell/titlebar' import { TitlebarControls } from '../shell/titlebar-controls' import { UpdatesOverlay } from '../updates-overlay' @@ -244,6 +243,15 @@ export function ContribWiring({ children }: { children: ReactNode }) { const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate]) + // Palette "Keyboard shortcuts" entry dispatches a custom event (contributions + // don't have router access); listen and navigate to the settings keybinds tab. + useEffect(() => { + const onOpenKeybinds = () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`) + window.addEventListener('hermes:open-keybinds', onOpenKeybinds) + + return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds) + }, [navigate]) + // Post-turn rehydrate from stored history (same behavior as DesktopController, // including finished-todos restoration). const hydrateFromStoredSession = useCallback( @@ -944,9 +952,6 @@ export function ContribWiring({ children }: { children: ReactNode }) { )} - {/* The full hotkey map (⌘/ and the titlebar keyboard button). */} - - {/* Toasts above everything. */} diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index fb0cf2ce3702..34a6bfa1e1c6 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -9,7 +9,7 @@ import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } fro import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo' import { $repoStatus } from '@/store/coding-status' import { toggleCommandPalette } from '@/store/command-palette' -import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds' +import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds' import { requestSessionSearchFocus, setFileBrowserOpen, @@ -120,7 +120,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { } handlersRef.current = { - 'keybinds.openPanel': toggleKeybindPanel, + 'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`), 'composer.focus': () => requestComposerFocus('main'), 'composer.modelPicker': () => setModelPickerOpen(true), diff --git a/apps/desktop/src/app/master-detail.tsx b/apps/desktop/src/app/master-detail.tsx index 4064f1b98fe8..a8fe634d07d4 100644 --- a/apps/desktop/src/app/master-detail.tsx +++ b/apps/desktop/src/app/master-detail.tsx @@ -6,6 +6,7 @@ import { Codicon } from '@/components/ui/codicon' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { RowButton } from '@/components/ui/row-button' import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { $paneHeightOverride, $paneState, setPaneHeightOverride } from '@/store/panes' @@ -196,20 +197,24 @@ export function DetailPane({ {title}
{actions} - - {onClose && ( - + + {onClose && ( + + + )}
@@ -266,7 +271,6 @@ export function ListStripMenu({ 'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground' )} size="icon" - title={label} variant="ghost" > diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index cbf482d952c6..75ed0b556cf5 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -8,6 +8,7 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { ErrorBanner } from '@/components/ui/error-state' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' import { getMessagingPlatforms, type MessagingEnvVarInfo, @@ -609,22 +610,25 @@ function MessagingField({ value={edits[field.key] || ''} /> {field.url && ( - + + + )} {field.is_set && ( - + + + )}
} diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 4f05170d2d1b..455da2992119 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -3,6 +3,7 @@ import { type CSSProperties, type ReactNode, useEffect } from 'react' import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { translateNow } from '@/i18n' import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' import { triggerHaptic } from '@/lib/haptics' @@ -91,15 +92,17 @@ export function OverlayView({ )} - + + + {/* No top padding here: the split-layout columns own their own diff --git a/apps/desktop/src/app/overlays/panel.tsx b/apps/desktop/src/app/overlays/panel.tsx index 60fc2ebfb326..ac11c0d50b0b 100644 --- a/apps/desktop/src/app/overlays/panel.tsx +++ b/apps/desktop/src/app/overlays/panel.tsx @@ -5,6 +5,7 @@ import { Codicon } from '@/components/ui/codicon' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { RowButton } from '@/components/ui/row-button' import { SearchField } from '@/components/ui/search-field' +import { Tip } from '@/components/ui/tooltip' import { translateNow } from '@/i18n' import { cn } from '@/lib/utils' @@ -217,15 +218,16 @@ export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuIte return ( - + + + {items.map(item => ( @@ -353,16 +355,17 @@ export function PanelAddButton({ onClick: () => void }) { return ( - + + + ) } diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index d800137789f2..98da3e6b3c49 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -5,6 +5,7 @@ import { TreeSkeleton } from '@/components/chat/skeletons' import { ErrorBoundary } from '@/components/error-boundary' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { useDelayedTrue } from '@/hooks/use-delayed-true' import { useI18n } from '@/i18n' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' @@ -151,28 +152,30 @@ function FilesystemTab({
{cwdName}
- - + + + + + + - - + + + ) } diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 0f248c642026..f50047b1b7e3 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -12,6 +12,7 @@ import { Download, Globe, Info, + Keyboard, KeyRound, Package, RefreshCw, @@ -33,6 +34,7 @@ import { AppearanceSettings } from './appearance-settings' import { ConfigSettings } from './config-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' +import { KeybindSettings } from './keybind-settings' import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' import { NotificationsSettings } from './notifications-settings' import { PluginsSettings } from './plugins-settings' @@ -44,6 +46,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ ...SECTIONS.map(s => `config:${s.id}` as SettingsViewId), 'providers', 'gateway', + 'keybinds', 'keys', 'notifications', 'plugins', @@ -178,6 +181,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.gateway, onSelect: () => setActiveView('gateway') }, + { + active: activeView === 'keybinds', + icon: Keyboard, + id: 'keybinds', + label: t.settings.nav.keybinds, + onSelect: () => setActiveView('keybinds') + }, { active: activeView === 'keys', children: [ @@ -268,6 +278,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'gateway' ? ( + ) : activeView === 'keybinds' ? ( + ) : activeView.startsWith('config:') ? ( >(new Set()) + // Subscribe so contributed actions appear/disappear live in the map. + useContributions(KEYBINDS_AREA) + const actionList = allKeybindActions() + const [query, setQuery] = useState('') + + const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0] + + const toggleCategory = (category: string) => + setCollapsed(prev => { + const next = new Set(prev) + + if (next.has(category)) { + next.delete(category) + } else { + next.add(category) + } + + return next + }) + + // Filter actions and readonly shortcuts by label match against the query. + // When searching, categories auto-expand (collapsed state is ignored). + const isSearching = query.trim().length > 0 + + const filteredActions = useMemo(() => { + if (!isSearching) { + return null + } + + const lower = query.toLowerCase() + + return actionList.filter(action => { + if (action.id === KEYBIND_PANEL_ACTION) { + return false + } + + const label = k.actions[action.id] ?? action.id + + return label.toLowerCase().includes(lower) || action.id.includes(lower) + }) + }, [actionList, isSearching, query, k.actions]) + + const filteredReadonly = useMemo(() => { + if (!isSearching) { + return null + } + + const lower = query.toLowerCase() + + return KEYBIND_READONLY.filter(shortcut => { + const label = k.actions[shortcut.id] ?? shortcut.id + + return label.toLowerCase().includes(lower) || shortcut.id.includes(lower) + }) + }, [isSearching, query, k.actions]) + + return ( + +
+
+

{k.title}

+

+ {k.subtitle(openCombo ? formatCombo(openCombo) : '')} +

+
+ +
+ +
+ +
+ + {isSearching ? ( +
+ {filteredActions?.length === 0 && filteredReadonly?.length === 0 ? ( +

+ ) : ( + <> + {filteredActions?.map(action => ( + + ))} + {filteredReadonly?.map(shortcut => ( + + ))} + + )} +
+ ) : ( +
+ {KEYBIND_CATEGORIES.map(category => { + const actions = actionList.filter( + action => action.category === category && action.id !== KEYBIND_PANEL_ACTION + ) + + const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category) + + if (actions.length === 0 && readonly.length === 0) { + return null + } + + const sectionOpen = !collapsed.has(category) + + return ( +
+ toggleCategory(category)} + open={sectionOpen} + /> + {sectionOpen && actions.map(action => )} + {sectionOpen && readonly.map(shortcut => )} +
+ ) + })} +
+ )} +
+ ) +} + +function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) { + return ( + + ) +} + +function KeybindRow({ action }: { action: KeybindActionMeta }) { + const { t } = useI18n() + const k = t.keybinds + const bindings = useStore($bindings) + const capture = useStore($capture) + + // bindingsFor resolves stored overrides for late-registered (contributed) + // actions too — $bindings only carries built-ins, so a raw lookup would show + // the default instead of the user's rebinding for a plugin/contrib action. + const combos = bindingsFor(action.id, bindings) + const capturing = capture === action.id + const label = k.actions[action.id] ?? action.label ?? action.id + const isDefault = arraysEqual(combos, [...action.defaults]) + + const conflict = combos + .flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other)) + .find(Boolean) + + return ( +
+ {label} + + {conflict && ( + + + + )} + + {/* Click the caps to rebind — the on-screen editor does the same thing. */} + + + + + {/* Reset only shows once a binding diverges from its default; the spacer + holds the column otherwise so rows stay aligned. */} + {isDefault ? ( + + ) : ( + + + + )} +
+ ) +} + +// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and +// the trailing reset slot stays empty (spacer keeps the columns aligned). +function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) { + const { t } = useI18n() + const k = t.keybinds + const label = k.actions[shortcut.id] ?? shortcut.id + + return ( +
+ {label} +
+ {shortcut.keys.map(key => ( + + ))} +
+ +
+ ) +} diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx index 70c1ab6c509c..6c2932cd545f 100644 --- a/apps/desktop/src/app/settings/pet-settings.tsx +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { SegmentedControl } from '@/components/ui/segmented-control' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons' @@ -370,17 +371,18 @@ function PetAction({ onClick: () => void }) { return ( - + + + ) } diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index f3637c37a001..f7fd585547b8 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes' export type SettingsView = | 'about' | 'gateway' + | 'keybinds' | 'keys' | 'notifications' | 'plugins' diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 10d5171c74a4..565ceb67e08d 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -431,6 +431,7 @@ export function useStatusbarItems({ hidden: gatewayState !== 'open' }, { + actionId: 'view.showTerminal', className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, hidden: !chatOpen, icon: , diff --git a/apps/desktop/src/app/shell/keybind-panel.tsx b/apps/desktop/src/app/shell/keybind-panel.tsx deleted file mode 100644 index 7cbfca1a6035..000000000000 --- a/apps/desktop/src/app/shell/keybind-panel.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useStore } from '@nanostores/react' -import { Dialog as DialogPrimitive } from 'radix-ui' -import { useState } from 'react' - -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' -import { DisclosureCaret } from '@/components/ui/disclosure-caret' -import { Kbd, KbdCombo } from '@/components/ui/kbd' -import { useContributions } from '@/contrib/react/use-contributions' -import { useI18n } from '@/i18n' -import { - allKeybindActions, - KEYBIND_CATEGORIES, - KEYBIND_PANEL_ACTION, - KEYBIND_READONLY, - type KeybindActionMeta, - type KeybindReadonly, - KEYBINDS_AREA -} from '@/lib/keybinds/actions' -import { formatCombo } from '@/lib/keybinds/combo' -import { arraysEqual } from '@/lib/storage' -import { - $bindings, - $capture, - $keybindPanelOpen, - beginCapture, - bindingsFor, - closeKeybindPanel, - conflictsFor, - endCapture, - resetAllBindings, - resetBinding -} from '@/store/keybinds' - -// The full hotkey map. Quiet popover, click a row's chip to rebind. -export function KeybindPanel() { - const { t } = useI18n() - const open = useStore($keybindPanelOpen) - const bindings = useStore($bindings) - const k = t.keybinds - const [collapsed, setCollapsed] = useState>(new Set()) - // Subscribe so contributed actions appear/disappear live in the map. - useContributions(KEYBINDS_AREA) - const actionList = allKeybindActions() - - const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0] - - const toggleCategory = (category: string) => - setCollapsed(prev => { - const next = new Set(prev) - - if (next.has(category)) { - next.delete(category) - } else { - next.add(category) - } - - return next - }) - - return ( - !next && closeKeybindPanel()} open={open}> - - - - {/* Header */} -
-
- {k.title} - - {k.subtitle(openCombo ? formatCombo(openCombo) : '')} - -
- -
- - {/* Body */} -
- {KEYBIND_CATEGORIES.map(category => { - const actions = actionList.filter( - action => action.category === category && action.id !== KEYBIND_PANEL_ACTION - ) - - const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category) - - if (actions.length === 0 && readonly.length === 0) { - return null - } - - const sectionOpen = !collapsed.has(category) - - return ( -
- toggleCategory(category)} - open={sectionOpen} - /> - {sectionOpen && actions.map(action => )} - {sectionOpen && readonly.map(shortcut => )} -
- ) - })} -
-
-
-
- ) -} - -// Collapsible category header — chevron fades in on hover, rotates when open -// (matches the sessions sidebar section pattern). -function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) { - return ( - - ) -} - -function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) { - return ( - - ) -} - -function KeybindRow({ action }: { action: KeybindActionMeta }) { - const { t } = useI18n() - const k = t.keybinds - const bindings = useStore($bindings) - const capture = useStore($capture) - - // bindingsFor resolves stored overrides for late-registered (contributed) - // actions too — $bindings only carries built-ins, so a raw lookup would show - // the default instead of the user's rebinding for a plugin/contrib action. - const combos = bindingsFor(action.id, bindings) - const capturing = capture === action.id - const label = k.actions[action.id] ?? action.label ?? action.id - const isDefault = arraysEqual(combos, [...action.defaults]) - - const conflict = combos - .flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other)) - .find(Boolean) - - return ( -
- {label} - - {conflict && ( - - - - )} - - {/* Click the caps to rebind — the on-screen editor does the same thing. */} - - - {/* Reset only shows once a binding diverges from its default; the spacer - holds the column otherwise so rows stay aligned. */} - {isDefault ? ( - - ) : ( - - )} -
- ) -} - -// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and -// the trailing reset slot stays empty (spacer keeps the columns aligned). -function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) { - const { t } = useI18n() - const k = t.keybinds - const label = k.actions[shortcut.id] ?? shortcut.id - - return ( -
- {label} -
- {shortcut.keys.map(key => ( - - ))} -
- -
- ) -} diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 096af07a96c3..0b265bb3d3cf 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -2,7 +2,7 @@ import { type ComponentProps, type ReactNode, useState } from 'react' import { useNavigate } from 'react-router-dom' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' // Shared chrome styling for interactive statusbar items (button / link / menu @@ -42,6 +42,8 @@ export interface StatusbarItem { menuContent?: ((close: () => void) => ReactNode) | ReactNode menuItems?: readonly StatusbarMenuItem[] onSelect?: (modifiers: StatusbarSelectModifiers) => void + /** Keybind action id — when set, the tooltip shows the label + keybind hint. */ + actionId?: string title?: string to?: string variant?: 'action' | 'link' | 'menu' | 'text' @@ -101,6 +103,8 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: return <>{item.render()} } + const tooltipLabel = item.actionId ? : item.title + const content = ( <> {item.icon} @@ -129,7 +133,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: {trigger} - {item.title} + {tooltipLabel} ) : ( @@ -185,7 +189,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) { return ( - +
+ {content} @@ -209,7 +213,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: } return ( - + + + +

{prettyName(name)}

@@ -1283,28 +1285,30 @@ function ServerIconActions({ return ( - - + + + + + + ) } diff --git a/apps/desktop/src/app/starmap/share-controls.tsx b/apps/desktop/src/app/starmap/share-controls.tsx index 45c9bcc0f1b4..9db2a4f2f682 100644 --- a/apps/desktop/src/app/starmap/share-controls.tsx +++ b/apps/desktop/src/app/starmap/share-controls.tsx @@ -10,6 +10,7 @@ import { DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { Upload } from '@/lib/icons' @@ -76,15 +77,16 @@ export function ShareControls({ imported = false, onImport, onResetMap, shareCod open={open} > - + + + diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 129acd3179dd..6eac1a3dd754 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -132,6 +132,8 @@ export interface SidebarNavItem { icon: React.ComponentType<{ className?: string }> route?: string action?: 'new-session' + /** Keybind action id — when set, the tooltip shows the keybind hint. */ + keybindActionId?: string } export interface ClientSessionState { diff --git a/apps/desktop/src/components/language-switcher.tsx b/apps/desktop/src/components/language-switcher.tsx index f54c850364ca..d9f330e1f2ee 100644 --- a/apps/desktop/src/components/language-switcher.tsx +++ b/apps/desktop/src/components/language-switcher.tsx @@ -66,7 +66,6 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false )} disabled={isSavingLocale} size="sm" - title={title} type="button" variant="outline" > diff --git a/apps/desktop/src/components/ui/__tests__/no-native-title.test.ts b/apps/desktop/src/components/ui/__tests__/no-native-title.test.ts new file mode 100644 index 000000000000..23519907c672 --- /dev/null +++ b/apps/desktop/src/components/ui/__tests__/no-native-title.test.ts @@ -0,0 +1,70 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +// Static-analysis guard: no + + + ) : null diff --git a/apps/desktop/src/components/ui/search-field.tsx b/apps/desktop/src/components/ui/search-field.tsx index 142229b8967d..a2c02d38a162 100644 --- a/apps/desktop/src/components/ui/search-field.tsx +++ b/apps/desktop/src/components/ui/search-field.tsx @@ -2,6 +2,7 @@ import { type ReactNode, type RefObject, useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { Loader2, Search } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -86,15 +87,17 @@ export function SearchField({ {loading ? ( ) : value ? ( - + + + ) : null}
) diff --git a/apps/desktop/src/components/ui/sidebar.tsx b/apps/desktop/src/components/ui/sidebar.tsx index 96d00e8b7ef3..fe2aac78cf86 100644 --- a/apps/desktop/src/components/ui/sidebar.tsx +++ b/apps/desktop/src/components/ui/sidebar.tsx @@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' import { Skeleton } from '@/components/ui/skeleton' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useIsMobile } from '@/hooks/use-mobile' import { useI18n } from '@/i18n' import { PanelLeftIcon } from '@/lib/icons' @@ -256,24 +256,25 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { const { t } = useI18n() return ( - + + + ) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 31c0a734f302..49517fc8902a 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -198,6 +198,7 @@ export const en: Translations = { keybinds: { title: 'Keyboard shortcuts', subtitle: open => `Click a shortcut to rebind it · ${open} reopens this panel.`, + search: 'Search shortcuts…', rebind: 'Rebind', reset: 'Reset to default', resetAll: 'Reset all', @@ -315,6 +316,7 @@ export const en: Translations = { providerApiKeys: 'API keys', gateway: 'Gateway', apiKeys: 'Tools & Keys', + keybinds: 'Keyboard Shortcuts', keysTools: 'Tools', keysSettings: 'Settings', mcp: 'MCP', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f6bb2c0d7b36..4d6d1e67ec94 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -217,6 +217,7 @@ export const ja = defineLocale({ providerApiKeys: 'API キー', gateway: 'ゲートウェイ', apiKeys: 'ツールとキー', + keybinds: 'キーボードショートカット', keysTools: 'ツール', keysSettings: '設定', mcp: 'MCP', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5962d317221b..d417242b7904 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -239,6 +239,7 @@ export interface Translations { keybinds: { title: string subtitle: (open: string) => string + search: string rebind: string reset: string resetAll: string @@ -273,6 +274,7 @@ export interface Translations { providerApiKeys: string gateway: string apiKeys: string + keybinds: string keysTools: string keysSettings: string mcp: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index a02abba62e0a..d8e1f420d001 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -211,6 +211,7 @@ export const zhHant = defineLocale({ providerApiKeys: 'API 金鑰', gateway: '閘道', apiKeys: '工具與金鑰', + keybinds: '鍵盤快捷鍵', keysTools: '工具', keysSettings: '設定', mcp: 'MCP', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1422fb886d7b..18d1448e3c3b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -193,6 +193,7 @@ export const zh: Translations = { keybinds: { title: '键盘快捷键', subtitle: open => `点击快捷键即可重新绑定 · ${open} 可重新打开此面板。`, + search: '搜索快捷键…', rebind: '重新绑定', reset: '恢复默认', resetAll: '全部重置', @@ -306,6 +307,7 @@ export const zh: Translations = { providerApiKeys: 'API 密钥', gateway: '网关', apiKeys: '工具与密钥', + keybinds: '键盘快捷键', keysTools: '工具', keysSettings: '设置', mcp: 'MCP', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index df12f7f59097..0260ce82c6c7 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -48,6 +48,7 @@ import { IconHelpCircle as HelpCircle, IconPhoto as ImageIcon, IconInfoCircle as Info, + IconKeyboard as Keyboard, IconKey as KeyRound, IconLayersIntersect2 as Layers3, IconLayoutDashboard as LayoutDashboard, @@ -165,6 +166,7 @@ export { HelpCircle, ImageIcon, Info, + Keyboard, KeyRound, Layers3, LayoutDashboard, diff --git a/apps/desktop/src/lib/keybinds/use-keybind-hint.ts b/apps/desktop/src/lib/keybinds/use-keybind-hint.ts new file mode 100644 index 000000000000..ba6f0425be01 --- /dev/null +++ b/apps/desktop/src/lib/keybinds/use-keybind-hint.ts @@ -0,0 +1,28 @@ +import { useStore } from '@nanostores/react' + +import { $bindings } from '@/store/keybinds' + +import { KEYBIND_READONLY } from './actions' +import { formatCombo } from './combo' + +// The formatted first combo for `actionId`, or null when unbound. Rebindable +// actions read live from the store; readonly shortcuts (e.g. `composer.steer`) +// fall back to their fixed combo. Returns null for unknown action ids so the +// tooltip shows just the text label with no trailing hint. +export function useKeybindHint(actionId: string): string | null { + const bindings = useStore($bindings) + + const rebindable = bindings[actionId]?.[0] + + if (rebindable) { + return formatCombo(rebindable) + } + + const readonly = KEYBIND_READONLY.find(entry => entry.id === actionId) + + if (readonly) { + return formatCombo(readonly.keys[0]) + } + + return null +} diff --git a/apps/desktop/src/store/keybinds.ts b/apps/desktop/src/store/keybinds.ts index 6aeeb60764ea..50c50d6bc297 100644 --- a/apps/desktop/src/store/keybinds.ts +++ b/apps/desktop/src/store/keybinds.ts @@ -141,24 +141,3 @@ export function beginCapture(actionId: string): void { export function endCapture(): void { $capture.set(null) } - -// ── Panel ─────────────────────────────────────────────────────────────────── - -export const $keybindPanelOpen = atom(false) - -export function openKeybindPanel(): void { - $keybindPanelOpen.set(true) -} - -export function closeKeybindPanel(): void { - $keybindPanelOpen.set(false) - $capture.set(null) -} - -export function toggleKeybindPanel(): void { - if ($keybindPanelOpen.get()) { - closeKeybindPanel() - } else { - openKeybindPanel() - } -}