hermes-agent/apps/desktop/src/components/language-switcher.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

175 lines
5.8 KiB
TypeScript

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { useIsMobile } from '@/hooks/use-mobile'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, ChevronDown, Globe } from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
export interface LanguageSwitcherProps {
className?: string
collapsed?: boolean
dropUp?: boolean
}
interface LanguageCommandProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>
autoFocus?: boolean
disabled?: boolean
locale: Locale
noResults: string
onSelect: (code: Locale) => void
searchPlaceholder: string
}
export function LanguageSwitcher({ className, collapsed = false, dropUp = false }: LanguageSwitcherProps) {
const { isSavingLocale, locale, setLocale, t } = useI18n()
const [open, setOpen] = useState(false)
const isMobile = useIsMobile()
const useMobileSheet = Boolean(dropUp && isMobile)
const current = LOCALE_META[locale]
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>
const title = t.language.switchTo
const selectLocale = async (code: Locale) => {
if (code === locale || isSavingLocale) {
setOpen(false)
return
}
triggerHaptic('selection')
try {
await setLocale(code)
setOpen(false)
triggerHaptic('success')
} catch (error) {
notifyError(error, t.language.saveError)
}
}
const trigger = (
<Button
aria-expanded={open}
aria-label={title}
className={cn(
'min-w-32 justify-between gap-2 border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 text-left text-muted-foreground hover:text-foreground',
collapsed && 'min-w-0 px-2',
className
)}
disabled={isSavingLocale}
size="sm"
type="button"
variant="outline"
>
<span className="inline-flex min-w-0 items-center gap-2">
<Globe className="size-3.5 shrink-0" />
{!collapsed && <span className="truncate">{current.name}</span>}
</span>
{!collapsed && <ChevronDown className="size-3 shrink-0 opacity-70" />}
</Button>
)
if (useMobileSheet) {
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className="max-h-[min(28rem,80vh)] rounded-t-xl" side="bottom">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{t.language.description}</SheetDescription>
</SheetHeader>
<LanguageCommand
allLocales={allLocales}
disabled={isSavingLocale}
locale={locale}
noResults={t.language.noResults}
onSelect={code => void selectLocale(code)}
searchPlaceholder={t.language.searchPlaceholder}
/>
</SheetContent>
</Sheet>
)
}
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-0" side={dropUp ? 'top' : 'bottom'}>
<LanguageCommand
allLocales={allLocales}
autoFocus
disabled={isSavingLocale}
locale={locale}
noResults={t.language.noResults}
onSelect={code => void selectLocale(code)}
searchPlaceholder={t.language.searchPlaceholder}
/>
</PopoverContent>
</Popover>
)
}
function LanguageCommand({
allLocales,
autoFocus,
disabled,
locale,
noResults,
onSelect,
searchPlaceholder
}: LanguageCommandProps) {
const [search, setSearch] = useState('')
// Own the search term and filter manually. cmdk's built-in shouldFilter
// reorders items by its fuzzy-match score (≈alphabetical with an empty
// query), which destroys the curated en→zh→zh-hant→ja order. We disable it
// and do a plain substring filter that preserves array order — matching
// model-picker.tsx. Match against the endonym, the (hidden) English name,
// and the locale code so "日本"/"japanese"/"ja" all find Japanese.
const q = normalize(search)
const filtered = allLocales.filter(
([code, meta]) =>
!q ||
meta.name.toLowerCase().includes(q) ||
meta.englishName.toLowerCase().includes(q) ||
code.toLowerCase().includes(q)
)
return (
<Command className="bg-transparent" shouldFilter={false}>
<CommandInput autoFocus={autoFocus} onValueChange={setSearch} placeholder={searchPlaceholder} value={search} />
<CommandList className="max-h-80 p-1">
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">{noResults}</div>
) : (
filtered.map(([code, meta]) => {
const selected = code === locale
return (
<CommandItem
className={cn(selected ? 'font-medium text-foreground' : 'text-muted-foreground')}
disabled={disabled}
key={code}
onSelect={() => onSelect(code)}
value={code}
>
<Check className={cn('size-3.5 shrink-0 text-primary', !selected && 'invisible')} />
<span className="min-w-0 flex-1 truncate">{meta.name}</span>
<span className="font-mono text-[0.65rem] uppercase text-(--ui-text-tertiary)">{code}</span>
</CommandItem>
)
})
)}
</CommandList>
</Command>
)
}