hermes-agent/apps/desktop/src/app/shell/titlebar-controls.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

322 lines
11 KiB
TypeScript

import { useStore } from '@nanostores/react'
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
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, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import {
$fileBrowserOpen,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
toggleSidebarOpen
} from '@/store/layout'
import { appViewForPath, isOverlayView, SETTINGS_ROUTE } from '../routes'
import { titlebarButtonClass } from './titlebar'
export interface TitlebarTool {
id: string
label: string
active?: boolean
className?: string
disabled?: boolean
hidden?: boolean
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
}
export type TitlebarToolSide = 'left' | 'right'
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
interface TitlebarControlsProps extends ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
tools?: readonly TitlebarTool[]
onOpenSettings: () => void
}
/**
* The layout button's glyph. Morphs into its composite reset form — the
* layout icon wearing a small counter-clockwise arrow badge ("layout, back
* to how it was") — ONLY while the pointer is on the button AND ⌘/Ctrl is
* held: hover gates via CSS (`group/tool` on the button), the modifier via
* the window listener. Pressing the modifier elsewhere changes nothing.
*/
function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
return (
<>
<span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}>
<Codicon name="layout" />
</span>
<span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}>
<Codicon name="layout" />
<span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px">
<Codicon className="-scale-x-100" name="refresh" size="0.5625rem" />
</span>
</span>
</>
)
}
/** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the
* layout button morphs into its reset form while the modifier is down). */
function useModifierHeld(): boolean {
const [held, setHeld] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey)
const clear = () => setHeld(false)
window.addEventListener('keydown', sync)
window.addEventListener('keyup', sync)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync)
window.removeEventListener('keyup', sync)
window.removeEventListener('blur', clear)
}
}, [])
return held
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const modHeld = useModifierHeld()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const toggleHaptics = () => {
if (!hapticsMuted) {
triggerHaptic('tap')
}
toggleHapticsMuted()
if (hapticsMuted) {
window.requestAnimationFrame(() => triggerHaptic('success'))
}
}
// POSITIONAL toggles: each button shows/hides everything on its physical
// side of the main zone (the layout tree collapses the whole side), so they
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
// show/hide affordances.
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const leftToolbarTools: TitlebarTool[] = [
{
actionId: 'view.toggleSidebar',
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
}
},
{
actionId: 'view.flipPanes',
icon: <Codicon name="arrow-swap" />,
id: 'flip-panes',
label: t.titlebar.swapSidebarSides,
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
},
title: t.titlebar.swapSidebarSidesTitle
},
...leftTools
]
const rightSidebarTool: TitlebarTool = {
actionId: 'view.toggleRightSidebar',
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
onSelect: () => {
triggerHaptic('tap')
rightEdge.toggle()
}
}
// Static system tools — always pinned to the screen's right edge.
const systemTools: TitlebarTool[] = [
{
className: 'group/tool',
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
// LayoutGlyph) — the mod-click telegraphs itself before it happens.
icon: <LayoutGlyph modHeld={modHeld} />,
id: 'layout',
label: t.titlebar.layoutEditor,
onSelect: event => {
if (event?.metaKey || event?.ctrlKey) {
triggerHaptic('warning')
resetLayoutTree()
return
}
triggerHaptic('open')
toggleLayoutEditMode()
},
title: t.titlebar.layoutEditorTitle
},
{
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
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,
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
}
}
]
// While a full-screen overlay (settings, command center, …) is open it should
// visually own the window. These control clusters are `fixed` at a higher
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
// and let the overlay's own chrome (close button, drag region) take over.
if (isOverlayView(appViewForPath(location.pathname))) {
return null
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const visiblePaneTools = tools.filter(tool => !tool.hidden)
return (
<>
<div
aria-label={t.shell.windowControls}
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{leftToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
{/*
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
to either the static cluster's width (file-browser closed → cluster
sits flush against system tools) or the file-browser pane's width
(file-browser open → cluster sits flush against the file-browser pane,
i.e. at the preview pane's right edge). No margin hacks needed.
*/}
{visiblePaneTools.length > 0 && (
<div
aria-label={t.shell.paneControls}
className="fixed top-[calc(var(--titlebar-controls-top)+var(--right-rail-top-inset,0px))] right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visiblePaneTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
)}
<div
aria-label={t.shell.appControls}
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
</>
)
}
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
// Titlebar actions never show an active background — state reads from the
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
// 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={tooltipLabel}>
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
>
{tool.icon}
</a>
</Button>
</Tip>
)
}
return (
<Tip label={tooltipLabel}>
<Button
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}
className={className}
disabled={tool.disabled}
onClick={event => {
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.(event)
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
type="button"
variant="ghost"
>
{tool.icon}
</Button>
</Tip>
)
}