Settings search is ⌘K: deep field/credential results everywhere, scoped palette + edge pill on Settings (#89167)

* feat(desktop): add scoped credential command search

* feat(desktop): add global settings search

* feat(desktop): settings search lives in the command palette, deep everywhere

Replace the PR's sidebar search input with a settings page inside the ⌘K
palette (the subPages mechanism themes/pets already use). ⌘K on the
Settings overlay opens the palette scoped to settings; Back/Esc/Backspace
step out to the root. The deep catalog — schema-driven config fields,
appearance controls, stored credentials — now also serves the ROOT
palette's typed results, replacing the static section-key list, so ⌘K is
the same settings search everywhere. The input refocuses on page swaps,
and a seed store carries the keystroke that opened the palette so
type-to-search never loses the first character.

* feat(desktop): search pill rides the Settings card's top edge and hands off to ⌘K

The trigger is a fake pill — pure chrome, not an input — centered on the
overlay card's top border, half off the card (a new OverlayView edgeBadge
slot; a sibling of the card since the card clips its own corners). Click
or just start typing to open the scoped palette; while the palette is up
the pill scales up and fades out, then fades back when it closes.
Type-to-search forwards printable keys (outside editable targets) with
the keystroke seeded into the palette filter.

* feat(desktop): plugins are searchable — ⌘K 'kanban' lands on Settings → Plugins with the row flashed

The catalog stopped at config fields and credentials; installed plugins
were invisible to search. Both stores now feed it — desktop plugins from
$pluginRecords, agent plugins via the same plugins.manage RPC the Plugins
page fires (deduped by the store's inflight guard, filtered by the shared
desktop-relevance curation, which moves to the store so both callers use
one predicate). Rows deep-link as ?tab=plugins&plugin=<id|key> and the
page scroll-flashes the row via the shared useDeepLinkHighlight hook.

* style(desktop): satisfy perfectionist import/prop ordering

* test(desktop): add setApiRequestProfile to KeysSettings hermes mock

---------

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
Co-authored-by: Adolanium <adolanium@users.noreply.github.com>
This commit is contained in:
brooklyn!
2026-08-18 08:28:39 -05:00
committed by GitHub
parent 593ad08496
commit eb6922a8cd
24 changed files with 1459 additions and 190 deletions
+128 -23
View File
@@ -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<string | null>(null)
const inputRef = useRef<HTMLInputElement>(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<PaletteGroup[]>(() => {
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<PaletteGroup[]>(() => {
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<Record<string, PalettePage>>(
@@ -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' ? <PetInlineToggle /> : undefined}
value={search}
/>
+12 -2
View File
@@ -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),
+53 -29
View File
@@ -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}
>
<div
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) shadow-md',
rootClassName
)}
// Marks the card as a RAISED surface for window glass: while the field
// behind it thins to show the desktop, this card stays near-opaque
// (see the [data-glass-raised] rules in styles.css). Inert otherwise.
data-glass-raised=""
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
{headerContent && (
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
</div>
<div className="relative h-full min-h-0">
<div
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) shadow-md',
rootClassName
)}
// Marks the card as a RAISED surface for window glass: while the field
// behind it thins to show the desktop, this card stays near-opaque
// (see the [data-glass-raised] rules in styles.css). Inert otherwise.
data-glass-raised=""
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
{headerContent && (
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
</div>
)}
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon-titlebar"
variant="ghost"
>
<TitlebarIcon name="close" />
</Button>
<div className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] flex -translate-y-1/2 items-center gap-1.5 [-webkit-app-region:no-drag]">
{titlebarActions}
<Button
aria-label={closeLabel}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={closeOverlay}
size="icon-titlebar"
variant="ghost"
>
<TitlebarIcon name="close" />
</Button>
</div>
</div>
{/* 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). */}
<div className={cn('min-h-0 flex flex-1 flex-col', contentClassName)}>{children}</div>
</div>
{/* 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). */}
<div className={cn('min-h-0 flex flex-1 flex-col', contentClassName)}>{children}</div>
{/* 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 && (
<div className="absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-1/2">
{edgeBadge}
</div>
)}
</div>
</div>
)
@@ -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<string>(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() {
<ListRow
action={<LanguageSwitcher />}
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={
<div className="flex items-center justify-between gap-3">
<span>{a.themeTitle}</span>
@@ -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() {
</div>
}
description={a.embedsDesc}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.embeds)}
title={a.embedsTitle}
/>
</div>
@@ -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)
+18
View File
@@ -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' }
+99 -39
View File
@@ -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 = (
<button
className={cn(
'flex h-(--titlebar-control-height) items-center gap-1.5 rounded-full border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) px-2.5 text-(--ui-text-tertiary) shadow-sm transition-all duration-200 ease-out hover:text-foreground motion-reduce:transition-none',
paletteOpen && 'pointer-events-none scale-110 opacity-0'
)}
onClick={() => {
triggerHaptic('open')
openCommandPalettePage('settings')
}}
tabIndex={paletteOpen ? -1 : undefined}
type="button"
>
<Search className="size-3" />
<span className="text-xs">{t.settings.search.pill}</span>
{searchCombo && <KbdCombo combo={searchCombo} size="sm" variant="ghost" />}
</button>
)
const navFooter = (
<>
<Tip label={t.settings.exportConfig}>
@@ -304,49 +363,50 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
</>
)
const activeSettingsContent =
activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
<AboutSettings />
) : activeView === 'gateway' || activeView === 'connections' ? (
// 'connections' renders the unified page too so the frame before
// the alias redirect lands doesn't flash the fallback view.
<GatewaySettings />
) : activeView === 'keybinds' ? (
<KeybindSettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings
onClose={onClose}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
onViewChange={setProviderView}
view={providerView}
/>
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : activeView === 'billing' ? (
<BillingSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
<SessionsSettings />
)
return (
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
<OverlayView closeLabel={t.settings.closeSettings} edgeBadge={searchPill} onClose={onClose}>
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />
<OverlayMain className="px-0 pb-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
<AboutSettings />
) : activeView === 'gateway' || activeView === 'connections' ? (
// 'connections' renders the unified page too so the frame before
// the alias redirect lands doesn't flash the fallback view.
<GatewaySettings />
) : activeView === 'keybinds' ? (
<KeybindSettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings
onClose={onClose}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
onViewChange={setProviderView}
view={providerView}
/>
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : activeView === 'billing' ? (
<BillingSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
<SessionsSettings />
)}
</OverlayMain>
<OverlayMain className="px-0 pb-0">{activeSettingsContent}</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
@@ -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> = {}): 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(
<MemoryRouter initialEntries={[route]}>
<KeysSettings view={view} />
</MemoryRouter>
)
})
}
function DeepLinkButton({ target }: { target: string }) {
const navigate = useNavigate()
return (
<button onClick={() => navigate(`/settings?tab=keys&key=${target}`)} type="button">
Open key
</button>
)
}
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(
<MemoryRouter initialEntries={['/settings?tab=keys']}>
<KeysSettings view="tools" />
<DeepLinkButton target="BRAVE_SEARCH_API_KEY" />
</MemoryRouter>
)
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()
})
})
+100 -45
View File
@@ -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<KeysView, readonly string[]> = {
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 | string>(null)
const [query, setQuery] = useState('')
useEffect(() => {
setOpenKey(null)
setQuery('')
}, [scopeProfile, view])
// Deep link from Capabilities env-var rows (?tab=keys&key=<ENV_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<ScopedCommandSearchItem[]>(
() =>
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=<ENV_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 <SettingsSkeleton sections={[{ rows: 5 }]} />
return <SettingsSkeleton search sections={[{ rows: 5 }]} />
}
const visible = groups.filter(g => g.category === view)
const searchLabel = view === 'tools' ? t.settings.keys.searchTools : t.settings.keys.searchSettings
return (
<SettingsContent>
<SettingsProfileScope className="mb-5" />
{visible.map(group => (
<div className="grid gap-2" key={group.category}>
{group.entries.map(([key, info]: [string, EnvVarInfo]) => {
const label = credentialRowLabel(key, info)
{entries.length > 0 ? (
<>
<div className="pb-3">
<ScopedCommandSearch
emptyMessage={t.settings.keys.noMatch}
items={searchItems}
onSelect={selectSearchResult}
onValueChange={setQuery}
placeholder={searchLabel}
value={query}
/>
</div>
return (
<div className="scroll-mt-6 rounded-[6px]" id={`credential-key-${key}`} key={key}>
<CredentialKeyCard
expanded={openKey === key}
info={info}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
</div>
)
})}
</div>
))}
<div className="grid gap-2">
{entries.map(([key, info]) => {
const label = credentialRowLabel(key, info)
{visible.length === 0 && (
return (
<div className="scroll-mt-6 rounded-[6px]" id={`credential-key-${key}`} key={key}>
<CredentialKeyCard
expanded={openKey === key}
info={info}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
</div>
)
})}
</div>
</>
) : (
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
{t.settings.keys.empty}
</div>
@@ -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<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
// User-installed plugins first — mirrors `hermes plugins list --user`.
const SOURCE_ORDER: Record<string, number> = { 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=<id>`). */
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 (
<div className="flex items-start gap-3 py-2">
<div className="flex items-start gap-3 rounded-lg py-2" id={id}>
<div className="min-w-0 flex-1 pr-4">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{title}
@@ -172,6 +158,7 @@ function AgentPluginRowView({ row, profile }: { row: AgentPluginRow; profile: st
<PluginLine
controls={key ? toggle : <Tip label={p.agent.updateBackendToManage}>{toggle}</Tip>}
description={row.description || (row.version ? `v${row.version}` : undefined)}
id={pluginElementId(key ?? row.name)}
title={
<>
<span>{row.name}</span>
@@ -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={
<>
<span>{record.name}</span>
@@ -371,6 +359,15 @@ export function PluginsSettings() {
const p = t.settings.plugins
const records = useStore($pluginRecords)
// Deep-link from settings search (?plugin=<id or key>): 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)
)
+3 -1
View File
@@ -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,…).
<div className={cn('@container', className)}>
<div className={cn('@container', className)} id={id}>
<div
className={cn(
'grid gap-3 py-3',
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { Settings2, Wrench } from '@/lib/icons'
import type { ConfigFieldSchema, EnvVarInfo, HermesConfigRecord } from '@/types/hermes'
import {
buildConfigSearchEntries,
buildCredentialSearchEntries,
credentialSettingsView,
filterSettingsSearchEntries
} from './settings-search'
function envVar(category: string, patch: Partial<EnvVarInfo> = {}): 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<string, ConfigFieldSchema> = {
'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([])
})
})
@@ -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<string, string>
fieldLabels: Record<string, string>
sections: Record<string, string>
}
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<string, ConfigFieldSchema> | 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<string, EnvVarInfo> | null | undefined,
copy: CredentialSearchCopy,
icons: Record<CredentialSettingsView, IconComponent>
): 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()
}
@@ -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)
@@ -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
}
}
@@ -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<string, unknown> = {}): HermesConfigRecord =>
({
@@ -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<T extends ScopedCommandSearchItem>({
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<T>) {
const inputRef = useRef<HTMLInputElement>(null)
const rootRef = useRef<HTMLDivElement>(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 (
<Command
className="relative h-auto overflow-visible rounded-none bg-transparent"
label={placeholder}
loop
ref={rootRef}
shouldFilter={shouldFilter}
>
<CommandInput
aria-expanded={open}
aria-label={placeholder}
className="text-xs"
onBlur={() => {
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}
/>
<div
className={`absolute top-full z-30 mt-1 rounded-[6px] border border-border bg-popover shadow-nous ${popoverClassName}`}
hidden={!open}
>
{resultSummary && (
<p aria-live="polite" className="sr-only" role="status">
{resultSummary}
</p>
)}
{listHeader}
<CommandList aria-busy={busy} className={`dt-portal-scrollbar p-1 ${listClassName}`}>
{emptyMessage && <CommandEmpty>{emptyMessage}</CommandEmpty>}
<CommandGroup className="p-0">
{open &&
items.map(item => (
<CommandItem
className={`group grid gap-3 px-3 py-2.5 ${itemClassName}`}
key={item.id}
keywords={item.keywords}
onSelect={() => onSelect(item)}
value={`${item.label}\u0001${item.id}`}
>
{renderItem ? (
renderItem(item)
) : (
<>
<span className="min-w-0">
<span className="block truncate font-medium">{item.label}</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{item.description || item.id}
</span>
</span>
<ChevronRight className="size-4 self-center text-muted-foreground opacity-0 transition-opacity group-data-[selected=true]:opacity-100" />
</>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
</Command>
)
}
interface ScopedCommandSearchProps<T extends ScopedCommandSearchItem> {
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
}
+8 -1
View File
@@ -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',
+8 -1
View File
@@ -852,7 +852,14 @@ export const ja = defineLocale({
keys: {
loading: 'API キーと認証情報を読み込み中...',
failedLoad: 'API キーの読み込みに失敗しました',
empty: 'このカテゴリーにはまだ設定がありません。'
empty: 'このカテゴリーにはまだ設定がありません。',
searchTools: 'ツールを検索...',
searchSettings: '設定を検索...',
noMatch: '検索に一致する項目はありません。'
},
search: {
placeholder: 'すべての設定を検索...',
pill: '検索'
},
profileScope: {
appliesTo: '適用対象',
+7
View File
@@ -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
+8 -1
View File
@@ -827,7 +827,14 @@ export const zhHant = defineLocale({
keys: {
loading: '正在載入 API 金鑰和憑證...',
failedLoad: 'API 金鑰載入失敗',
empty: '此類別尚未有任何設定。'
empty: '此類別尚未有任何設定。',
searchTools: '搜尋工具...',
searchSettings: '搜尋設定...',
noMatch: '沒有符合搜尋條件的項目。'
},
search: {
placeholder: '搜尋所有設定...',
pill: '搜尋'
},
profileScope: {
appliesTo: '套用至',
+8 -1
View File
@@ -1064,7 +1064,14 @@ export const zh: Translations = {
keys: {
loading: '正在加载 API 密钥和凭据...',
failedLoad: 'API 密钥加载失败',
empty: '此类别暂时没有配置项。'
empty: '此类别暂时没有配置项。',
searchTools: '搜索工具...',
searchSettings: '搜索设置...',
noMatch: '没有与搜索匹配的条目。'
},
search: {
placeholder: '搜索所有设置...',
pill: '搜索'
},
profileScope: {
appliesTo: '应用于',
+17
View File
@@ -39,6 +39,23 @@ export const $agentPluginsError = atom<string | null>(null)
/** Best available address of the row whose toggle RPC is in flight. */
export const $agentPluginBusy = atom<string | null>(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<void> | null = null
let inflightProfile: string | null = null
// Bumped per load so a slow response from a previous profile scope can't
+11 -1
View File
@@ -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<string | null>(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<string | null>(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()