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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -852,7 +852,14 @@ export const ja = defineLocale({
|
||||
keys: {
|
||||
loading: 'API キーと認証情報を読み込み中...',
|
||||
failedLoad: 'API キーの読み込みに失敗しました',
|
||||
empty: 'このカテゴリーにはまだ設定がありません。'
|
||||
empty: 'このカテゴリーにはまだ設定がありません。',
|
||||
searchTools: 'ツールを検索...',
|
||||
searchSettings: '設定を検索...',
|
||||
noMatch: '検索に一致する項目はありません。'
|
||||
},
|
||||
search: {
|
||||
placeholder: 'すべての設定を検索...',
|
||||
pill: '検索'
|
||||
},
|
||||
profileScope: {
|
||||
appliesTo: '適用対象',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -827,7 +827,14 @@ export const zhHant = defineLocale({
|
||||
keys: {
|
||||
loading: '正在載入 API 金鑰和憑證...',
|
||||
failedLoad: 'API 金鑰載入失敗',
|
||||
empty: '此類別尚未有任何設定。'
|
||||
empty: '此類別尚未有任何設定。',
|
||||
searchTools: '搜尋工具...',
|
||||
searchSettings: '搜尋設定...',
|
||||
noMatch: '沒有符合搜尋條件的項目。'
|
||||
},
|
||||
search: {
|
||||
placeholder: '搜尋所有設定...',
|
||||
pill: '搜尋'
|
||||
},
|
||||
profileScope: {
|
||||
appliesTo: '套用至',
|
||||
|
||||
@@ -1064,7 +1064,14 @@ export const zh: Translations = {
|
||||
keys: {
|
||||
loading: '正在加载 API 密钥和凭据...',
|
||||
failedLoad: 'API 密钥加载失败',
|
||||
empty: '此类别暂时没有配置项。'
|
||||
empty: '此类别暂时没有配置项。',
|
||||
searchTools: '搜索工具...',
|
||||
searchSettings: '搜索设置...',
|
||||
noMatch: '没有与搜索匹配的条目。'
|
||||
},
|
||||
search: {
|
||||
placeholder: '搜索所有设置...',
|
||||
pill: '搜索'
|
||||
},
|
||||
profileScope: {
|
||||
appliesTo: '应用于',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user