diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index fa0328af4d..9ab43fed65 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -62,6 +62,7 @@ import { $repoWorktrees } from '@/store/coding-status' import { $commandPaletteOpen, $commandPalettePage, + $commandPaletteSeed, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' @@ -99,9 +100,9 @@ import { SKILLS_ROUTE, STARMAP_ROUTE } from '../routes' -import { FIELD_LABELS, SECTIONS } from '../settings/constants' -import { fieldCopyForSchemaKey } from '../settings/field-copy' -import { prettyName } from '../settings/helpers' +import { SECTIONS } from '../settings/constants' +import { type SettingsSearchEntry, settingsSearchTargetQuery } from '../settings/settings-search' +import { useSettingsSearchCatalog } from '../settings/use-settings-search' import { usePaletteContributions } from './contrib' import { MarketplaceThemePage } from './marketplace-theme-page' @@ -542,6 +543,7 @@ export function CommandPalette() { function CommandPaletteBody({ onExited }: { onExited: () => void }) { const { t } = useI18n() const pendingPage = useStore($commandPalettePage) + const pendingSeed = useStore($commandPaletteSeed) const bindings = useStore($bindings) const worktrees = useStore($repoWorktrees) const projectTree = useStore($projectTree) @@ -550,6 +552,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme() const [search, setSearch] = useState('') const [page, setPage] = useState(null) + const inputRef = useRef(null) // The Update row names the same install the statusbar names — same target // selection, same resolver. Reduced to the label string: an in-flight apply @@ -658,6 +661,24 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { } }, [pendingPage]) + // Landing on a page (deep-link open onto Settings, drilling into a submenu, + // stepping back out) must leave the filter typeable immediately — the whole + // point of the pill/⌘K hand-off is "just start typing". Radix only + // autofocuses on mount; a page swap re-renders the same input, and the + // back-button click moves focus to the button. + useEffect(() => { + inputRef.current?.focus() + }, [page]) + + // Type-to-search hand-off: the keystroke that opened the palette (typing on + // the Settings page) prefills the filter so the first character isn't lost. + useEffect(() => { + if (pendingSeed !== null) { + setSearch(pendingSeed) + $commandPaletteSeed.set(null) + } + }, [pendingSeed]) + const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate]) // Sessions: plain select = open beside what's already loaded (focus existing @@ -683,14 +704,6 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { [t.settings.sections] ) - const configFieldLabel = useCallback( - (key: string) => - fieldCopyForSchemaKey(t.settings.fieldLabels, key) ?? - fieldCopyForSchemaKey(FIELD_LABELS, key) ?? - prettyName(key.split('.').pop() ?? key), - [t.settings.fieldLabels] - ) - // Running a keepOpen row (a toggle) changes state the rows themselves report, // so the groups have to rebuild without the palette closing. A counter keeps // that generic — the palette never learns which stores its contributions read. @@ -988,6 +1001,26 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the // navigation entries on an empty palette. + // + // Settings results are the DEEP catalog (schema-driven config fields, + // appearance controls, stored credentials — the same entries the Settings + // page serves), not a hardcoded key list: ⌘K is the de-facto settings + // search everywhere, so root and the settings page must agree. The body + // mounts per open, so the catalog queries fire on open and stay warm. + const settingsCatalog = useSettingsSearchCatalog(true) + + const settingsEntryItem = useCallback( + (entry: SettingsSearchEntry): PaletteItem => ({ + detail: entry.context, + icon: entry.icon, + id: `sp-${entry.id}`, + keywords: [entry.context, entry.description ?? '', ...entry.keywords], + label: entry.label, + run: go(`${SETTINGS_ROUTE}?${settingsSearchTargetQuery(entry.target)}`) + }), + [go] + ) + const searchGroups = useMemo(() => { if (!search.trim()) { return [] @@ -1117,17 +1150,27 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { }) } - const fieldItems = SECTIONS.flatMap(section => - section.keys.map(key => ({ - icon: section.icon, - id: `field-${key}`, - keywords: ['settings', key, section.label, settingsSectionLabel(section)], - label: `${settingsSectionLabel(section)}: ${configFieldLabel(key)}`, - run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`) - })) + const fieldItems = [...settingsCatalog.appearanceEntries, ...settingsCatalog.configEntries].map( + settingsEntryItem ) - result.push({ heading: t.commandCenter.settingsFields, items: fieldItems }) + if (fieldItems.length > 0) { + result.push({ heading: t.commandCenter.settingsFields, items: fieldItems }) + } + + if (settingsCatalog.pluginEntries.length > 0) { + result.push({ + heading: t.settings.nav.plugins, + items: settingsCatalog.pluginEntries.map(settingsEntryItem) + }) + } + + if (settingsCatalog.credentialEntries.length > 0) { + result.push({ + heading: t.settings.nav.apiKeys, + items: settingsCatalog.credentialEntries.map(settingsEntryItem) + }) + } if (mcpServers.length > 0) { result.push({ @@ -1165,7 +1208,6 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { }, [ archivedSessions, availableThemes, - configFieldLabel, go, goSession, mcpServers, @@ -1175,7 +1217,8 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { sessions, setMode, setTheme, - settingsSectionLabel, + settingsCatalog, + settingsEntryItem, t, themeName ]) @@ -1189,6 +1232,60 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { [baseGroups, branchGroup, searchGroups] ) + // Settings-scoped page (⌘K on the Settings overlay, or its search pill): + // the same catalog as root, minus everything that isn't settings. Pages + // always list; the granular entries surface on type, same contract as the + // root. + const settingsPageGroups = useMemo(() => { + const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` + const cc = t.commandCenter + + const result: PaletteGroup[] = [ + { + heading: cc.settings, + items: [ + ...SECTIONS.map(section => ({ + icon: section.icon, + id: `sp-config-${section.id}`, + keywords: ['settings', section.label, settingsSectionLabel(section)], + label: settingsSectionLabel(section), + run: go(settingsTab(`config:${section.id}`)) + })), + ...NON_CONFIG_SETTINGS.map(entry => ({ + icon: entry.icon, + id: `sp-${entry.tab}`, + keywords: ['settings', ...(entry.keywords ?? [])], + label: t.settings.nav[entry.labelKey], + run: go(settingsTab(entry.tab)) + })) + ] + } + ] + + if (search.trim()) { + result.push({ + heading: cc.settingsFields, + items: [...settingsCatalog.appearanceEntries, ...settingsCatalog.configEntries].map(settingsEntryItem) + }) + + if (settingsCatalog.pluginEntries.length > 0) { + result.push({ + heading: t.settings.nav.plugins, + items: settingsCatalog.pluginEntries.map(settingsEntryItem) + }) + } + + if (settingsCatalog.credentialEntries.length > 0) { + result.push({ + heading: t.settings.nav.apiKeys, + items: settingsCatalog.credentialEntries.map(settingsEntryItem) + }) + } + } + + return result + }, [go, search, settingsCatalog, settingsEntryItem, settingsSectionLabel, t]) + // Nested palette pages (VS Code-style submenus). Reusable: add an entry here // and point a root item at it via `to`. const subPages = useMemo>( @@ -1261,9 +1358,16 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { title: t.commandCenter.installTheme.pageTitle, placeholder: t.commandCenter.installTheme.placeholder, groups: [] + }, + // Settings-scoped search (⌘K while the Settings overlay is up, or the + // search pill beside its close button). + settings: { + title: t.commandCenter.nav.settings.title, + placeholder: t.settings.search.placeholder, + groups: settingsPageGroups } }), - [availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName] + [availableThemes, mode, resolvedMode, setMode, setTheme, settingsPageGroups, t, themeName] ) const activePage = page ? subPages[page] : null @@ -1357,6 +1461,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { }} onValueChange={setSearch} placeholder={placeholder} + ref={inputRef} right={page === 'pets' ? : undefined} value={search} /> diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 1b7217d477..caa0f9d7bc 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -19,7 +19,7 @@ import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } fro import { actionAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo' import { composerFocusKeysAllowed, isComposerFocusSoftCombo, typeToFocusChar } from '@/lib/keybinds/composer-focus-keys' import { openWorktreeDialog } from '@/store/coding-status' -import { toggleCommandPalette } from '@/store/command-palette' +import { $commandPaletteOpen, openCommandPalettePage, toggleCommandPalette } from '@/store/command-palette' import { $findInPage, findNext as findNextMatch, @@ -191,7 +191,17 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { }, 'composer.voice': requestVoiceToggle, - 'nav.commandPalette': toggleCommandPalette, + // On the Settings overlay, ⌘K scopes to settings search; the second press + // (or Esc) still closes as usual via toggle. + 'nav.commandPalette': () => { + if (!$commandPaletteOpen.get() && appViewForPath(location.pathname) === 'settings') { + openCommandPalettePage('settings') + + return + } + + toggleCommandPalette() + }, 'nav.commandCenter': deps.toggleCommandCenter, 'nav.settings': () => navigate(SETTINGS_ROUTE), 'nav.profiles': () => navigate(PROFILES_ROUTE), diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 6830c20796..41ab55e38e 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -21,8 +21,15 @@ interface OverlayViewProps { onClose: () => void closeLabel?: string contentClassName?: string + /** Chrome pinned to the card's top edge, horizontally centered and riding + * the border half-in half-out (e.g. the Settings search pill). Rendered + * beside the card, not inside it — the card clips its own overflow. */ + edgeBadge?: ReactNode headerContent?: ReactNode rootClassName?: string + /** Controls rendered on the close button's row, to its left. They ride the + * titlebar strip, so keep them titlebar-sized and quiet. */ + titlebarActions?: ReactNode } export function OverlayView({ @@ -30,8 +37,10 @@ export function OverlayView({ onClose, closeLabel = translateNow('common.close'), contentClassName, + edgeBadge, headerContent, - rootClassName + rootClassName, + titlebarActions }: OverlayViewProps) { const closeOverlay = () => { triggerHaptic('close') @@ -93,38 +102,53 @@ export function OverlayView({ // the edges. Re-pin the real height at the overlay root. style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties} > -
-
- {headerContent && ( -
- {headerContent} -
+
+
+
+ {headerContent && ( +
+ {headerContent} +
+ )} - +
+ {titlebarActions} + + +
+
+ + {/* No top padding here: the split-layout columns own their own + titlebar clearance so their backgrounds run flush to the card top + (otherwise the card surface shows as a gap above the sidebar). */} +
{children}
- {/* No top padding here: the split-layout columns own their own - titlebar clearance so their backgrounds run flush to the card top - (otherwise the card surface shows as a gap above the sidebar). */} -
{children}
+ {/* Sibling of the card, not a child: the card clips its own overflow + (rounded corners), and the badge deliberately straddles the top + border — half above, half below. */} + {edgeBadge && ( +
+ {edgeBadge} +
+ )}
) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 0fd8fd5e38..b84debd606 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -46,7 +46,9 @@ import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/use import { MODE_OPTIONS } from './constants' import { PetSettings } from './pet-settings' import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' +import { APPEARANCE_SETTING_IDS } from './settings-search' import { TerminalFontSetting } from './terminal-font-setting' +import { useDeepLinkHighlight } from './use-deep-link-highlight' function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) { // Preview in the *current* mode: the dark palette in Dark, and the light @@ -91,6 +93,8 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) // presets highlights nothing, and the row description keeps showing the // exact current percent. const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const +const APPEARANCE_SEARCH_TARGETS = new Set(Object.values(APPEARANCE_SETTING_IDS)) +const appearanceSettingElementId = (id: string) => `setting-field-${id}` type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] @@ -316,6 +320,12 @@ export function AppearanceSettings() { const [query, setQuery] = useState('') + useDeepLinkHighlight({ + elementId: appearanceSettingElementId, + param: 'setting', + ready: id => APPEARANCE_SEARCH_TARGETS.has(id) + }) + // One box does double duty: filter installed themes live (below), and run a // name search against the VS Code Marketplace (the Cmd-K "Install theme…" // backend) for anything not already installed. @@ -374,6 +384,7 @@ export function AppearanceSettings() { } description={isSavingLocale ? t.language.saving : t.language.description} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.language)} title={t.language.label} /> @@ -461,6 +472,7 @@ export function AppearanceSettings() { } description={a.themeDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.theme)} title={
{a.themeTitle} @@ -489,6 +501,7 @@ export function AppearanceSettings() { /> } description={a.uiScaleDesc(zoomPercent)} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.uiScale)} title={a.uiScaleTitle} /> @@ -594,6 +607,7 @@ export function AppearanceSettings() { ) : undefined } description={translucency.mode === 'glass' ? a.translucencyGlassDesc : a.translucencyDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)} title={a.translucencyTitle} /> @@ -612,6 +626,7 @@ export function AppearanceSettings() { /> } description={a.backdropDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.backdrop)} title={a.backdropTitle} /> @@ -652,6 +667,7 @@ export function AppearanceSettings() { /> } description={a.toolViewDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.toolView)} title={a.toolViewTitle} /> @@ -699,6 +715,7 @@ export function AppearanceSettings() {
} description={a.embedsDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.embeds)} title={a.embedsTitle} />
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 7ded49ff4b..77d501b731 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -37,7 +37,8 @@ import { getNested, isExternalMemoryProvider, sectionFieldEntries, - setNested + setNested, + voiceFieldVisible } from './helpers' import { MemoryConnect } from './memory/connect' import { ProviderConfigPanel } from './memory/provider-config-panel' @@ -46,26 +47,6 @@ import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } fro import { SettingsProfileScope } from './profile-scope' import { QuickEntrySettings } from './quick-entry-settings' -// On the Voice page, only surface the sub-fields of the *selected* TTS/STT -// provider — otherwise every provider's options render at once (the "totally -// crazy" wall of ~30 fields). Top-level keys (tts.provider, stt.enabled, -// voice.*) always show; STT provider fields hide entirely when STT is off. -export function voiceFieldVisible(key: string, config: HermesConfigRecord): boolean { - const match = /^(tts|stt)\.([^.]+)\./.exec(key) - - if (!match) { - return true - } - - const [, domain, provider] = match - - if (domain === 'stt' && !getNested(config, 'stt.enabled')) { - return false - } - - return provider === String(getNested(config, `${domain}.provider`) ?? '') -} - export function ConfigSettings({ activeSectionId, onConfigSaved, @@ -274,6 +255,12 @@ function ConfigSettingsInner({ } element.scrollIntoView({ behavior: 'smooth', block: 'center' }) + + if (!element.hasAttribute('tabindex')) { + element.tabIndex = -1 + } + + element.focus({ preventScroll: true }) element.classList.add('setting-field-highlight') const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600) diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 8806e5e421..8da0c92f05 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -122,6 +122,24 @@ export function clearsEnabledToolsets(prev: HermesConfigRecord, next: HermesConf return hadToolsets && clearsToolsets } +// Voice renders only fields for the selected TTS/STT provider. Search and the +// page share this rule so every indexed field can actually mount when opened. +export function voiceFieldVisible(key: string, config: HermesConfigRecord): boolean { + const match = /^(tts|stt)\.([^.]+)\./.exec(key) + + if (!match) { + return true + } + + const [, domain, provider] = match + + if (domain === 'stt' && !getNested(config, 'stt.enabled')) { + return false + } + + return provider === String(getNested(config, `${domain}.provider`) ?? '') +} + export function inferFieldSchema(value: unknown): ConfigFieldSchema { if (typeof value === 'boolean') { return { type: 'boolean' } diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 8222b58433..dddfe74172 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -1,7 +1,9 @@ +import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useLocation, useNavigate } from 'react-router' import { codiconIcon } from '@/components/ui/codicon' +import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' @@ -17,11 +19,17 @@ import { KeyRound, Package, RefreshCw, + Search, Settings2, Upload, Wrench, Zap } from '@/lib/icons' +import { isEditableTarget } from '@/lib/keybinds/combo' +import { typeToFocusChar } from '@/lib/keybinds/composer-focus-keys' +import { cn } from '@/lib/utils' +import { $commandPaletteOpen, openCommandPalettePage } from '@/store/command-palette' +import { bindingsFor } from '@/store/keybinds' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -273,6 +281,57 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set [activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView] ) + // Type-to-search: printable keystrokes on the Settings surface (outside any + // field) open the settings-scoped palette, seeded with the character — same + // reflex as the chat surface's type-to-focus, pointed at search instead. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ($commandPaletteOpen.get() || isEditableTarget(event.target)) { + return + } + + const char = typeToFocusChar(event) + + if (char === null || char === ' ') { + return + } + + event.preventDefault() + openCommandPalettePage('settings', char) + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + // Fake search pill riding the card's top edge, dead-center and half off it. + // Clicking (or just typing) opens the ⌘K palette scoped to settings; while + // the palette is up the pill hands over to it — grows slightly and fades, + // then fades back when the palette closes. It renders as chrome, not an + // input — no border, recessed fill, live ⌘K hint. + const searchCombo = bindingsFor('nav.commandPalette')[0] + const paletteOpen = useStore($commandPaletteOpen) + + const searchPill = ( + + ) + const navFooter = ( <> @@ -304,49 +363,50 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) + const activeSettingsContent = + activeView === 'config:appearance' ? ( + + ) : activeView === 'about' ? ( + + ) : activeView === 'gateway' || activeView === 'connections' ? ( + // 'connections' renders the unified page too so the frame before + // the alias redirect lands doesn't flash the fallback view. + + ) : activeView === 'keybinds' ? ( + + ) : activeView.startsWith('config:') ? ( + + ) : activeView === 'providers' ? ( + + ) : activeView === 'keys' ? ( + + ) : activeView === 'notifications' ? ( + + ) : activeView === 'billing' ? ( + + ) : activeView === 'plugins' ? ( + + ) : ( + + ) + return ( - + - - {activeView === 'config:appearance' ? ( - - ) : activeView === 'about' ? ( - - ) : activeView === 'gateway' || activeView === 'connections' ? ( - // 'connections' renders the unified page too so the frame before - // the alias redirect lands doesn't flash the fallback view. - - ) : activeView === 'keybinds' ? ( - - ) : activeView.startsWith('config:') ? ( - - ) : activeView === 'providers' ? ( - - ) : activeView === 'keys' ? ( - - ) : activeView === 'notifications' ? ( - - ) : activeView === 'billing' ? ( - - ) : activeView === 'plugins' ? ( - - ) : ( - - )} - + {activeSettingsContent} ) diff --git a/apps/desktop/src/app/settings/keys-settings.test.tsx b/apps/desktop/src/app/settings/keys-settings.test.tsx new file mode 100644 index 0000000000..e806eb382a --- /dev/null +++ b/apps/desktop/src/app/settings/keys-settings.test.tsx @@ -0,0 +1,226 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, useNavigate } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { EnvVarInfo } from '@/types/hermes' + +const getEnvVars = vi.fn() + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) + +vi.mock('@/hermes', () => ({ + deleteEnvVar: vi.fn(), + getEnvVars: () => getEnvVars(), + revealEnvVar: vi.fn(), + setApiRequestProfile: () => undefined, + setEnvVar: vi.fn() +})) + +function envVar(category: string, patch: Partial = {}): EnvVarInfo { + return { + advanced: false, + category, + description: '', + is_password: true, + is_set: false, + redacted_value: null, + tools: [], + url: '', + ...patch + } +} + +beforeEach(() => { + getEnvVars.mockResolvedValue({}) + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderKeysSettings(view: 'settings' | 'tools', route = '/settings') { + const { KeysSettings } = await import('./keys-settings') + + await act(async () => { + render( + + + + ) + }) +} + +function DeepLinkButton({ target }: { target: string }) { + const navigate = useNavigate() + + return ( + + ) +} + +describe('KeysSettings scoped command search', () => { + it('curates tool results by label, description, env key, URL, and tool name', async () => { + getEnvVars.mockResolvedValue({ + BRAVE_SEARCH_API_KEY: envVar('tool', { + description: 'Search the web with Brave.', + url: 'https://brave.com/search/api/' + }), + FIRECRAWL_API_KEY: envVar('tool', { + description: 'Crawl and extract websites.', + tools: ['browser_navigate'] + }), + GATEWAY_PROXY: envVar('setting', { description: 'Gateway reverse proxy.' }) + }) + + await renderKeysSettings('tools') + + const search = await screen.findByRole('combobox', { name: 'Search tools…' }) + expect(screen.getByText('BRAVE SEARCH')).toBeTruthy() + expect(screen.getByText('FIRECRAWL')).toBeTruthy() + expect(screen.queryByText('GATEWAY PROXY')).toBeNull() + expect(screen.queryByRole('option')).toBeNull() + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.change(search, { target: { value: 'crawl and extract' } }) + const firecrawlResult = await screen.findByRole('option', { name: /FIRECRAWL/ }) + expect(firecrawlResult.getAttribute('data-selected')).toBe('true') + expect(screen.queryByRole('option', { name: /BRAVE SEARCH/ })).toBeNull() + + fireEvent.change(search, { target: { value: 'brave_search_api' } }) + expect(await screen.findByRole('option', { name: /BRAVE SEARCH/ })).toBeTruthy() + expect(screen.queryByRole('option', { name: /FIRECRAWL/ })).toBeNull() + + fireEvent.change(search, { target: { value: 'brave.com' } }) + expect(await screen.findByRole('option', { name: /BRAVE SEARCH/ })).toBeTruthy() + + fireEvent.change(search, { target: { value: 'browser_navigate' } }) + expect(await screen.findByRole('option', { name: /FIRECRAWL/ })).toBeTruthy() + expect(screen.queryByRole('option', { name: /BRAVE SEARCH/ })).toBeNull() + + fireEvent.change(search, { target: { value: 'does-not-exist' } }) + expect(await screen.findByText('No entries match your search.')).toBeTruthy() + }) + + it('scopes settings results and excludes channel-managed credentials', async () => { + getEnvVars.mockResolvedValue({ + API_SERVER_TOKEN: envVar('setting', { description: 'Protect the local API server.' }), + GATEWAY_PROXY: envVar('messaging', { description: 'Gateway reverse proxy address.' }), + TELEGRAM_BOT_TOKEN: envVar('messaging', { + channel_managed: true, + description: 'Telegram bot token.' + }), + BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }) + }) + + await renderKeysSettings('settings') + + const search = await screen.findByRole('combobox', { name: 'Search settings…' }) + expect(screen.getByText('API SERVER')).toBeTruthy() + expect(screen.getByText('GATEWAY PROXY')).toBeTruthy() + expect(screen.queryByText('TELEGRAM BOT')).toBeNull() + expect(screen.queryByText('BRAVE SEARCH')).toBeNull() + + fireEvent.change(search, { target: { value: 'reverse proxy' } }) + expect(await screen.findByRole('option', { name: /GATEWAY PROXY/ })).toBeTruthy() + expect(screen.queryByRole('option', { name: /API SERVER/ })).toBeNull() + }) + + it('selects a command result, then expands and highlights its credential card', async () => { + getEnvVars.mockResolvedValue({ + BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }), + FIRECRAWL_API_KEY: envVar('tool', { + description: 'Crawl and extract websites.', + tools: ['browser_navigate'] + }) + }) + + await renderKeysSettings('tools', '/settings?tab=keys&kview=tools') + + const search = await screen.findByRole('combobox', { name: 'Search tools…' }) + fireEvent.change(search, { target: { value: 'browser_navigate' } }) + await screen.findByRole('option', { name: /FIRECRAWL/ }) + fireEvent.keyDown(search, { key: 'Enter' }) + + await waitFor(() => expect((search as HTMLInputElement).value).toBe('')) + await waitFor(() => { + const target = globalThis.document.getElementById('credential-key-FIRECRAWL_API_KEY') + expect(target?.classList).toContain('setting-field-highlight') + }) + expect(screen.getByText('Crawl and extract websites.')).toBeTruthy() + }) + + it('dismisses outside and reopens on focus while Escape clears the query', async () => { + getEnvVars.mockResolvedValue({ + BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }) + }) + + await renderKeysSettings('tools') + + const search = await screen.findByRole('combobox', { name: 'Search tools…' }) + fireEvent.change(search, { target: { value: 'brave' } }) + expect(await screen.findByRole('option', { name: /BRAVE SEARCH/ })).toBeTruthy() + expect(search.getAttribute('aria-expanded')).toBe('true') + + fireEvent.pointerDown(globalThis.document.body) + await waitFor(() => expect(search.getAttribute('aria-expanded')).toBe('false')) + expect((search as HTMLInputElement).value).toBe('brave') + + fireEvent.focus(search) + await waitFor(() => expect(search.getAttribute('aria-expanded')).toBe('true')) + fireEvent.keyDown(search, { key: 'Escape' }) + expect((search as HTMLInputElement).value).toBe('') + expect(search.getAttribute('aria-expanded')).toBe('false') + }) + + it('clears the scoped query when a deep link opens a key in the active view', async () => { + getEnvVars.mockResolvedValue({ + BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }), + FIRECRAWL_API_KEY: envVar('tool', { description: 'Crawl and extract websites.' }) + }) + const { KeysSettings } = await import('./keys-settings') + + render( + + + + + ) + + const search = await screen.findByRole('combobox', { name: 'Search tools…' }) + fireEvent.change(search, { target: { value: 'firecrawl' } }) + expect(await screen.findByRole('option', { name: /FIRECRAWL/ })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Open key' })) + + await waitFor(() => expect((search as HTMLInputElement).value).toBe('')) + expect(await screen.findByText('Search the web with Brave.')).toBeTruthy() + }) + + it('does not clear a query for a deep link owned by the other sub-view', async () => { + getEnvVars.mockResolvedValue({ + BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }), + GATEWAY_PROXY: envVar('setting', { description: 'Gateway reverse proxy address.' }) + }) + + await renderKeysSettings('settings', '/settings?tab=keys&kview=settings&key=BRAVE_SEARCH_API_KEY') + + const search = await screen.findByRole('combobox', { name: 'Search settings…' }) + fireEvent.change(search, { target: { value: 'reverse proxy' } }) + + expect((search as HTMLInputElement).value).toBe('reverse proxy') + expect(await screen.findByRole('option', { name: /GATEWAY PROXY/ })).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index 173b359856..d92dba33aa 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -1,9 +1,10 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' +import { ScopedCommandSearch, type ScopedCommandSearchItem } from '@/components/ui/scoped-command-search' import { useI18n } from '@/i18n' import { $settingsScopeOverride } from '@/store/settings-scope' -import type { EnvVarInfo } from '@/types/hermes' import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui' import { useEnvCredentials } from './env-credentials' @@ -31,77 +32,131 @@ const VIEW_CATEGORIES: Record = { tools: ['tool'] } +const credentialElementId = (key: string) => `credential-key-${key}` + export function KeysSettings({ view }: KeysSettingsProps) { const { t } = useI18n() // Shared settings "Applies to" scope: fetch + edit the selected profile's // env store instead of the active one (null → active, the default path). const scopeProfile = useStore($settingsScopeOverride) const { rowProps, vars } = useEnvCredentials(scopeProfile) + const [, setSearchParams] = useSearchParams() const [openKey, setOpenKey] = useState(null) + const [query, setQuery] = useState('') useEffect(() => { setOpenKey(null) + setQuery('') }, [scopeProfile, view]) - // Deep link from Capabilities env-var rows (?tab=keys&key=): scroll - // the credential card into view, flash it, and expand it. Same mechanism the - // command palette uses for config fields / archived sessions. - useDeepLinkHighlight({ - elementId: key => `credential-key-${key}`, - onResolve: key => setOpenKey(key), - param: 'key', - ready: key => Boolean(vars && key in vars) - }) - - const groups = useMemo(() => { + const entries = useMemo(() => { if (!vars) { return [] } - return KEYS_VIEWS.flatMap(v => { - const cats = VIEW_CATEGORIES[v] + const cats = VIEW_CATEGORIES[view] - const entries = Object.entries(vars) - .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category))) - .sort(([a], [b]) => a.localeCompare(b)) + return Object.entries(vars) + .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category))) + .sort(([a], [b]) => a.localeCompare(b)) + }, [vars, view]) - return entries.length === 0 ? [] : [{ category: v, entries }] - }) - }, [vars]) + const searchItems = useMemo( + () => + entries.map(([key, info]) => ({ + description: info.description?.trim() || key, + id: key, + keywords: [ + key, + asText(info.description), + asText(info.url), + ...(Array.isArray(info.tools) ? info.tools.map(asText) : []) + ].filter(Boolean), + label: credentialRowLabel(key, info) + })), + [entries] + ) + + const renderableKeys = useMemo(() => new Set(entries.map(([key]) => key)), [entries]) + + const resolveDeepLink = useCallback((key: string) => { + setQuery('') + setOpenKey(key) + }, []) + + const deepLinkReady = useCallback((key: string) => renderableKeys.has(key), [renderableKeys]) + + const selectSearchResult = useCallback( + (item: ScopedCommandSearchItem) => { + setQuery('') + setSearchParams( + previous => { + const next = new URLSearchParams(previous) + next.set('key', item.id) + + return next + }, + { replace: true } + ) + }, + [setSearchParams] + ) + + // Deep link from Capabilities env-var rows (?tab=keys&key=): scroll + // the credential card into view, flash it, and expand it. Only consume keys + // rendered by this sub-view so a stale Tools/Settings pairing cannot keep + // clearing the user's search while the target remains impossible to mount. + useDeepLinkHighlight({ + elementId: credentialElementId, + onResolve: resolveDeepLink, + param: 'key', + ready: deepLinkReady + }) if (!vars) { - return + return } - const visible = groups.filter(g => g.category === view) + const searchLabel = view === 'tools' ? t.settings.keys.searchTools : t.settings.keys.searchSettings return ( - {visible.map(group => ( -
- {group.entries.map(([key, info]: [string, EnvVarInfo]) => { - const label = credentialRowLabel(key, info) + {entries.length > 0 ? ( + <> +
+ +
- return ( -
- setOpenKey(key)} - onToggle={() => setOpenKey(prev => (prev === key ? null : key))} - placeholder={credentialPlaceholder(key, info, label)} - rowProps={rowProps} - varKey={key} - /> -
- ) - })} -
- ))} +
+ {entries.map(([key, info]) => { + const label = credentialRowLabel(key, info) - {visible.length === 0 && ( + return ( +
+ setOpenKey(key)} + onToggle={() => setOpenKey(prev => (prev === key ? null : key))} + placeholder={credentialPlaceholder(key, info, label)} + rowProps={rowProps} + varKey={key} + /> +
+ ) + })} +
+ + ) : (
{t.settings.keys.empty}
diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 1c379c7fbc..69598afe43 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -22,6 +22,7 @@ import { $agentPluginsStatus, type AgentPluginRow, type GatewayRequest, + isDesktopRelevantPlugin, loadAgentPlugins, toggleAgentPlugin } from '@/store/agent-plugins' @@ -30,36 +31,19 @@ import { $activeGatewayProfile } from '@/store/profile' import { $connection, $gatewayState } from '@/store/session' import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives' +import { useDeepLinkHighlight } from './use-deep-link-highlight' const KIND_ORDER: Record = { disk: 0, runtime: 1, bundled: 2 } // User-installed plugins first — mirrors `hermes plugins list --user`. const SOURCE_ORDER: Record = { user: 0, git: 0, project: 1, entrypoint: 2 } -// Fallback for older backends whose rows predate a reliable `source` field: -// plugin categories (by registry key prefix) that only exist bundled in the -// repo — same curation stance as desktop-slash-commands.ts. -const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/'] - -// This section is the control panel for plugins the USER installed -// (~/.hermes/plugins, git, project, pip entry points, portable packages). -// Repo-bundled built-ins (browser backends, cron providers, model providers, -// platform adapters, image/video backends…) ship enabled-by-default and are -// configured from their own surfaces (Settings → Models, Messaging, `hermes -// tools`…), so listing dozens of them here is pure noise — hide them. -const isDesktopRelevant = (row: AgentPluginRow) => { - if (row.source === 'bundled') { - return false - } - - const key = row.key - - return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix)) -} - const agentPluginRowKey = (row: AgentPluginRow) => row.key ?? [row.name, row.source, row.version, row.description].join('\0') +/** Deep-link anchor for a plugin row (`?tab=plugins&plugin=`). */ +export const pluginElementId = (target: string) => `plugin-${target}` + function reveal(file: string) { void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined) } @@ -118,14 +102,16 @@ async function revealAgentPluginsDir(request: GatewayRequest) { function PluginLine({ title, description, - controls + controls, + id }: { title: ReactNode description?: ReactNode controls: ReactNode + id?: string }) { return ( -
+
{title} @@ -172,6 +158,7 @@ function AgentPluginRowView({ row, profile }: { row: AgentPluginRow; profile: st {toggle}} description={row.description || (row.version ? `v${row.version}` : undefined)} + id={pluginElementId(key ?? row.name)} title={ <> {row.name} @@ -231,7 +218,7 @@ function AgentPluginsSection() { const needle = normalize(query) const sorted = rows - .filter(isDesktopRelevant) + .filter(isDesktopRelevantPlugin) .filter( row => !needle || @@ -355,6 +342,7 @@ function PluginRow({ record }: { record: PluginRecord }) { (record.description ?? record.file ?? record.id) ) } + id={pluginElementId(record.id)} title={ <> {record.name} @@ -371,6 +359,15 @@ export function PluginsSettings() { const p = t.settings.plugins const records = useStore($pluginRecords) + // Deep-link from settings search (?plugin=): rows render as soon + // as their store hydrates, so "ready" is simply target-present; the polling + // in the hook rides out the async list loads (agent rows arrive via RPC). + useDeepLinkHighlight({ + param: 'plugin', + ready: () => true, + elementId: pluginElementId + }) + const rows = Object.values(records).sort( (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name) ) diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index bbe86a45f2..59f3e1fe00 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -111,6 +111,7 @@ export function ListRow({ hint, action, below, + id, wide = false, className }: { @@ -119,6 +120,7 @@ export function ListRow({ hint?: ReactNode action?: ReactNode below?: ReactNode + id?: string wide?: boolean className?: string }) { @@ -126,7 +128,7 @@ export function ListRow({ // Container-queried, not viewport-queried: the label/control split keys on // the row's own pane width, so a narrow detail column (messaging, split // views) stacks instead of squishing the label against minmax(15rem,…). -
+
= {}): EnvVarInfo { + return { + advanced: false, + category, + description: '', + is_password: true, + is_set: false, + redacted_value: null, + tools: [], + url: '', + ...patch + } +} + +const searchCopy = { + fieldDescriptions: { + 'display.personality': 'Choose how Hermes sounds in conversation.', + 'tts.edge.voice': 'Voice used by Edge TTS.' + }, + fieldLabels: { + 'display.personality': 'Personality', + 'tts.edge.voice': 'Edge voice', + 'tts.openai.voice': 'OpenAI voice' + }, + sections: { + chat: 'Chat', + voice: 'Voice' + } +} + +describe('settings search index', () => { + it('builds config results from renderable schema fields with exact deep links', () => { + const schema: Record = { + 'display.personality': { type: 'select' }, + 'tts.edge.voice': { type: 'string' }, + 'tts.openai.voice': { type: 'string' } + } + + const config = { + display: { personality: 'default' }, + tts: { provider: 'edge', edge: { voice: '' }, openai: { voice: '' } } + } as unknown as HermesConfigRecord + + const entries = buildConfigSearchEntries(schema, config, searchCopy) + + expect(entries.map(entry => entry.id)).toEqual([ + 'config-field:display.personality', + 'config-field:tts.provider', + 'config-field:tts.edge.voice' + ]) + expect(entries[0]).toMatchObject({ + context: 'Chat', + description: 'Choose how Hermes sounds in conversation.', + label: 'Personality', + target: { field: 'display.personality', view: 'config:chat' } + }) + expect(entries.some(entry => entry.id === 'config-field:tts.openai.voice')).toBe(false) + }) + + it('discovers future tool and setting entries entirely from backend metadata', () => { + const vars = { + FUTURE_CRAWLER_API_KEY: envVar('tool', { + description: 'Fetch structured pages from a new crawler.', + tools: ['future_crawl'], + url: 'https://future.example/keys' + }), + FUTURE_GATEWAY_URL: envVar('setting', { description: 'Route gateway traffic.' }), + TELEGRAM_BOT_TOKEN: envVar('messaging', { channel_managed: true }), + MODEL_PROVIDER_API_KEY: envVar('provider') + } + + const entries = buildCredentialSearchEntries( + vars, + { settings: 'Settings', tools: 'Tools' }, + { settings: Settings2, tools: Wrench } + ) + + expect(entries.map(entry => entry.id)).toEqual([ + 'credential:FUTURE_CRAWLER_API_KEY', + 'credential:FUTURE_GATEWAY_URL' + ]) + expect(entries[0]).toMatchObject({ + context: 'Tools', + label: 'FUTURE CRAWLER', + target: { key: 'FUTURE_CRAWLER_API_KEY', keysView: 'tools', view: 'keys' } + }) + expect(filterSettingsSearchEntries(entries, 'structured crawler')).toHaveLength(1) + expect(filterSettingsSearchEntries(entries, 'future_crawl')[0]?.id).toBe('credential:FUTURE_CRAWLER_API_KEY') + expect(filterSettingsSearchEntries(entries, 'gateway traffic')[0]?.id).toBe('credential:FUTURE_GATEWAY_URL') + }) + + it('shares the Tools and Settings category boundary with the rendered page', () => { + expect(credentialSettingsView(envVar('tool'))).toBe('tools') + expect(credentialSettingsView(envVar('setting'))).toBe('settings') + expect(credentialSettingsView(envVar('messaging'))).toBe('settings') + expect(credentialSettingsView(envVar('messaging', { channel_managed: true }))).toBeNull() + expect(credentialSettingsView(envVar('provider'))).toBeNull() + }) + + it('uses AND matching across labels, context, descriptions, and raw keys', () => { + const entries = buildCredentialSearchEntries( + { + BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search public web pages.' }), + FIRECRAWL_API_KEY: envVar('tool', { description: 'Extract public web pages.' }) + }, + { settings: 'Settings', tools: 'Tools' }, + { settings: Settings2, tools: Wrench } + ) + + expect(filterSettingsSearchEntries(entries, 'brave tools')[0]?.id).toBe('credential:BRAVE_SEARCH_API_KEY') + expect(filterSettingsSearchEntries(entries, 'firecrawl extract')[0]?.id).toBe('credential:FIRECRAWL_API_KEY') + expect(filterSettingsSearchEntries(entries, 'brave extract')).toEqual([]) + }) +}) diff --git a/apps/desktop/src/app/settings/settings-search.ts b/apps/desktop/src/app/settings/settings-search.ts new file mode 100644 index 0000000000..4fc75eebd3 --- /dev/null +++ b/apps/desktop/src/app/settings/settings-search.ts @@ -0,0 +1,227 @@ +import type { IconComponent } from '@/lib/icons' +import { normalize } from '@/lib/text' +import type { ConfigFieldSchema, EnvVarInfo, HermesConfigRecord } from '@/types/hermes' + +import { FIELD_LABELS, SECTIONS } from './constants' +import { credentialRowLabel } from './credential-key-ui' +import { fieldCopyForSchemaKey } from './field-copy' +import { prettyName, sectionFieldEntries, voiceFieldVisible } from './helpers' +import type { DesktopConfigSection, SettingsView } from './types' + +export type CredentialSettingsView = 'settings' | 'tools' + +export const APPEARANCE_SETTING_IDS = { + backdrop: 'appearance.backdrop', + embeds: 'appearance.embeds', + language: 'appearance.language', + theme: 'appearance.theme', + toolView: 'appearance.tool-view', + translucency: 'appearance.translucency', + uiScale: 'appearance.ui-scale' +} as const + +export interface SettingsSearchTarget { + field?: string + key?: string + keysView?: CredentialSettingsView + plugin?: string + providerView?: 'accounts' | 'custom-endpoints' | 'keys' + setting?: string + view: SettingsView +} + +export interface SettingsSearchEntry { + context: string + description?: string + icon: IconComponent + id: string + keywords: string[] + label: string + target: SettingsSearchTarget +} + +interface ConfigSearchCopy { + fieldDescriptions: Record + fieldLabels: Record + sections: Record +} + +interface CredentialSearchCopy { + settings: string + tools: string +} + +export function credentialSettingsView(info: EnvVarInfo): CredentialSettingsView | null { + if (info.channel_managed) { + return null + } + + if (info.category === 'tool') { + return 'tools' + } + + if (info.category === 'setting' || info.category === 'messaging') { + return 'settings' + } + + return null +} + +function configFieldLabel(key: string, copy: ConfigSearchCopy): string { + return ( + fieldCopyForSchemaKey(copy.fieldLabels, key) ?? + fieldCopyForSchemaKey(FIELD_LABELS, key) ?? + prettyName(key.split('.').pop() ?? key) + ) +} + +function configFieldDescription(key: string, field: ConfigFieldSchema, copy: ConfigSearchCopy): string { + return fieldCopyForSchemaKey(copy.fieldDescriptions, key) ?? field.description ?? '' +} + +export function buildConfigSearchEntries( + schema: Record | null | undefined, + config: HermesConfigRecord | null | undefined, + copy: ConfigSearchCopy, + sections: DesktopConfigSection[] = SECTIONS +): SettingsSearchEntry[] { + if (!schema || !config) { + return [] + } + + const sectionFields = sectionFieldEntries(schema, config) + + return sections.flatMap(section => { + const context = copy.sections[section.id] ?? section.label + const fields = sectionFields.get(section.id) ?? [] + const visibleFields = section.id === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields + + return visibleFields.map(([key, field]) => ({ + context, + description: configFieldDescription(key, field, copy), + icon: section.icon, + id: `config-field:${key}`, + keywords: ['settings', section.id, section.label, key], + label: configFieldLabel(key, copy), + target: { + field: key, + view: `config:${section.id}` as SettingsView + } + })) + }) +} + +export function buildCredentialSearchEntries( + vars: Record | null | undefined, + copy: CredentialSearchCopy, + icons: Record +): SettingsSearchEntry[] { + if (!vars) { + return [] + } + + return Object.entries(vars) + .sort(([a], [b]) => a.localeCompare(b)) + .flatMap(([key, info]) => { + const view = credentialSettingsView(info) + + if (!view) { + return [] + } + + return [ + { + context: view === 'tools' ? copy.tools : copy.settings, + description: info.description || undefined, + icon: icons[view], + id: `credential:${key}`, + keywords: [key, info.url ?? '', ...(Array.isArray(info.tools) ? info.tools : [])], + label: credentialRowLabel(key, info), + target: { + key, + keysView: view, + view: 'keys' as const + } + } + ] + }) +} + +function searchScore(entry: SettingsSearchEntry, query: string): number { + const needle = normalize(query) + + if (!needle) { + return 0 + } + + const label = normalize(entry.label) + const context = normalize(entry.context) + const haystack = normalize([entry.label, entry.context, entry.description ?? '', ...entry.keywords].join(' ')) + const terms = needle.split(/\s+/).filter(Boolean) + + if (!terms.every(term => haystack.includes(term))) { + return 0 + } + + if (label === needle) { + return 100 + } + + if (label.startsWith(needle)) { + return 90 + } + + if (label.includes(needle)) { + return 80 + } + + if (context.includes(needle)) { + return 70 + } + + if (terms.every(term => label.includes(term) || context.includes(term))) { + return 60 + } + + return 50 +} + +export function filterSettingsSearchEntries(entries: SettingsSearchEntry[], query: string): SettingsSearchEntry[] { + return entries + .map((entry, index) => ({ entry, index, score: searchScore(entry, query) })) + .filter(result => result.score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(result => result.entry) +} + +/** Serialize a search target to the Settings route's query string. */ +export function settingsSearchTargetQuery(target: SettingsSearchTarget): string { + const params = new URLSearchParams() + params.set('tab', target.view) + + if (target.providerView) { + params.set('pview', target.providerView) + } + + if (target.keysView) { + params.set('kview', target.keysView) + } + + if (target.field) { + params.set('field', target.field) + } + + if (target.setting) { + params.set('setting', target.setting) + } + + if (target.key) { + params.set('key', target.key) + } + + if (target.plugin) { + params.set('plugin', target.plugin) + } + + return params.toString() +} diff --git a/apps/desktop/src/app/settings/use-deep-link-highlight.ts b/apps/desktop/src/app/settings/use-deep-link-highlight.ts index 35321c1ac6..2a93596cec 100644 --- a/apps/desktop/src/app/settings/use-deep-link-highlight.ts +++ b/apps/desktop/src/app/settings/use-deep-link-highlight.ts @@ -61,6 +61,12 @@ export function useDeepLinkHighlight({ if (element) { element.scrollIntoView({ behavior: 'smooth', block }) + + if (!element.hasAttribute('tabindex')) { + element.tabIndex = -1 + } + + element.focus({ preventScroll: true }) element.classList.add('setting-field-highlight') window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600) diff --git a/apps/desktop/src/app/settings/use-settings-search.ts b/apps/desktop/src/app/settings/use-settings-search.ts new file mode 100644 index 0000000000..20d9b44173 --- /dev/null +++ b/apps/desktop/src/app/settings/use-settings-search.ts @@ -0,0 +1,191 @@ +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import { useCallback, useEffect } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { $pluginRecords } from '@/contrib/plugins-store' +import { getEnvVars, getHermesConfigSchema } from '@/hermes' +import { useI18n } from '@/i18n' +import { Package, Palette, Settings2, Wrench } from '@/lib/icons' +import { $agentPlugins, isDesktopRelevantPlugin, loadAgentPlugins } from '@/store/agent-plugins' +import { $gatewayState } from '@/store/session' + +import { useHermesConfigRecord } from '../hooks/use-config-record' +import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' + +import { + APPEARANCE_SETTING_IDS, + buildConfigSearchEntries, + buildCredentialSearchEntries, + type SettingsSearchEntry +} from './settings-search' + +/** + * The granular settings-search catalog (appearance controls, config fields, + * credentials) for the command palette's Settings page. Page destinations stay + * on the palette side — it already owns section/page rows — this hook only + * contributes the deep, schema-driven targets. + */ +export function useSettingsSearchCatalog(enabled: boolean) { + const { t } = useI18n() + const configQuery = useHermesConfigRecord() + + const schemaQuery = useQuery({ + queryKey: ['hermes-config-schema'], + queryFn: () => getHermesConfigSchema(), + enabled, + staleTime: 5 * 60 * 1000 + }) + + const { + data: envVars, + isError: envVarsError, + isFetching: envVarsFetching, + refetch: refetchEnvVars + } = useQuery({ + queryKey: ['desktop-settings-search-env-vars'], + queryFn: () => getEnvVars(), + enabled, + staleTime: 5 * 60 * 1000 + }) + + const refreshCatalog = useCallback(() => { + void refetchEnvVars() + }, [refetchEnvVars]) + + useOnProfileSwitch(refreshCatalog) + + // Plugin rows: desktop plugins are already in their store (discovered at + // boot); agent plugins ride the gateway, so load them the first time the + // catalog is wanted — same RPC the Plugins page fires on mount, deduped by + // the store's own inflight guard. + const { requestGateway } = useGatewayRequest() + const gatewayState = useStore($gatewayState) + const desktopPluginRecords = useStore($pluginRecords) + const agentPlugins = useStore($agentPlugins) + + useEffect(() => { + if (enabled && gatewayState === 'open') { + void loadAgentPlugins(requestGateway) + } + }, [enabled, gatewayState, requestGateway]) + + const pluginContext = t.settings.nav.plugins + + const pluginEntries: SettingsSearchEntry[] = [ + ...Object.values(desktopPluginRecords).map(record => ({ + context: pluginContext, + description: record.description, + icon: Package, + id: `plugin:desktop:${record.id}`, + keywords: ['plugin', 'extension', record.id], + label: record.name, + target: { plugin: record.id, view: 'plugins' as const } + })), + ...agentPlugins.filter(isDesktopRelevantPlugin).map(row => ({ + context: pluginContext, + description: row.description || undefined, + icon: Package, + id: `plugin:agent:${row.key ?? row.name}`, + keywords: ['plugin', 'extension', ...(row.key ? [row.key] : [])], + label: row.name, + target: { plugin: row.key ?? row.name, view: 'plugins' as const } + })) + ] + + // Never expose stale profile-scoped targets while a catalog is refreshing. + // Field/key results wait for the current profile's data rather than briefly + // pointing into the previous one. + const configEntries = + configQuery.isFetching || schemaQuery.isFetching || configQuery.isError || schemaQuery.isError + ? [] + : buildConfigSearchEntries(schemaQuery.data?.fields, configQuery.data, { + fieldDescriptions: t.settings.fieldDescriptions, + fieldLabels: t.settings.fieldLabels, + sections: t.settings.sections + }) + + const appearanceContext = t.settings.sections.appearance + const appearance = t.settings.appearance + + const appearanceEntries: SettingsSearchEntry[] = [ + { + context: appearanceContext, + description: t.language.description, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.language}`, + keywords: ['locale'], + label: t.language.label, + target: { setting: APPEARANCE_SETTING_IDS.language, view: 'config:appearance' } + }, + { + context: appearanceContext, + description: appearance.themeDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.theme}`, + keywords: ['color mode', 'skin'], + label: appearance.themeTitle, + target: { setting: APPEARANCE_SETTING_IDS.theme, view: 'config:appearance' } + }, + { + context: appearanceContext, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.uiScale}`, + keywords: ['zoom', 'size'], + label: appearance.uiScaleTitle, + target: { setting: APPEARANCE_SETTING_IDS.uiScale, view: 'config:appearance' } + }, + { + context: appearanceContext, + description: appearance.translucencyDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.translucency}`, + keywords: ['opacity', 'transparent'], + label: appearance.translucencyTitle, + target: { setting: APPEARANCE_SETTING_IDS.translucency, view: 'config:appearance' } + }, + { + context: appearanceContext, + description: appearance.backdropDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.backdrop}`, + keywords: ['background', 'blur'], + label: appearance.backdropTitle, + target: { setting: APPEARANCE_SETTING_IDS.backdrop, view: 'config:appearance' } + }, + { + context: appearanceContext, + description: appearance.toolViewDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.toolView}`, + keywords: ['tool display', 'technical'], + label: appearance.toolViewTitle, + target: { setting: APPEARANCE_SETTING_IDS.toolView, view: 'config:appearance' } + }, + { + context: appearanceContext, + description: appearance.embedsDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.embeds}`, + keywords: ['external content', 'privacy'], + label: appearance.embedsTitle, + target: { setting: APPEARANCE_SETTING_IDS.embeds, view: 'config:appearance' } + } + ] + + const credentialEntries = buildCredentialSearchEntries( + envVarsFetching || envVarsError ? null : envVars, + { + settings: t.settings.nav.keysSettings, + tools: t.settings.nav.keysTools + }, + { settings: Settings2, tools: Wrench } + ) + + return { + appearanceEntries, + configEntries, + credentialEntries, + pluginEntries + } +} diff --git a/apps/desktop/src/app/settings/voice-field-visible.test.ts b/apps/desktop/src/app/settings/voice-field-visible.test.ts index 61228e7c7d..95b7810f4f 100644 --- a/apps/desktop/src/app/settings/voice-field-visible.test.ts +++ b/apps/desktop/src/app/settings/voice-field-visible.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' -import { voiceFieldVisible } from './config-settings' +import { voiceFieldVisible } from './helpers' const cfg = (over: Record = {}): HermesConfigRecord => ({ diff --git a/apps/desktop/src/components/ui/scoped-command-search.tsx b/apps/desktop/src/components/ui/scoped-command-search.tsx new file mode 100644 index 0000000000..a1ebd69076 --- /dev/null +++ b/apps/desktop/src/components/ui/scoped-command-search.tsx @@ -0,0 +1,156 @@ +import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' + +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { ChevronRight } from '@/lib/icons' + +export interface ScopedCommandSearchItem { + description?: string + id: string + keywords?: string[] + label: string +} + +/** + * Embeddable cmdk search whose caller owns the catalog and destination. + * Passing only the current pane's items keeps results scoped without teaching + * this component about settings, credentials, or routes. + */ +export function ScopedCommandSearch({ + busy = false, + emptyMessage, + items, + itemClassName = 'grid-cols-[minmax(0,1fr)_auto]', + listClassName = 'max-h-64', + listHeader, + onSelect, + onValueChange, + placeholder, + popoverClassName = 'inset-x-0', + renderItem, + resultSummary, + shouldFilter = true, + value +}: ScopedCommandSearchProps) { + const inputRef = useRef(null) + const rootRef = useRef(null) + const [dismissed, setDismissed] = useState(false) + const open = Boolean(value.trim()) && !dismissed + + useLayoutEffect(() => { + inputRef.current?.setAttribute('aria-expanded', String(open)) + }, [open]) + + useEffect(() => { + if (!open) { + return + } + + const dismissOutside = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setDismissed(true) + } + } + + globalThis.document.addEventListener('pointerdown', dismissOutside) + + return () => globalThis.document.removeEventListener('pointerdown', dismissOutside) + }, [open]) + + const updateValue = (next: string) => { + setDismissed(false) + onValueChange(next) + } + + return ( + + { + globalThis.requestAnimationFrame(() => { + if (!rootRef.current?.contains(globalThis.document.activeElement)) { + setDismissed(true) + } + }) + }} + onFocus={() => setDismissed(false)} + onKeyDown={event => { + if (event.key === 'Escape' && open) { + event.preventDefault() + event.stopPropagation() + updateValue('') + } + }} + onValueChange={updateValue} + placeholder={placeholder} + ref={inputRef} + value={value} + /> + + + + ) +} + +interface ScopedCommandSearchProps { + busy?: boolean + emptyMessage: ReactNode + itemClassName?: string + items: T[] + listClassName?: string + listHeader?: ReactNode + onSelect: (item: T) => void + onValueChange: (value: string) => void + placeholder: string + popoverClassName?: string + renderItem?: (item: T) => ReactNode + resultSummary?: string + shouldFilter?: boolean + value: string +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d87a8be1a1..e09354fff8 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -866,7 +866,14 @@ export const en: Translations = { keys: { loading: 'Loading API keys and credentials...', failedLoad: 'API keys failed to load', - empty: 'Nothing configured in this category yet.' + empty: 'Nothing configured in this category yet.', + searchTools: 'Search tools…', + searchSettings: 'Search settings…', + noMatch: 'No entries match your search.' + }, + search: { + placeholder: 'Search all settings…', + pill: 'Search' }, profileScope: { appliesTo: 'Applies to', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index b9c7cf6d55..de0138fc4d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -852,7 +852,14 @@ export const ja = defineLocale({ keys: { loading: 'API キーと認証情報を読み込み中...', failedLoad: 'API キーの読み込みに失敗しました', - empty: 'このカテゴリーにはまだ設定がありません。' + empty: 'このカテゴリーにはまだ設定がありません。', + searchTools: 'ツールを検索...', + searchSettings: '設定を検索...', + noMatch: '検索に一致する項目はありません。' + }, + search: { + placeholder: 'すべての設定を検索...', + pill: '検索' }, profileScope: { appliesTo: '適用対象', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 6518c53cc8..212319a4ae 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -737,6 +737,13 @@ export interface Translations { loading: string failedLoad: string empty: string + searchTools: string + searchSettings: string + noMatch: string + } + search: { + placeholder: string + pill: string } profileScope: { appliesTo: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index a2ad143819..5c8b8787a3 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -827,7 +827,14 @@ export const zhHant = defineLocale({ keys: { loading: '正在載入 API 金鑰和憑證...', failedLoad: 'API 金鑰載入失敗', - empty: '此類別尚未有任何設定。' + empty: '此類別尚未有任何設定。', + searchTools: '搜尋工具...', + searchSettings: '搜尋設定...', + noMatch: '沒有符合搜尋條件的項目。' + }, + search: { + placeholder: '搜尋所有設定...', + pill: '搜尋' }, profileScope: { appliesTo: '套用至', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 7236571c5a..f51b2d681e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1064,7 +1064,14 @@ export const zh: Translations = { keys: { loading: '正在加载 API 密钥和凭据...', failedLoad: 'API 密钥加载失败', - empty: '此类别暂时没有配置项。' + empty: '此类别暂时没有配置项。', + searchTools: '搜索工具...', + searchSettings: '搜索设置...', + noMatch: '没有与搜索匹配的条目。' + }, + search: { + placeholder: '搜索所有设置...', + pill: '搜索' }, profileScope: { appliesTo: '应用于', diff --git a/apps/desktop/src/store/agent-plugins.ts b/apps/desktop/src/store/agent-plugins.ts index fcec7c2210..9b08d9cdd5 100644 --- a/apps/desktop/src/store/agent-plugins.ts +++ b/apps/desktop/src/store/agent-plugins.ts @@ -39,6 +39,23 @@ export const $agentPluginsError = atom(null) /** Best available address of the row whose toggle RPC is in flight. */ export const $agentPluginBusy = atom(null) +// Rows the Plugins page actually lists (and search should surface): plugins +// the USER installed. Repo-bundled built-ins ship enabled-by-default and are +// configured from their own surfaces, so they're pure noise here. The prefix +// list is the fallback for older backends whose rows predate a reliable +// `source` field — same curation stance as desktop-slash-commands.ts. +const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/'] + +export const isDesktopRelevantPlugin = (row: AgentPluginRow): boolean => { + if (row.source === 'bundled') { + return false + } + + const key = row.key + + return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix)) +} + let inflight: Promise | null = null let inflightProfile: string | null = null // Bumped per load so a slow response from a previous profile scope can't diff --git a/apps/desktop/src/store/command-palette.ts b/apps/desktop/src/store/command-palette.ts index 9b0a427f92..b6fe80c1e9 100644 --- a/apps/desktop/src/store/command-palette.ts +++ b/apps/desktop/src/store/command-palette.ts @@ -8,13 +8,22 @@ export const $commandPaletteOpen = atom(false) /** Optional nested page to open when the palette next opens (e.g. `pets`). */ export const $commandPalettePage = atom(null) +/** Optional search text to prefill when the palette next opens (type-to-search + * from a surface: the keystroke that opened the palette must not be lost). */ +export const $commandPaletteSeed = atom(null) + export function openCommandPalette(): void { $commandPaletteOpen.set(true) } /** Open the palette directly on a nested page (`theme`, `pets`, …). */ -export function openCommandPalettePage(page: string): void { +export function openCommandPalettePage(page: string, seed?: string): void { $commandPalettePage.set(page) + + if (seed) { + $commandPaletteSeed.set(seed) + } + $commandPaletteOpen.set(true) } @@ -29,6 +38,7 @@ function setOpen(open: boolean): void { if (!open) { $commandPalettePage.set(null) + $commandPaletteSeed.set(null) if (wasOpen) { releaseTypingFocus()