hermes-agent/apps/desktop/src/app/settings/keybind-settings.tsx
ethernet f08b1f3445
feat(desktop): button tooltip keybind hints + keybinds settings tab + unified worktree dialog (#65204)
* feat(desktop): add useKeybindHint hook and TipKeybindLabel

Add a shared hook that reads the current keybind combo for an action
id from the $bindings store (rebindable) or KEYBIND_READONLY (fixed),
returning a formatted string or null when unbound.

Add TipKeybindLabel — a convenience component that auto-reads both
its label (from i18n) and keybind combo from the action registry.
Pass only actionId for the common case; pass text to override when
the tooltip is context-dependent.

* fix(desktop): replace native title= on buttons with themed Tip

Migrate all <button>/<Button> elements using the native HTML title=
attribute to the instant, themed <Tip> component. Native tooltips are
unstyled, delayed (~500ms OS default), and visually inconsistent with
the app's instant themed tooltips.

Also adds <Tip> wrappers to icon-only buttons that were missing
tooltips entirely (dialog close, search clear, overlay close,
master-detail pane controls, keybind panel rebind/reset buttons).

Adds an enforcement test (no-native-title.test.ts) that scans all
.tsx files for <button>/<Button> with title= and fails if any are
found. Updates DESIGN.md with the icon-only button tooltip rule and
keybind hint guidance.

* feat(desktop): wire keybind hints into button tooltips

Add actionId to TitlebarTool, StatusbarItem, and SidebarNavItem so
their tooltips show the current keybind combo via TipKeybindLabel.
Fix the hardcoded NEW_SESSION_KBD in the sidebar to read from
$bindings so it stays live on rebind.

Wired surfaces:
- Titlebar: sidebar toggle, flip panes, keybinds, settings
- Statusbar: terminal toggle (view.showTerminal)
- Status stack: open agents button (nav.agents)
- Sidebar nav: new session, skills, messaging, artifacts

* feat(desktop): move keybind panel to settings tab with search filter

Move the keyboard shortcuts panel from a Radix Dialog into a proper
Settings tab (/settings?tab=keybinds). The ⌘/ shortcut and titlebar
keyboard button now navigate to this settings tab instead of toggling
a dialog. Adds a search filter to filter shortcuts by label.

- New: src/app/settings/keybind-settings.tsx (extracted from keybind-panel.tsx)
- Delete: src/app/shell/keybind-panel.tsx (dialog wrapper removed)
- Remove: $keybindPanelOpen atom and toggle/open/close functions
- Add: IconKeyboard to lib/icons.ts
- i18n: keybinds.search + settings.nav.keybinds (en, zh, zh-hant, ja)

* refactor(desktop): unify worktree dialog into shared WorktreeDialog

Extract the worktree creation dialog from StartWorkButton (sidebar) into a
shared WorktreeDialog component. Both the sidebar's StartWorkButton and the
composer's CodingStatusRow now use the same dialog, eliminating the duplicated
UI.

The shared dialog keeps the sidebar version's full feature set:
- BaseBranchPicker (filterable base branch combobox)
- Convert mode (check out an existing branch into a worktree)
- Sanitized branch name input

The coding row passes repoPath (from cwd) and onOpenWorktree (which carries
the composer draft to the new session) so the unified dialog works everywhere
there's a repo, not just inside an entered project.
2026-07-16 18:26:21 -04:00

274 lines
9 KiB
TypeScript

import { useStore } from '@nanostores/react'
import { useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
import { Tip } from '@/components/ui/tooltip'
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,
beginCapture,
bindingsFor,
conflictsFor,
endCapture,
resetAllBindings,
resetBinding
} from '@/store/keybinds'
import { SettingsContent } from './primitives'
export function KeybindSettings() {
const { t } = useI18n()
const bindings = useStore($bindings)
const k = t.keybinds
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(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 (
<SettingsContent>
<div className="flex items-center justify-between gap-3 pb-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{k.title}</h2>
<p className="mt-0.5 text-[0.72rem] text-muted-foreground">
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
</p>
</div>
<button
className="flex shrink-0 items-center gap-1 rounded-md text-[0.72rem] text-muted-foreground hover:text-foreground"
onClick={resetAllBindings}
type="button"
>
<Codicon name="discard" size="0.8125rem" />
{k.resetAll}
</button>
</div>
<div className="pb-3">
<SearchField
aria-label={k.search}
containerClassName="w-full"
onChange={setQuery}
placeholder={k.search}
value={query}
/>
</div>
{isSearching ? (
<div className="px-2 py-1.5">
{filteredActions?.length === 0 && filteredReadonly?.length === 0 ? (
<p className="px-2.5 py-4 text-center text-[0.82rem] text-muted-foreground"></p>
) : (
<>
{filteredActions?.map(action => (
<KeybindRow action={action} key={action.id} />
))}
{filteredReadonly?.map(shortcut => (
<ReadonlyRow key={shortcut.id} shortcut={shortcut} />
))}
</>
)}
</div>
) : (
<div className="px-2 py-1.5">
{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 (
<section key={category}>
<CategoryHeader
label={k.categories[category] ?? category}
onToggle={() => toggleCategory(category)}
open={sectionOpen}
/>
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
</section>
)
})}
</div>
)}
</SettingsContent>
)
}
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
return (
<button
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
onClick={onToggle}
type="button"
>
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
open={open}
size="0.6875rem"
/>
</button>
)
}
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 (
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
{conflict && (
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
<Codicon name="warning" size="0.8125rem" />
</span>
)}
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
<Tip label={k.rebind}>
<button
aria-label={k.rebind}
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
type="button"
>
{capturing ? (
<Kbd variant="capturing">{k.pressKey}</Kbd>
) : combos.length > 0 ? (
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
) : (
<Kbd variant="ghost">{k.set}</Kbd>
)}
</button>
</Tip>
{/* Reset only shows once a binding diverges from its default; the spacer
holds the column otherwise so rows stay aligned. */}
{isDefault ? (
<span aria-hidden className="size-6 shrink-0" />
) : (
<Tip label={k.reset}>
<button
aria-label={k.reset}
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
onClick={() => resetBinding(action.id)}
type="button"
>
<Codicon name="discard" size="0.8125rem" />
</button>
</Tip>
)}
</div>
)
}
// 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 (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<KbdCombo combo={key} key={key} />
))}
</div>
<span aria-hidden className="size-6 shrink-0" />
</div>
)
}