mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
* 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.
322 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|