feat(settings): add item search (#1592)

Adds item-level search inside Settings so users can find concrete settings like provider auth, agent mode, terminal font size, tunnel options, notification events, and similar controls instead of only filtering top-level pages.
Groups search results by Settings page and shows localized labels plus optional descriptions where useful.
Supports keyboard navigation with Arrow Up/Down, Enter, and Escape, matching the existing autocomplete interaction style.
Opens the correct Settings page or split-page draft state before scrolling to the matching control.
Highlights the matched setting with a subtle token-based background so users can see where they landed without an aggressive outline.
Adds explicit data-settings-item anchors across Settings pages and a centralized search registry with runtime/mobile availability guards.
Updates Settings UI skill guidance so future Settings changes keep search registry entries, anchors, localization, and availability guards in sync.
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 12:15:15 +03:00
committed by GitHub
parent eff6f46ad9
commit 079e3a9bd6
44 changed files with 1428 additions and 87 deletions
@@ -8,6 +8,7 @@ import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
@@ -37,7 +38,7 @@ import type { OpenChamberSection } from '@/components/sections/openchamber/types
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
@@ -51,6 +52,7 @@ import {
type SettingsRuntimeContext,
type SettingsPageMeta,
} from '@/lib/settings/metadata';
import { buildSettingsSearchResults, type SettingsSearchResult } from '@/lib/settings/search';
// Same constraints as main sidebar
const SETTINGS_NAV_MIN_WIDTH = 176;
@@ -105,6 +107,7 @@ const pageOrder: SettingsPageSlug[] = [
];
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
const isVSCode = isVSCodeRuntime();
@@ -123,6 +126,17 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function nextUniqueName(baseName: string, existingNames: Iterable<string>): string {
const existing = new Set(existingNames);
let name = baseName;
let counter = 1;
while (existing.has(name)) {
name = `${baseName}-${counter}`;
counter += 1;
}
return name;
}
function getSettingsDetailHistoryEntry(state: unknown): SettingsDetailHistoryEntry | null {
if (!isObjectRecord(state)) {
return null;
@@ -298,15 +312,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const autoNavSlugRef = React.useRef<string | null>(null);
const [navWidth, setNavWidth] = React.useState(216);
const [settingsSearchQuery, setSettingsSearchQuery] = React.useState('');
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
const [hasManuallyResized, setHasManuallyResized] = React.useState(false);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(navWidth);
const containerRef = React.useRef<HTMLDivElement>(null);
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const activeSearchResultIndexRef = React.useRef(0);
const keyboardSearchNavigationRef = React.useRef(false);
const isDesktopApp = React.useMemo(() => {
return isDesktopShell();
}, []);
const isDesktopLocalOrigin = React.useMemo(() => {
return isDesktopShell() && isDesktopLocalOriginActive();
}, []);
// keep platform check available for future window chrome tweaks
@@ -506,6 +529,204 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
}
}, [t]);
const settingsSearchResults = React.useMemo(() => {
return buildSettingsSearchResults({
query: settingsSearchQuery,
runtimeCtx: { ...runtimeCtx, isMobile, isDesktopLocalOrigin },
visiblePageSlugs,
t,
getPageTitle,
});
}, [getPageTitle, isDesktopLocalOrigin, isMobile, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => {
if (result.id.startsWith('agents.')) {
const store = useAgentsStore.getState();
const name = nextUniqueName('new-agent', store.agents.map((agent) => agent.name));
store.setAgentDraft({ name, scope: 'user' });
store.setSelectedAgent(name);
return result.id === 'agents.create' ? 'agents.name' : result.id;
}
if (result.id.startsWith('commands.')) {
const store = useCommandsStore.getState();
const name = nextUniqueName('new-command', store.commands.map((command) => command.name));
store.setCommandDraft({ name, scope: 'user' });
store.setSelectedCommand(name);
return result.id === 'commands.create' ? 'commands.name' : result.id;
}
if (result.id.startsWith('mcp.')) {
const store = useMcpConfigStore.getState();
const name = nextUniqueName('new-mcp-server', store.mcpServers.map((server) => server.name));
store.setMcpDraft({
name,
scope: 'user',
type: 'local',
command: [],
url: '',
environment: [],
headers: [],
oauthEnabled: true,
oauthClientId: '',
oauthClientSecret: '',
oauthScope: '',
oauthRedirectUri: '',
timeout: '',
enabled: true,
});
store.setSelectedMcp(name);
return result.id === 'mcp.create' ? 'mcp.server' : result.id;
}
if (result.id.startsWith('snippets.')) {
const store = useSnippetsStore.getState();
const name = nextUniqueName('new-snippet', store.snippets.map((snippet) => snippet.name));
store.setSnippetDraft({ name, scope: 'global' });
store.setSelectedSnippet(name);
return result.id === 'snippets.create' ? 'snippets.content' : result.id;
}
if (result.id.startsWith('skills.')) {
const store = useSkillsStore.getState();
const name = nextUniqueName('new-skill', store.skills.map((skill) => skill.name));
store.setSkillDraft({ name, scope: 'user', source: 'opencode', description: '', instructions: '' });
store.setSelectedSkill(name);
return result.id === 'skills.create' ? 'skills.basic-information' : result.id;
}
if (result.id === 'providers.connect') {
useConfigStore.getState().setSelectedProvider(ADD_PROVIDER_SETTINGS_ID);
}
if (result.id === 'plugins.create') {
return 'plugins.spec';
}
return result.id;
}, []);
const groupedSettingsSearchResults = React.useMemo(() => {
const groups: Array<{ page: SettingsPageSlug; pageTitle: string; results: SettingsSearchResult[] }> = [];
const groupByPage = new Map<SettingsPageSlug, { page: SettingsPageSlug; pageTitle: string; results: SettingsSearchResult[] }>();
for (const result of settingsSearchResults) {
let group = groupByPage.get(result.page);
if (!group) {
group = { page: result.page, pageTitle: result.pageTitle, results: [] };
groupByPage.set(result.page, group);
groups.push(group);
}
group.results.push(result);
}
return groups;
}, [settingsSearchResults]);
React.useEffect(() => {
setActiveSearchResultIndex(0);
activeSearchResultIndexRef.current = 0;
keyboardSearchNavigationRef.current = false;
}, [settingsSearchQuery]);
React.useEffect(() => {
activeSearchResultIndexRef.current = activeSearchResultIndex;
}, [activeSearchResultIndex]);
React.useEffect(() => {
searchResultRefs.current[activeSearchResultIndex]?.scrollIntoView({ block: 'nearest' });
}, [activeSearchResultIndex]);
React.useEffect(() => {
if (activeSearchResultIndex >= settingsSearchResults.length) {
setActiveSearchResultIndex(Math.max(0, settingsSearchResults.length - 1));
}
searchResultRefs.current.length = settingsSearchResults.length;
}, [activeSearchResultIndex, settingsSearchResults.length]);
const openSearchResult = React.useCallback((result: SettingsSearchResult) => {
const targetId = prepareSettingsSearchTarget(result);
setPendingSearchItemId(targetId);
openPage(result.page);
if (isMobile) {
setMobileStage('page-content');
}
if (result.id === 'plugins.create' && typeof window !== 'undefined') {
window.setTimeout(() => {
window.dispatchEvent(new CustomEvent('openchamber:settings-open-plugin-add'));
}, 50);
}
}, [isMobile, openPage, prepareSettingsSearchTarget]);
const handleSettingsSearchKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (!settingsSearchQuery.trim()) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
setSettingsSearchQuery('');
return;
}
if (settingsSearchResults.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
keyboardSearchNavigationRef.current = true;
setActiveSearchResultIndex((current) => (current + 1) % settingsSearchResults.length);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
keyboardSearchNavigationRef.current = true;
setActiveSearchResultIndex((current) => (current - 1 + settingsSearchResults.length) % settingsSearchResults.length);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const safeIndex = ((activeSearchResultIndexRef.current % settingsSearchResults.length) + settingsSearchResults.length) % settingsSearchResults.length;
const result = settingsSearchResults[safeIndex] ?? settingsSearchResults[0];
if (result) {
openSearchResult(result);
}
}
}, [openSearchResult, settingsSearchQuery, settingsSearchResults]);
React.useEffect(() => {
const targetId = pendingSearchItemId;
if (!targetId) {
return;
}
let cancelled = false;
const frame = window.requestAnimationFrame(() => {
if (cancelled) {
return;
}
const escapedId = typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(targetId)
: targetId.replace(/[^a-zA-Z0-9_-]/g, '\\$&');
const target = containerRef.current?.querySelector<HTMLElement>(`[data-settings-item="${escapedId}"]`);
if (!target) {
return;
}
setPendingSearchItemId(null);
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
target.setAttribute('data-settings-search-highlight', 'true');
window.setTimeout(() => {
target.removeAttribute('data-settings-search-highlight');
}, 1600);
});
return () => {
cancelled = true;
window.cancelAnimationFrame(frame);
};
}, [pendingSearchItemId, settingsSlug]);
const renderUnavailable = React.useCallback(() => {
return (
<div className="flex h-full items-center justify-center px-6">
@@ -705,12 +926,83 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
}, []);
const renderSettingsNav = () => {
const hasSearchQuery = settingsSearchQuery.trim().length > 0;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-2 pt-3">
<div className="flex h-10 items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-muted-foreground focus-within:ring-2 focus-within:ring-primary/40 sm:h-8">
<Icon name="search" className="h-4 w-4 shrink-0" />
<input
value={settingsSearchQuery}
onChange={(event) => setSettingsSearchQuery(event.target.value)}
onKeyDown={handleSettingsSearchKeyDown}
placeholder={t('settings.view.search.placeholder')}
aria-label={t('settings.view.search.aria')}
className="typography-ui min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground/70"
/>
{hasSearchQuery && (
<button
type="button"
onClick={() => setSettingsSearchQuery('')}
aria-label={t('settings.view.search.clear')}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-interactive-hover hover:text-foreground sm:h-5 sm:w-5"
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Scrollable nav items */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<div className="flex flex-col gap-0.5 pt-4 pb-2 px-2">
{sortedFilteredPages.map((page) => {
{hasSearchQuery ? (
settingsSearchResults.length > 0 ? (() => {
let resultIndex = 0;
return groupedSettingsSearchResults.map((group) => (
<div key={group.page} className="space-y-0.5">
<div className="px-2 pb-0.5 pt-2 typography-micro font-medium uppercase tracking-wide text-muted-foreground/70">
{group.pageTitle}
</div>
{group.results.map((result) => {
const currentIndex = resultIndex;
resultIndex += 1;
const active = currentIndex === activeSearchResultIndex;
const hasDescription = Boolean(result.description);
return (
<button
key={result.id}
type="button"
ref={(element) => {
searchResultRefs.current[currentIndex] = element;
}}
onMouseMove={() => {
keyboardSearchNavigationRef.current = false;
setActiveSearchResultIndex(currentIndex);
}}
onClick={() => openSearchResult(result)}
className={cn(
'flex w-full flex-col rounded-md px-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
hasDescription ? 'min-h-11 py-1.5' : 'py-2',
active ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<span className="typography-ui-label text-foreground truncate">{result.title}</span>
{hasDescription && (
<span className="typography-micro text-muted-foreground/70 line-clamp-2">{result.description}</span>
)}
</button>
);
})}
</div>
));
})() : (
<div className="px-2 py-6 text-center typography-ui text-muted-foreground">
{t('settings.view.search.noResults')}
</div>
)
) : sortedFilteredPages.map((page) => {
const selected = settingsSlug === page.slug;
const iconName = getSettingsNavIcon(page.slug);
if (!iconName && page.slug !== 'mcp') return null;