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:
committed by
GitHub
parent
eff6f46ad9
commit
079e3a9bd6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user