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.
This commit is contained in:
ethernet 2026-07-16 18:26:21 -04:00 committed by GitHub
parent f1315ae91e
commit f08b1f3445
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1091 additions and 946 deletions

View file

@ -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 `<Tip label={...}>`
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 `<button>`
or `<Button>` that still carries `title=`.
**Keybind hints in tooltips.** When a button corresponds to a rebindable
hotkey, use `<TipKeybindLabel actionId="..." />` as the `Tip` label — it
auto-reads both the i18n label and the current keybind combo from the store,
so the hint stays live when the user rebinds. Pass `text={...}` only when the
tooltip is context-dependent (e.g. "Show" / "Hide" based on state). Never
hardcode combos in components — always read from the `$bindings` store via
`useKeybindHint` or `TipKeybindLabel`.
Notes:
- Text buttons are square (no radius) and sized by padding + line-height (no
fixed heights). Only icon buttons carry the shared 4px radius.
@ -278,6 +294,9 @@ The detailed state contract lives in the scoped
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
one-off shadows?
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
- [ ] Icon-only buttons wrapped in `<Tip>` with a descriptive label?
- [ ] No native `title=` on buttons — use `<Tip>` instead?
- [ ] Keybind hints read from the store via `useKeybindHint` / `TipKeybindLabel`?
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
- [ ] Flat — no card-in-card, no gratuitous row dividers?
- [ ] No automatic navigation, focus steal, or pane opening from background

View file

@ -203,7 +203,6 @@ function ConversationPill({
triggerHaptic('submit')
onStopTurn()
}}
title={c.stopListening}
type="button"
variant="ghost"
>
@ -219,7 +218,6 @@ function ConversationPill({
triggerHaptic('close')
onEnd()
}}
title={c.endConversation}
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />

View file

@ -945,6 +945,7 @@ export function ChatBar({
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
repoPath={cwd}
/>
<div
className={cn(

View file

@ -1,18 +1,10 @@
import { useStore } from '@nanostores/react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { WorktreeDialog } from '@/app/chat/sidebar/projects/worktree-dialog'
import { StatusRow } from '@/components/chat/status-row'
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 { DiffCount } from '@/components/ui/diff-count'
import {
DropdownMenu,
@ -22,10 +14,8 @@ 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 { $repoStatus, $repoWorktrees } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $newWorktreeRequest } from '@/store/projects'
@ -33,20 +23,6 @@ import { $newWorktreeRequest } from '@/store/projects'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
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
}
interface CodingStatusRowProps {
/** Branch the current draft off into a fresh worktree + session, based on
* `base` (a branch name; omitted = current HEAD). The composer owns the
@ -64,6 +40,8 @@ interface CodingStatusRowProps {
onOpenWorktree?: (path: string) => void
/** Switch the current repo checkout to another branch. */
onSwitchBranch?: (branch: string) => Promise<void>
/** Repo root path for the worktree dialog. */
repoPath?: null | string
}
/**
@ -79,7 +57,8 @@ export const CodingStatusRow = memo(function CodingStatusRow({
onListBranches,
onOpen,
onOpenWorktree,
onSwitchBranch
onSwitchBranch,
repoPath
}: CodingStatusRowProps) {
const { t } = useI18n()
const s = t.statusStack.coding
@ -87,113 +66,11 @@ export const CodingStatusRow = memo(function CodingStatusRow({
const status = useStore($repoStatus)
const worktrees = useStore($repoWorktrees)
const [branchOpen, setBranchOpen] = useState(false)
const [branchName, setBranchName] = useState('')
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
const [branchPending, setBranchPending] = useState(false)
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const loadBranches = useCallback(async () => {
if (!onListBranches) {
return
}
setBranchesLoading(true)
try {
setBranches(await onListBranches())
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [onListBranches])
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
// closing before the dialog grabs focus (Radix focus-trap handoff races
// otherwise).
const startBranch = (base: string | undefined) => {
setBranchBase(base)
setBranchName('')
setConvertMode(false)
setTimeout(() => setBranchOpen(true), 0)
}
const startConvert = () => {
setBranchBase(undefined)
setBranchName('')
setConvertMode(true)
void loadBranches()
setTimeout(() => setBranchOpen(true), 0)
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
const convertBranch = async (branch: HermesGitBranch) => {
if (branchPending || !branch || !onConvertBranch) {
return
}
setBranchPending(true)
try {
await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault)
setBranchOpen(false)
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
// mount or unrelated re-renders.
const worktreeReq = useStore($newWorktreeRequest)
const lastWorktreeReqRef = useRef(worktreeReq)
useEffect(() => {
if (worktreeReq === lastWorktreeReqRef.current) {
return
}
lastWorktreeReqRef.current = worktreeReq
if (!onBranchOff) {
return
}
setBranchBase(undefined)
setBranchName('')
setConvertMode(false)
setBranchOpen(true)
}, [onBranchOff, worktreeReq])
const submitBranch = async () => {
const branch = branchName.trim()
if (branchPending || !branch || !onBranchOff) {
return
}
setBranchPending(true)
try {
await onBranchOff(branch, branchBase)
setBranchOpen(false)
setBranchName('')
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
// Shared worktree dialog — replaces the old inline dialog. Opened by the
// dropdown menu's "branch off" items and the global ⌘⇧B hotkey.
const [worktreeOpen, setWorktreeOpen] = useState(false)
const [worktreeBase, setWorktreeBase] = useState<string | undefined>(undefined)
const resolvedRepoPath = repoPath?.trim() || undefined
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
@ -207,6 +84,34 @@ export const CodingStatusRow = memo(function CodingStatusRow({
}
}
// Global ⌘⇧B (workspace.newWorktree): open the shared worktree dialog. The
// coding row only renders inside a repo, so the hotkey naturally no-ops
// elsewhere. Guarded by a token ref so it fires on the keypress, not on
// mount or unrelated re-renders.
const worktreeReq = useStore($newWorktreeRequest)
const lastWorktreeReqRef = useRef(worktreeReq)
useEffect(() => {
if (worktreeReq === lastWorktreeReqRef.current) {
return
}
lastWorktreeReqRef.current = worktreeReq
if (!resolvedRepoPath || !onOpenWorktree) {
return
}
setWorktreeBase(undefined)
setWorktreeOpen(true)
}, [onOpenWorktree, resolvedRepoPath, worktreeReq])
// Open the worktree dialog from the dropdown menu with a pre-selected base.
const startBranch = (base: string | undefined) => {
setWorktreeBase(base)
setTimeout(() => setWorktreeOpen(true), 0)
}
if (!status) {
return null
}
@ -320,9 +225,10 @@ export const CodingStatusRow = memo(function CodingStatusRow({
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.startWork}</span>
</DropdownMenuItem>
{/* Check an EXISTING branch out into a worktree (no new branch). */}
{/* Create a fresh worktree off the current HEAD (the generic
"spin up a worktree here", mirroring the sidebar's + button). */}
{onConvertBranch && (
<DropdownMenuItem onSelect={() => startConvert()}>
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.convertBranch}</span>
</DropdownMenuItem>
)}
@ -363,107 +269,15 @@ export const CodingStatusRow = memo(function CodingStatusRow({
) : null}
</StatusRow>
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
{!convertMode && branchBase && (
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
)}
</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
// The branch name is the authoritative key; filter on it directly.
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={branchPending}
key={branch.name}
onSelect={() => void convertBranch(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={branchPending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submitBranch()
} else if (event.key === 'Escape') {
setBranchOpen(false)
}
}}
onValueChange={setBranchName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={branchName}
/>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
{onConvertBranch ? (
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={branchPending || !branchName.trim()}
onClick={() => void submitBranch()}
type="button"
>
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
{resolvedRepoPath && onOpenWorktree && (
<WorktreeDialog
initialBase={worktreeBase}
onOpenChange={setWorktreeOpen}
onStarted={onOpenWorktree}
open={worktreeOpen}
repoPath={resolvedRepoPath}
/>
)}
</>
)
})

View file

@ -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
<StatusSection
accessory={
group.type === 'subagent' ? (
<Button
className="text-muted-foreground/75 hover:text-foreground/90"
onClick={openAgents}
size="micro"
type="button"
variant="text"
>
{t.statusStack.agents}
</Button>
<Tip label={<TipKeybindLabel actionId="nav.agents" text={t.statusStack.agents} />}>
<Button
className="text-muted-foreground/75 hover:text-foreground/90"
onClick={openAgents}
size="micro"
type="button"
variant="text"
>
{t.statusStack.agents}
</Button>
</Tip>
) : undefined
}
defaultCollapsed={group.type !== 'todo'}

View file

@ -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 ? (
<button
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
onClick={beginEdit}
title={`${t.preview.edit} (e)`}
type="button"
>
<Pencil className="size-3" />
{t.preview.edit}
</button>
<Tip label={`${t.preview.edit} (e)`}>
<button
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
onClick={beginEdit}
type="button"
>
<Pencil className="size-3" />
{t.preview.edit}
</button>
</Tip>
) : null
}
/>

View file

@ -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. */}
<Tip label={label}>
<button
aria-expanded={expanded}
aria-label={expanded ? c.hideRuns : c.showRuns}
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onTogglePeek}
title={label}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
@ -230,6 +230,7 @@ function CronJobSidebarRow({
open={expanded}
/>
</button>
</Tip>
{/* Trailing cluster: countdown by default, quick actions on hover. */}
<div className="flex items-center gap-0.5 justify-self-end pr-1">
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">

View file

@ -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 => <Codicon name="robot" {...props} />,
action: 'new-session'
action: 'new-session',
keybindActionId: 'session.new'
},
{
id: 'skills',
label: '',
icon: props => <Codicon name="symbol-misc" {...props} />,
route: SKILLS_ROUTE
route: SKILLS_ROUTE,
keybindActionId: 'nav.skills'
},
{ id: 'messaging', label: '', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: '', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
{
id: 'messaging',
label: '',
icon: props => <Codicon name="comment" {...props} />,
route: MESSAGING_ROUTE,
keybindActionId: 'nav.messaging'
},
{
id: 'artifacts',
label: '',
icon: props => <Codicon name="files" {...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<SessionSearchResult[]>([])
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: (
<TipKeybindLabel actionId={item.keybindActionId} text={s.nav[item.id] ?? item.label} />
)
}
: (s.nav[item.id] ?? item.label)
}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
@ -1131,7 +1155,7 @@ export function ChatSidebar({
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
keys={newSessionKbd}
size="sm"
/>
)}

View file

@ -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<HermesGitBranch[]>([])
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 (
<>
<button
aria-label={p.startWork}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
onClick={() => {
setConvertMode(false)
setName('')
setSelectedBase('')
setOpen(true)
}}
onClick={() => setOpen(true)}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
<Dialog onOpenChange={next => !pending && setOpen(next)} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={pending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={pending}
key={branch.name}
onSelect={() => void convert(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<>
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
setOpen(false)
}
}}
onValueChange={setName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
<BaseBranchPicker
disabled={pending}
onValueChange={setSelectedBase}
repoPath={repoPath}
value={selectedBase}
/>
</>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
<div className="flex items-center gap-2">
<Button disabled={pending} onClick={() => setOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
<WorktreeDialog onOpenChange={setOpen} onStarted={onStarted} open={open} repoPath={repoPath} />
</>
)
}

View file

@ -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<HermesGitBranch[]>([])
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 (
<Dialog onOpenChange={next => !pending && onOpenChange(next)} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={pending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={pending}
key={branch.name}
onSelect={() => void convert(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<>
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
onOpenChange(false)
}
}}
onValueChange={setName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
<BaseBranchPicker
disabled={pending}
onValueChange={setSelectedBase}
repoPath={repoPath}
value={selectedBase}
/>
</>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
<div className="flex items-center gap-2">
<Button disabled={pending} onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -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"
>
<Codicon name="kebab-vertical" size="0.875rem" />

View file

@ -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 (
<Button
aria-label={title}
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
onClick={onClick}
size="icon-xs"
title={title}
type="button"
variant="ghost"
>
{children}
</Button>
<Tip label={title}>
<Button
aria-label={title}
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
onClick={onClick}
size="icon-xs"
type="button"
variant="ghost"
>
{children}
</Button>
</Tip>
)
}

View file

@ -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
}
])

View file

@ -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 }) {
</Suspense>
)}
{/* The full hotkey map (⌘/ and the titlebar keyboard button). */}
<KeybindPanel />
{/* Toasts above everything. */}
<NotificationStack />

View file

@ -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),

View file

@ -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({
<span className="min-w-0 truncate text-xs font-medium text-foreground">{title}</span>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{actions}
<Button
aria-expanded={!collapsed}
aria-label={collapsed ? t.common.expand : t.common.collapse}
className={ICON_BUTTON}
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
size="icon"
variant="ghost"
>
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
</Button>
{onClose && (
<Button aria-label={t.common.close} className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
<Codicon name="close" size="0.8125rem" />
<Tip label={collapsed ? t.common.expand : t.common.collapse}>
<Button
aria-expanded={!collapsed}
aria-label={collapsed ? t.common.expand : t.common.collapse}
className={ICON_BUTTON}
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
size="icon"
variant="ghost"
>
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
</Button>
</Tip>
{onClose && (
<Tip label={t.common.close}>
<Button aria-label={t.common.close} className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
<Codicon name="close" size="0.8125rem" />
</Button>
</Tip>
)}
</div>
</header>
@ -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"
>
<Codicon name="kebab-vertical" size="0.8125rem" />

View file

@ -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 && (
<Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
<Tip label={m.openDocs}>
<Button asChild className="size-8 shrink-0" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
</Tip>
)}
{field.is_set && (
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={m.clearField(field.key)}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
<Tip label={m.clearField(field.key)}>
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</Tip>
)}
</div>
}

View file

@ -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({
</div>
)}
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon-titlebar"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
<Tip label={closeLabel}>
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon-titlebar"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
</Tip>
</div>
{/* No top padding here: the split-layout columns own their own

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={label}
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
size="icon"
title={label}
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
<Tip label={label}>
<Button
aria-label={label}
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
size="icon"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</Tip>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40" sideOffset={6}>
{items.map(item => (
@ -353,16 +355,17 @@ export function PanelAddButton({
onClick: () => void
}) {
return (
<Button
aria-label={label}
className="h-7 w-full shrink-0 justify-center text-muted-foreground/70 hover:bg-(--ui-row-hover-background) hover:text-foreground"
onClick={onClick}
size="sm"
title={label}
variant="ghost"
>
<Codicon name={icon} size="0.875rem" />
</Button>
<Tip label={label}>
<Button
aria-label={label}
className="h-7 w-full shrink-0 justify-center text-muted-foreground/70 hover:bg-(--ui-row-hover-background) hover:text-foreground"
onClick={onClick}
size="sm"
variant="ghost"
>
<Codicon name={icon} size="0.875rem" />
</Button>
</Tip>
)
}

View file

@ -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({
<div className="flex min-w-0 flex-1">
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</div>
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={loading}
onClick={onRefresh}
size="icon-xs"
title={r.refreshTree}
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!canCollapse}
onClick={onCollapseAll}
size="icon-xs"
title={r.collapseAll}
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
<Tip label={r.refreshTree}>
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={loading}
onClick={onRefresh}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</Tip>
<Tip label={r.collapseAll}>
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!canCollapse}
onClick={onCollapseAll}
size="icon-xs"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
</Tip>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}

View file

@ -9,6 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ExternalLink, Eye, EyeOff, Trash2 } from '@/lib/icons'
@ -119,15 +120,16 @@ export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActio
const copy = t.settings.envActions
return (
<Button
aria-label={copy.actionsFor(label)}
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
title={copy.credentialActions}
variant="ghost"
{...props}
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
<Tip label={copy.credentialActions}>
<Button
aria-label={copy.actionsFor(label)}
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
variant="ghost"
{...props}
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</Tip>
)
}

View file

@ -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
<AboutSettings />
) : activeView === 'gateway' ? (
<GatewaySettings />
) : activeView === 'keybinds' ? (
<KeybindSettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}

View file

@ -0,0 +1,274 @@
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>
)
}

View file

@ -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 (
<button
aria-label={label}
className={cn(
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
)}
onClick={onClick}
title={label}
type="button"
>
{icon}
</button>
<Tip label={label}>
<button
aria-label={label}
className={cn(
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
)}
onClick={onClick}
type="button"
>
{icon}
</button>
</Tip>
)
}

View file

@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView =
| 'about'
| 'gateway'
| 'keybinds'
| 'keys'
| 'notifications'
| 'plugins'

View file

@ -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: <Terminal className="size-3.5" />,

View file

@ -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<ReadonlySet<string>>(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 (
<DialogPrimitive.Root onOpenChange={next => !next && closeKeybindPanel()} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/25 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-4 py-3">
<div className="min-w-0">
<DialogPrimitive.Title className="text-sm font-semibold text-foreground">{k.title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-0.5 text-[0.72rem] text-muted-foreground">
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
</DialogPrimitive.Description>
</div>
<HeaderButton icon="discard" label={k.resetAll} onClick={resetAllBindings} />
</div>
{/* Body */}
<div className="min-h-0 flex-1 overflow-y-auto 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>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
// 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 (
<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 HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<Button className="shrink-0 text-[0.72rem]" onClick={onClick} size="xs" variant="text">
<Codicon name={icon} size="0.8125rem" />
{label}
</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. */}
<button
aria-label={k.rebind}
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
title={k.rebind}
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>
{/* 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" />
) : (
<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)}
title={k.reset}
type="button"
>
<Codicon name="discard" size="0.8125rem" />
</button>
)}
</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>
)
}

View file

@ -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 ? <TipKeybindLabel actionId={item.actionId} text={item.title} /> : item.title
const content = (
<>
{item.icon}
@ -129,7 +133,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent>{item.title}</TooltipContent>
<TooltipContent>{tooltipLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
@ -185,7 +189,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
return (
<Tip label={item.title}>
<Tip label={tooltipLabel}>
<div
className={cn(
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
@ -200,7 +204,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.href || item.variant === 'link') {
return (
<Tip label={item.title}>
<Tip label={tooltipLabel}>
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
{content}
</a>
@ -209,7 +213,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
}
return (
<Tip label={item.title}>
<Tip label={tooltipLabel}>
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}

View file

@ -6,7 +6,7 @@ import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -19,7 +19,7 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import { appViewForPath, isOverlayView } from '../routes'
import { appViewForPath, isOverlayView, SETTINGS_ROUTE } from '../routes'
import { titlebarButtonClass } from './titlebar'
@ -33,6 +33,8 @@ export interface TitlebarTool {
href?: string
icon: ReactNode
onSelect?: (event?: MouseEvent) => void
/** Keybind action id — when set, the tooltip shows the label + keybind hint. */
actionId?: string
title?: string
to?: string
}
@ -123,6 +125,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const leftToolbarTools: TitlebarTool[] = [
{
actionId: 'view.toggleSidebar',
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
@ -132,6 +135,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
},
{
actionId: 'view.flipPanes',
icon: <Codicon name="arrow-swap" />,
id: 'flip-panes',
label: t.titlebar.swapSidebarSides,
@ -145,6 +149,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
]
const rightSidebarTool: TitlebarTool = {
actionId: 'view.toggleRightSidebar',
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
@ -184,6 +189,17 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
onSelect: toggleHaptics
},
{
actionId: 'keybinds.openPanel',
icon: <Codicon name="keyboard" />,
id: 'keybinds',
label: t.titlebar.openKeybinds,
onSelect: () => {
triggerHaptic('open')
navigate(`${SETTINGS_ROUTE}?tab=keybinds`)
}
},
{
actionId: 'nav.settings',
icon: <Codicon name="settings-gear" />,
id: 'settings',
label: t.titlebar.openSettings,
@ -256,9 +272,15 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
// for a11y.
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
const tooltipLabel = tool.actionId ? (
<TipKeybindLabel actionId={tool.actionId} text={tool.title ?? tool.label} />
) : (
(tool.title ?? tool.label)
)
if (tool.href) {
return (
<Tip label={tool.title ?? tool.label}>
<Tip label={tooltipLabel}>
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
@ -275,7 +297,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
}
return (
<Tip label={tool.title ?? tool.label}>
<Tip label={tooltipLabel}>
<Button
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}

View file

@ -24,6 +24,7 @@ import { ErrorBanner } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { TextTab } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import {
authMcpServer,
getActionStatus,
@ -1140,16 +1141,17 @@ function ServerConfig({
row's h-11 centering exactly (h-5 controls mt-3, size-6 avatar
mt-2.5, h-4 switch mt-3.5) no matter how tall the text column gets. */}
<div className="flex items-start gap-2 pr-1.5">
<Button
aria-label={m.allServers}
className={cn('mt-3', ICON_BUTTON)}
onClick={onBack}
size="icon"
title={m.allServers}
variant="ghost"
>
<Codicon name="chevron-left" size="0.8125rem" />
</Button>
<Tip label={m.allServers}>
<Button
aria-label={m.allServers}
className={cn('mt-3', ICON_BUTTON)}
onClick={onBack}
size="icon"
variant="ghost"
>
<Codicon name="chevron-left" size="0.8125rem" />
</Button>
</Tip>
<McpAvatar className="mt-2.5" name={name} status={status} />
<div className="min-w-0 flex-1 pt-1">
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{prettyName(name)}</h3>
@ -1283,28 +1285,30 @@ function ServerIconActions({
return (
<span className={cn('flex items-center gap-0.5', className)}>
<Button
aria-label={m.reload}
className={ICON_BUTTON}
disabled={probing}
onClick={onProbe}
size="icon"
title={m.reload}
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={probing} />
</Button>
<Button
aria-label={m.remove}
className={cn(ICON_BUTTON, 'hover:text-destructive')}
disabled={saving}
onClick={onRemove}
size="icon"
title={m.remove}
variant="ghost"
>
<Codicon name="trash" size="0.8125rem" />
</Button>
<Tip label={m.reload}>
<Button
aria-label={m.reload}
className={ICON_BUTTON}
disabled={probing}
onClick={onProbe}
size="icon"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={probing} />
</Button>
</Tip>
<Tip label={m.remove}>
<Button
aria-label={m.remove}
className={cn(ICON_BUTTON, 'hover:text-destructive')}
disabled={saving}
onClick={onRemove}
size="icon"
variant="ghost"
>
<Codicon name="trash" size="0.8125rem" />
</Button>
</Tip>
</span>
)
}

View file

@ -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}
>
<DialogTrigger asChild>
<Button
aria-label={t.starmap.shareTitle}
className="text-muted-foreground hover:text-foreground"
size="icon"
title={t.starmap.shareTitle}
variant="ghost"
>
<Upload className="size-3.5" />
</Button>
<Tip label={t.starmap.shareTitle}>
<Button
aria-label={t.starmap.shareTitle}
className="text-muted-foreground hover:text-foreground"
size="icon"
variant="ghost"
>
<Upload className="size-3.5" />
</Button>
</Tip>
</DialogTrigger>
<DialogContent className="max-w-md">

View file

@ -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 {

View file

@ -66,7 +66,6 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
)}
disabled={isSavingLocale}
size="sm"
title={title}
type="button"
variant="outline"
>

View file

@ -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 <button> or <Button> element in the desktop renderer
// may use the native HTML `title=` attribute. Native tooltips are unstyled,
// delayed (~500ms OS default), and visually inconsistent with the app's instant
// themed `Tip` component. Use `<Tip label={...}>` instead.
//
// This is a source-text scan, not a behavior test — it's the same category as
// an ESLint rule, expressed as a vitest so it runs with the rest of the suite.
// See DESIGN.md "Buttons — one component" for the rule.
// Recursively walk a directory and collect all .tsx file paths.
function collectTsxFiles(dir: string): string[] {
const results: string[] = []
for (const entry of readdirSync(dir)) {
// Skip node_modules, dist, and __tests__ (this file itself)
if (entry === 'node_modules' || entry === 'dist' || entry === '__tests__') {
continue
}
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
results.push(...collectTsxFiles(fullPath))
} else if (entry.endsWith('.tsx')) {
results.push(fullPath)
}
}
return results
}
describe('no native title= on button elements', () => {
// Scan every .tsx file under src/ for <button or <Button opening tags that
// also carry a title= attribute (anywhere in the opening tag, which may span
// multiple lines).
it('uses <Tip> instead of native title= on all button elements', () => {
const violations: string[] = []
const srcDir = resolve(__dirname, '../..')
for (const filePath of collectTsxFiles(srcDir)) {
const content = readFileSync(filePath, 'utf-8')
const relativePath = filePath.replace(srcDir + '/', '')
// Match <Button ...> or <button ...> opening tags (may span multiple lines).
// We use a non-greedy match up to the closing > — this won't perfectly
// handle every edge case (e.g. > inside a string literal), but it's good
// enough for a lint-style guard.
const tagPattern = /<(Button|button)\b([^>]*?)>/gsu
let match: RegExpExecArray | null
while ((match = tagPattern.exec(content)) !== null) {
const tagName = match[1]
const attrs = match[2]
if (/\btitle=/.test(attrs)) {
const lineNum = content.slice(0, match.index).split('\n').length
violations.push(`${relativePath}:${lineNum} <${tagName}> has title= — use <Tip>`)
}
}
}
expect(violations, violations.join('\n')).toEqual([])
})
})

View file

@ -2,6 +2,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -73,15 +74,17 @@ function DialogContent({
const closeButton = showCloseButton ? (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
<Tip label={t.common.close}>
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</Tip>
</DialogPrimitive.Close>
) : null

View file

@ -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 ? (
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label={t.ui.search.clear}
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
<Tip label={t.ui.search.clear}>
<Button
aria-label={t.ui.search.clear}
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
</Tip>
) : null}
</div>
)

View file

@ -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 (
<button
aria-label={t.ui.sidebar.toggle}
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
data-sidebar="rail"
data-slot="sidebar-rail"
onClick={toggleSidebar}
tabIndex={-1}
title={t.ui.sidebar.toggle}
{...props}
/>
<Tip label={t.ui.sidebar.toggle}>
<button
aria-label={t.ui.sidebar.toggle}
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
data-sidebar="rail"
data-slot="sidebar-rail"
onClick={toggleSidebar}
tabIndex={-1}
{...props}
/>
</Tip>
)
}

View file

@ -1,6 +1,8 @@
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import * as React from 'react'
import { useI18n } from '@/i18n'
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
import { cn } from '@/lib/utils'
function TooltipProvider({
@ -111,4 +113,23 @@ function TipHintLabel({ text, hint }: TipHintLabelProps) {
)
}
export { Tip, TipHintLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
interface TipKeybindLabelProps {
/** Keybind action id — pulls the label from i18n AND the combo from the store. */
actionId: string
/** Override the i18n label (for context-dependent text like "Show"/"Hide"). */
text?: string
}
/** TipHintLabel that auto-reads both its label and keybind from the action
* registry. Pass only `actionId` for the common case; pass `text` to override
* when the button's tooltip is context-dependent. */
function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) {
const { t } = useI18n()
const hint = useKeybindHint(actionId)
const label = text ?? t.keybinds.actions[actionId] ?? actionId
return <TipHintLabel hint={hint ?? undefined} text={label} />
}
export { Tip, TipHintLabel, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View file

@ -3,6 +3,7 @@
import { type ReactNode, useEffect, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tip } from '@/components/ui/tooltip'
import { Check, Copy, Maximize, RefreshCw, X, ZoomIn, ZoomOut } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -159,14 +160,15 @@ function Divider() {
function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {
return (
<button
aria-label={label}
className="grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
<Tip label={label}>
<button
aria-label={label}
className="grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onClick}
type="button"
>
{children}
</button>
</Tip>
)
}

View file

@ -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',

View file

@ -217,6 +217,7 @@ export const ja = defineLocale({
providerApiKeys: 'API キー',
gateway: 'ゲートウェイ',
apiKeys: 'ツールとキー',
keybinds: 'キーボードショートカット',
keysTools: 'ツール',
keysSettings: '設定',
mcp: 'MCP',

View file

@ -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

View file

@ -211,6 +211,7 @@ export const zhHant = defineLocale({
providerApiKeys: 'API 金鑰',
gateway: '閘道',
apiKeys: '工具與金鑰',
keybinds: '鍵盤快捷鍵',
keysTools: '工具',
keysSettings: '設定',
mcp: 'MCP',

View file

@ -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',

View file

@ -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,

View file

@ -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
}

View file

@ -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()
}
}