hermes-agent/apps/desktop/DESIGN.md
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

16 KiB

Desktop Design System

Conventions for the Electron desktop app (apps/desktop). Read this before adding a component, overlay, or style. The rule of thumb: one source per concern, tokens over literals, flat over boxed. If you reach for a raw color, a one-off shadow, a bespoke button, or a hardcoded px-* on a control — stop, there's already a primitive for it.

This file owns the visual and interaction contract. Read AGENTS.md for architecture, state, resolver, transport, and testing rules.

This doc contains two kinds of content, maintained differently:

  • Principles (flatness, intent, feedback, motion, cancellation) are durable. They hold as components come and go.
  • Named contracts (tokens, Button variants, primitive names) are the design system's current API. They are maintained with the code: if you change a primitive, token, or variant, update its entry here in the same change — a stale name in this file is a bug, exactly like a stale type.

When a rule and the code disagree, fix whichever is wrong rather than forking a one-off at the call site.

Principles

  1. Flat, not boxed. No card-in-card, no divider borders inside a panel. Group with whitespace and a single hairline, never nested rounded boxes.
  2. Borderless elevation for floating panels. Overlays float on shadow-nous + a --stroke-nous hairline, not thick framed boxes. In-panel structure may use token hairlines sparingly.
  3. One primitive per concern. One Button, one set of control variants, one SearchField, one Loader, one ErrorState. Migrate onto them; don't fork.
  4. Tokens, not literals. Reference CSS vars (--ui-*, --shadow-nous, --theme-*), never raw hex / ad-hoc rgba in components.
  5. Style lives in the primitive. Variants and sizes own padding, radius, color, chrome. Call sites pass a variant/size, not className overrides that re-specify those.
  6. Intent before automation. Surface useful actions and previews, but do not open panes, move focus, or navigate because a tool happened to produce something.
  7. Immediate feedback. Direct manipulation updates the view first. Network or disk persistence reconciles afterward and rolls back visibly on failure.

Information architecture

  • Chat is the home surface. The transcript and composer stay primary; tools, previews, files, review, and terminal complement the conversation.
  • Pages are durable destinations. Chat, Skills, Messaging, and Artifacts remain in shell chrome. Do not hide a distinct product noun inside an unrelated page.
  • Route overlays are short tasks. Settings, Command Center, Cron, Profiles, Agents, and Starmap render as OverlayView cards and return to the previous route on close. Model/session pickers and dialogs layer above the current surface; they are not navigation stacks.
  • Panes are working context. Preview, files, review, and terminal remain attached to the current task. Their state survives temporary hiding and chat switches where the underlying tool is meant to persist.
  • One action, one home. A command may have keyboard, palette, and visible affordances, but they invoke the same action and state. Do not fork behavior per entry point.
  • Projects own workspace cwd. Use Sidebar → Projects for local folders and worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.

Navigation must preserve context. A background session finishing, a tool result arriving, or a project refresh may update badges and cached data; it must not replace the foreground transcript or steal focus.

Surfaces & elevation

Floating panels (base Dialog, route overlays, boot/install/update surfaces, model-picker, onboarding, prompt overlays, notifications) use:

shadow-nous           /* downward-weighted, layered contact→ambient falloff */
border-(--stroke-nous) /* currentColor hairline, theme-adaptive */

Both are CSS vars in src/styles.css — tune in one place, everything inherits. Don't add per-overlay shadow-[…] or border-(--ui-stroke-secondary) one-offs; if elevation needs to change, change the token.

Menus and popovers use their own shared shadow-md + --ui-stroke-secondary primitive treatment. Drag affordances may use tokenized dashed targets and local blur. These are semantic surface classes, not licenses for call-site shadow or border inventions.

Stroke & color tokens

Token Use
--ui-stroke-primary…quaternary hairlines, in descending strength
--ui-stroke-tertiary the default in-panel divider / list hairline
--stroke-nous the overlay hairline (pairs with shadow-nous)
--ui-text-primary / -secondary / -tertiary text hierarchy
--ui-bg-quaternary soft control fill (secondary button)
--chrome-action-hover hover fill for quiet controls
--theme-primary, --ui-accent brand/accent

Never hardcode border-gray-*, bg-white, text-black, etc. The white tile in BrandMark is the one sanctioned literal (the mark needs a fixed backdrop).

Buttons — one component

src/components/ui/button.tsx is the single source. Pick a variant + size; do not pass h-*, px-*, py-*, or icon-size overrides.

Variants: default (primary), destructive, secondary (soft fill — the default non-primary look), outline (transparent + 1px inset ring, no fill/shadow), ghost, link, text (boxless quiet inline — "Cancel", "Clear"), textStrong (bold underlined inline affordance — "Change", "Open logs").

Sizes: default, xs, sm, lg, inline (flush, zero box — for buttons 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.
  • SVGs inherit size-3.5 (size-3 at xs). Don't re-set icon size.
  • Polymorph with asChild when the button must render as a link/Slot.

Form controls

  • controlVariants (src/components/ui/control.ts) is the shared shape for Input / Textarea / SelectTrigger. New text-entry controls compose it.
  • SearchField — borderless, underline-on-focus, auto-width. The only search input. Don't build boxed search bars; don't wrap it in a bordered tile. Empty lists hide their search field.
  • SegmentedControl — the choice control for small mutually-exclusive sets (color mode, tool-call display, usage period). Replaces radio piles and pill rows.
  • Switch (size="xs") — bare, with aria-label. No bordered text wrapper.

Layout

  • Gutters: PAGE_INSET_X (src/app/layout-constants.ts) for page side padding; PAGE_INSET_NEG_X to bleed a child to the edge. Don't hardcode px-6/px-8 on pages.
  • Master/detail overlays: OverlaySplitLayout + OverlaySidebar / OverlayMain. Cron, profiles, etc. ride this — don't rebuild a titlebar shell.
  • Rows: ListRow (settings primitives.tsx) for label/description/action rows. Flat, flush-left; no per-row indentation that fights flush headers.
  • No dividers between rows unless the list genuinely needs them; prefer spacing. When you do need one, it's a single --ui-stroke-tertiary hairline.

Feedback & empty/error/loading states

  • Loading: Loader (src/components/ui/loader.tsx) — animated math/ascii curves (lemniscate-bloom for long ops). Never ship the literal text "Loading…".
  • Errors: ErrorState + the canonical ErrorIcon (no bg chip). One look for the React boundary, in-dialog errors, and the boot-failure banner. Pass nodes for title/description so Radix DialogTitle/Description can flow through for a11y.
  • Logs: LogView — no bg, hairline border, tight padding, small mono. Every place we surface raw logs uses it.
  • Empty: EmptyState for plain page bodies; PanelEmpty for overlay master/detail empties with an icon and action. Don't hand-roll a third centered empty.

Chat, tools & boot surfaces

  • The transcript and composer are built on @assistant-ui/react. Extend the existing components under src/components/assistant-ui and src/app/chat/composer; do not fork a second markdown, message, tool-call, or approval renderer for one feature.
  • A tool result may expose an inline action that opens a preview. It must not open the rail automatically.
  • Install, onboarding, connecting, boot failure, and reauthentication are distinct states with shared visual primitives. Preserve their recovery semantics when unifying appearance.
  • Respect AppShell overlay ownership. Persistent terminal/content layers, route overlays, dialogs, and boot surfaces must not compete through ad-hoc z-index literals.

Iconography & brand

  • Tabler is the default component/chrome set. Import its curated aliases and iconSize scale from src/lib/icons.ts; do not import icon packages directly in feature code.
  • Codicon is the compact editor/tool/status vocabulary. Use src/components/ui/codicon.tsx, including codiconIcon() where a Tabler-shaped component is required.
  • Pick the vocabulary by semantic context and reuse the existing icon for an action. Do not introduce a third icon set or mix styles within one control group.
  • BrandMark (src/components/brand-mark.tsx) is the brand glyph — the nous-girl mark on a white tile, softly rounded, identical in light/dark. It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it for hero/brand moments; don't reintroduce decorative star/sparkle icons.

Motion

  • Quick, functional transitions (~100ms on controls). Respect prefers-reduced-motion for anything beyond a fade.
  • Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element then settle the surface — the outer container's fade is delayed so it doesn't swallow the inner animation. Don't let a global fade race the detail.
  • Motion follows state; it never delays state. Selection, drag targets, cancel, and pressed feedback paint in the current frame.
  • Do not animate layout geometry with transition-all on a hot interaction. Name the properties, avoid backdrop-filter repaints during movement, and remove animation before masking a performance problem.

Direct manipulation & performance

The app should feel instant under real load — long transcripts, several panes, live streams. Design toward that:

  • Direct manipulation paints first; persistence reconciles after and rolls back visibly on failure.
  • Keep interaction feedback cheap: hot-path state stays local or narrowly derived, not wired into heavy trees; pointer work coalesces per frame.
  • One drop region has one visual owner, and drop targets speak one affordance language across files, sessions, tabs, and panes. Overlapping targets resolve to the active one instead of stacking overlays.
  • Forgiving geometry beats pixel-perfect triggers; edge actions live near their edge, not clustered in the center.
  • Expensive stateful surfaces stay mounted when hidden. Visibility is not lifecycle.

Prove speed with realistic content. A fast empty-state demo says nothing about a long transcript or a busy terminal.

Keyboard & cancellation

  • Keyboard ownership follows focus. The focused surface wins its keys; shell shortcuts must not steal a terminal's or editor's bindings.
  • Register global shortcuts through the shared layer, not ad-hoc listeners.
  • One cancel gesture does one thing: cancel the active interaction, or close the topmost dismissable surface — never both, never the control underneath.
  • Cancellation is synchronous in the UI even if cleanup is async: overlays, cursors, and pending gesture state clear at once.
  • Flows that deliberately cannot be dismissed (install/onboarding, destructive confirmation) must make that explicit.

i18n

  • Every user-facing string goes through useI18n() (src/i18n/context.tsx). No literals in JSX.
  • Update all locales togetheren, ja, zh, zh-hant. A string change in en.ts that skips the others is a regression (drifted punctuation, stale labels). Keep trailing-punctuation and tone consistent across all four.

State (TypeScript)

The detailed state contract lives in the scoped AGENTS.md. Visual code follows these essentials:

  • Shared/cross-component state → small nanostores, not prop-drilling. Each feature owns its atoms; shared atoms live in src/store.
  • Rendering components subscribe with useStore; non-render actions read with $atom.get().
  • Subscribe to derived coarse facts instead of high-frequency source atoms when the component does not render the full value.
  • Colocated action modules over god hooks. A hook owns one narrow job.
  • Keep persistence beside the atom that owns it. Route roots stay thin.
  • Prefer interface for public props; extend React primitives (React.ComponentProps<'button'>, Omit<…>).

Affordances

  • cursor-pointer at the primitive level (Button, dropdown/select) — don't hardcode it per call site.
  • Global focus-ring reset; titlebar actions have no active-background state.
  • Esc closes every dismissable overlay/dialog (install/onboarding excluded); close is an x-icon, not the word "Close".

Before you add something — checklist

  • Reuse a primitive (Button, SearchField, SegmentedControl, ListRow, Loader, ErrorState, LogView) instead of forking one?
  • 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 events?
  • Direct manipulation paints immediately and rolls back cleanly on failure?
  • Hot interactions avoid broad subscriptions, layout thrash, and transition-all?
  • Keyboard ownership and single-action Esc behavior are correct?
  • All four locales updated for any new/changed string?
  • cursor-pointer, focus ring, and Esc-to-close behave?
  • Touched a primitive, token, or variant? Its named-contract entry in this file is updated in the same change.