feat(settings): global settings search bar (#3574)

This commit is contained in:
Cyrus Gray
2026-06-10 17:52:01 +05:30
committed by GitHub
parent 23b3830623
commit 94c5d22931
21 changed files with 1240 additions and 45 deletions
+54 -42
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { type ReactNode, useState } from 'react';
import { useDeveloperMode } from '../../hooks/useDeveloperMode';
import { useT } from '../../lib/i18n/I18nContext';
@@ -6,6 +6,7 @@ import LanguageSelect from '../LanguageSelect';
import SettingsHeader from './components/SettingsHeader';
import SettingsMenuItem from './components/SettingsMenuItem';
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
import SettingsSearchBar from './search/SettingsSearchBar';
// ---------------------------------------------------------------------------
// Types
@@ -180,6 +181,11 @@ const SettingsHome = () => {
const { t } = useT();
const developerMode = useDeveloperMode();
// Global settings search. While a query is active the normal menu is hidden
// and the search bar renders its own ranked result list instead.
const [searchQuery, setSearchQuery] = useState('');
const isSearching = searchQuery.trim().length > 0;
// --- 👤 Account group ---
const accountGroup: SettingsGroup = {
id: 'account',
@@ -335,49 +341,55 @@ const SettingsHome = () => {
<SettingsHeader />
</div>
<div className="px-4 pb-5">
{/* Merged layman card — no Account/Assistant/… subheadings. */}
<div
data-testid="settings-group-main"
className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{laymanItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === laymanItems.length - 1}
rightElement={item.rightElement}
/>
<SettingsSearchBar value={searchQuery} onValueChange={setSearchQuery} />
{/* While searching, the search bar renders its own results and the normal
settings menu is hidden to avoid a confusing double list. */}
{isSearching ? null : (
<div className="px-4 pt-3 pb-5">
{/* Merged layman card — no Account/Assistant/… subheadings. */}
<div
data-testid="settings-group-main"
className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{laymanItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === laymanItems.length - 1}
rightElement={item.rightElement}
/>
))}
</div>
{trailingGroups.map(group => (
<div key={group.id} data-testid={`settings-group-${group.id}`}>
<GroupHeader label={group.label} />
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{group.items.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === group.items.length - 1}
rightElement={item.rightElement}
/>
))}
</div>
</div>
))}
</div>
{trailingGroups.map(group => (
<div key={group.id} data-testid={`settings-group-${group.id}`}>
<GroupHeader label={group.label} />
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{group.items.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === group.items.length - 1}
rightElement={item.rightElement}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,101 @@
/**
* Tests for SettingsSearchBar — the global Settings search input + result list.
*
* The translator is mocked to identity so we can assert against the stable i18n
* keys, and the registry's own keys (e.g. 'settings.appearance.title') contain
* the words we search for. Navigation and developer-mode are mocked so the bar
* is exercised in isolation.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import SettingsSearchBar from './SettingsSearchBar';
const hoisted = vi.hoisted(() => ({ devMode: false, navigate: vi.fn() }));
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../../hooks/useDeveloperMode', () => ({ useDeveloperMode: () => hoisted.devMode }));
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateToSettings: hoisted.navigate }),
}));
// Controlled wrapper so typing flows through value/onValueChange like in
// SettingsHome.
const Harness = () => {
const [value, setValue] = useState('');
return <SettingsSearchBar value={value} onValueChange={setValue} />;
};
const type = (text: string) =>
fireEvent.change(screen.getByTestId('settings-search-input'), { target: { value: text } });
beforeEach(() => {
hoisted.devMode = false;
hoisted.navigate.mockReset();
});
describe('SettingsSearchBar', () => {
test('shows no results list until a query is entered', () => {
render(<Harness />);
expect(screen.queryByTestId('settings-search-results')).toBeNull();
expect(screen.queryByTestId('settings-search-empty')).toBeNull();
});
test('filters entries by query and navigates on click', () => {
render(<Harness />);
type('appearance');
const result = screen.getByTestId('settings-search-result-appearance');
expect(result).toBeTruthy();
fireEvent.click(result);
expect(hoisted.navigate).toHaveBeenCalledWith('appearance');
});
test('renders an empty state when nothing matches', () => {
render(<Harness />);
type('zzzznomatchqq');
expect(screen.getByTestId('settings-search-empty')).toBeTruthy();
expect(screen.queryByTestId('settings-search-results')).toBeNull();
});
test('hides developer-only destinations unless developer mode is on', () => {
render(<Harness />);
// 'cron-jobs' is a devOnly entry.
type('cron');
expect(screen.queryByTestId('settings-search-result-cron-jobs')).toBeNull();
});
test('surfaces developer-only destinations when developer mode is on', () => {
hoisted.devMode = true;
render(<Harness />);
type('cron');
expect(screen.getByTestId('settings-search-result-cron-jobs')).toBeTruthy();
});
test('Enter activates the highlighted result', () => {
render(<Harness />);
const input = screen.getByTestId('settings-search-input');
type('appearance');
fireEvent.keyDown(input, { key: 'Enter' });
expect(hoisted.navigate).toHaveBeenCalledWith('appearance');
});
test('Escape clears the query', () => {
render(<Harness />);
const input = screen.getByTestId('settings-search-input') as HTMLInputElement;
type('appearance');
expect(input.value).toBe('appearance');
fireEvent.keyDown(input, { key: 'Escape' });
expect(input.value).toBe('');
});
test('clear button empties the query', () => {
render(<Harness />);
const input = screen.getByTestId('settings-search-input') as HTMLInputElement;
type('appearance');
fireEvent.click(screen.getByTestId('settings-search-clear'));
expect(input.value).toBe('');
});
});
@@ -0,0 +1,199 @@
// ---------------------------------------------------------------------------
// SettingsSearchBar
//
// Global search for the Settings tree (Phase 1 — pages / sections / dev tools).
// Renders a search input plus, while a query is active, a ranked result list.
// Selecting a result navigates to that settings route.
//
// Implemented as an ARIA combobox: the input owns `aria-activedescendant`, the
// result list is a `listbox`, and Arrow/Enter/Escape are handled on the input
// so the user never has to move focus off the field.
//
// The component is controlled (`value` / `onValueChange`) so the parent
// (SettingsHome) can hide the normal menu while a search is in progress.
// ---------------------------------------------------------------------------
import { useCallback, useId, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { useSettingsSearch } from './useSettingsSearch';
interface SettingsSearchBarProps {
value: string;
onValueChange: (next: string) => void;
}
const SearchIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z"
/>
</svg>
);
const ClearIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
const SettingsSearchBar = ({ value, onValueChange }: SettingsSearchBarProps) => {
const { t } = useT();
const { navigateToSettings } = useSettingsNavigation();
const results = useSettingsSearch(value);
const isSearching = value.trim().length > 0;
const [activeIndex, setActiveIndex] = useState(0);
const [lastValue, setLastValue] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
const listboxId = useId();
const optionId = (index: number) => `${listboxId}-opt-${index}`;
// Reset the highlighted row whenever the query changes so the active index
// never points past the end of the (possibly shorter) result set. Adjusting
// state during render is React's recommended alternative to a reset effect.
if (value !== lastValue) {
setLastValue(value);
setActiveIndex(0);
}
const goToResult = useCallback(
(index: number) => {
const result = results[index];
if (!result) return;
// [settings-search] navigate to the chosen destination and clear the query
// so returning to the menu shows the full list again.
onValueChange('');
navigateToSettings(result.entry.route);
},
[results, navigateToSettings, onValueChange]
);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!isSearching) {
if (event.key === 'Escape' && value) {
event.preventDefault();
onValueChange('');
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setActiveIndex(prev => (results.length ? (prev + 1) % results.length : 0));
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0));
break;
case 'Enter':
if (results.length) {
event.preventDefault();
goToResult(activeIndex);
}
break;
case 'Escape':
event.preventDefault();
onValueChange('');
break;
default:
break;
}
};
return (
<div className="px-4 pt-4" data-testid="settings-search">
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-stone-400 dark:text-neutral-500">
<SearchIcon />
</span>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={isSearching}
aria-controls={listboxId}
aria-activedescendant={isSearching && results.length ? optionId(activeIndex) : undefined}
aria-label={t('settings.settingsSearch.ariaLabel')}
autoComplete="off"
spellCheck={false}
value={value}
onChange={event => onValueChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('settings.settingsSearch.placeholder')}
data-testid="settings-search-input"
className="w-full rounded-2xl border border-stone-200 bg-white py-2.5 pl-10 pr-10 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 dark:focus:ring-primary-500/20"
/>
{value && (
<button
type="button"
onClick={() => {
onValueChange('');
inputRef.current?.focus();
}}
aria-label={t('settings.settingsSearch.clear')}
data-testid="settings-search-clear"
className="absolute inset-y-0 right-2 flex items-center px-1.5 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<ClearIcon />
</button>
)}
</div>
{isSearching && (
<div className="pt-3 pb-5">
{results.length === 0 ? (
<div
role="status"
data-testid="settings-search-empty"
className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-6 text-center text-sm text-stone-500 dark:border-neutral-800 dark:bg-neutral-900/40 dark:text-neutral-400">
{t('settings.settingsSearch.noResults').replace('{query}', value.trim())}
</div>
) : (
<ul
id={listboxId}
role="listbox"
aria-label={t('settings.settingsSearch.resultsLabel')}
data-testid="settings-search-results"
className="overflow-hidden rounded-3xl border border-stone-200 dark:border-neutral-800">
{results.map((result, index) => (
<li
key={result.entry.id}
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
data-testid={`settings-search-result-${result.entry.id}`}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => goToResult(index)}
className={`flex cursor-pointer items-center justify-between gap-3 border-b border-stone-200 px-4 py-3 last:border-b-0 dark:border-neutral-800 ${
index === activeIndex
? 'bg-primary-50 dark:bg-primary-500/10'
: 'bg-stone-50 dark:bg-neutral-900/40'
}`}>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-stone-900 dark:text-neutral-100">
{result.title}
</div>
{result.description && (
<div className="truncate text-xs text-stone-500 dark:text-neutral-400">
{result.description}
</div>
)}
</div>
<span className="shrink-0 rounded-full bg-stone-100 px-2 py-0.5 text-[11px] font-medium text-stone-500 dark:bg-neutral-800 dark:text-neutral-400">
{result.section}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
};
export default SettingsSearchBar;
@@ -0,0 +1,544 @@
// ---------------------------------------------------------------------------
// Settings search registry
//
// A single, flat, declarative manifest of every navigable settings destination.
// It is the source of truth for the global Settings search bar (Phase 1 —
// "shallow": pages / sections / dev tools, not the individual controls inside
// each panel).
//
// Each entry reuses the SAME i18n keys the existing menus render, so search
// results stay translated and in sync with the menu labels without inventing
// new copy. `keywords` are English-only match aids (synonyms a user might type)
// layered ON TOP of the already-localised title/description — they widen recall
// for English without affecting localised matching.
//
// `devOnly` entries are only surfaced when developer mode is on (they live
// under Settings → Developer & Diagnostics). Routes are resolved via
// `navigateToSettings(route)` — they map 1:1 to the <Route> table in
// `app/src/pages/Settings.tsx`.
//
// This registry is intentionally ADDITIVE: it mirrors the inline menu arrays
// rather than replacing them, so existing navigation cannot regress. A future
// follow-up can consolidate the menus to render from this registry.
// ---------------------------------------------------------------------------
export interface SettingsSearchEntry {
/** Stable unique id — used as the React key and test id. */
id: string;
/** i18n key for the result title (reused from the existing menu item). */
titleKey: string;
/** i18n key for the result description (optional). */
descriptionKey?: string;
/** Settings route passed to `navigateToSettings(route)`. */
route: string;
/** i18n key for the section badge shown next to the result. */
sectionKey: string;
/** Extra English match terms (synonyms). Not shown in the UI. */
keywords?: string[];
/** When true, only surfaced if developer mode is enabled. */
devOnly?: boolean;
}
// Section badge i18n keys (reused from the existing section headers).
const SECTION = {
account: 'settings.groups.account',
assistant: 'settings.groups.assistant',
privacy: 'settings.privacySecurity.privacy',
notifications: 'settings.groups.notifications',
about: 'settings.about',
features: 'pages.settings.featuresSection.title',
ai: 'pages.settings.aiSection.title',
agents: 'settings.agentsSection.title',
composio: 'pages.settings.composioSection.title',
crypto: 'settings.cryptoSection.title',
settings: 'nav.settings',
developer: 'settings.developerDiagnostics',
} as const;
/**
* Every searchable settings destination. Deduped by route — where a route is
* reachable from more than one menu, the most user-facing entry is kept and the
* developer-menu duplicate is dropped.
*/
export const SETTINGS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
// --- Account ---
{
id: 'account',
titleKey: 'pages.settings.accountSection.title',
descriptionKey: 'pages.settings.accountSection.description',
route: 'account',
sectionKey: SECTION.account,
keywords: ['profile', 'sign out', 'logout'],
},
{
id: 'appearance',
titleKey: 'settings.appearance.title',
descriptionKey: 'settings.appearance.menuDesc',
route: 'appearance',
sectionKey: SECTION.account,
keywords: ['theme', 'dark', 'light', 'mode', 'color', 'colour'],
},
{
id: 'devices',
titleKey: 'settings.account.devices',
descriptionKey: 'settings.account.devicesDesc',
route: 'devices',
sectionKey: SECTION.account,
keywords: ['mobile', 'phone', 'ios', 'android', 'pair'],
},
{
id: 'data-sync',
titleKey: 'settings.dataSync.title',
descriptionKey: 'settings.dataSync.menuDesc',
route: 'memory-sync',
sectionKey: SECTION.account,
keywords: ['sync', 'backup', 'data', 'memory'],
},
{
id: 'team',
titleKey: 'pages.settings.account.team',
descriptionKey: 'pages.settings.account.teamDesc',
route: 'team',
sectionKey: SECTION.account,
keywords: ['members', 'invites', 'organization', 'organisation', 'workspace'],
},
{
id: 'security',
titleKey: 'pages.settings.account.security',
descriptionKey: 'pages.settings.account.securityDesc',
route: 'security',
sectionKey: SECTION.account,
keywords: ['keychain', 'secret', 'password', 'encryption', 'credentials'],
},
{
id: 'migration',
titleKey: 'pages.settings.account.migration',
descriptionKey: 'pages.settings.account.migrationDesc',
route: 'migration',
sectionKey: SECTION.account,
keywords: ['import', 'export', 'transfer', 'data'],
},
// --- Assistant ---
{
id: 'persona',
titleKey: 'settings.assistant.personality',
descriptionKey: 'settings.assistant.personalityDesc',
route: 'persona',
sectionKey: SECTION.assistant,
keywords: ['personality', 'tone', 'character', 'persona'],
},
{
id: 'mascot',
titleKey: 'settings.assistant.faceMascot',
descriptionKey: 'settings.assistant.faceMascotDesc',
route: 'mascot',
sectionKey: SECTION.assistant,
keywords: ['face', 'avatar', 'tiny', 'character'],
},
// --- Privacy ---
{
id: 'privacy',
titleKey: 'settings.privacySecurity.privacy',
descriptionKey: 'settings.privacySecurity.privacyDesc',
route: 'privacy',
sectionKey: SECTION.privacy,
keywords: ['telemetry', 'tracking', 'analytics', 'data'],
},
// --- Notifications ---
{
id: 'notifications-hub',
titleKey: 'settings.notifications.menuTitle',
descriptionKey: 'settings.notifications.menuDesc',
route: 'notifications-hub',
sectionKey: SECTION.notifications,
keywords: ['alerts', 'push', 'routing'],
},
{
id: 'notification-settings',
titleKey: 'pages.settings.features.notifications',
descriptionKey: 'pages.settings.features.notificationsDesc',
route: 'notifications',
sectionKey: SECTION.notifications,
keywords: ['alerts', 'push', 'preferences', 'routing'],
},
// --- Features ---
{
id: 'features',
titleKey: 'pages.settings.featuresSection.title',
descriptionKey: 'pages.settings.featuresSection.description',
route: 'features',
sectionKey: SECTION.settings,
},
{
id: 'screen-intelligence',
titleKey: 'pages.settings.features.screenAwareness',
descriptionKey: 'pages.settings.features.screenAwarenessDesc',
route: 'screen-intelligence',
sectionKey: SECTION.features,
keywords: ['screen', 'awareness', 'vision', 'capture'],
},
{
id: 'tools',
titleKey: 'pages.settings.features.tools',
descriptionKey: 'pages.settings.features.toolsDesc',
route: 'tools',
sectionKey: SECTION.features,
keywords: ['tools', 'capabilities', 'functions'],
},
{
id: 'companion',
titleKey: 'pages.settings.features.desktopCompanion',
descriptionKey: 'pages.settings.features.desktopCompanionDesc',
route: 'companion',
sectionKey: SECTION.features,
keywords: ['desktop', 'overlay', 'companion'],
},
// --- AI ---
{
id: 'ai',
titleKey: 'pages.settings.aiSection.title',
descriptionKey: 'pages.settings.aiSection.description',
route: 'ai',
sectionKey: SECTION.settings,
keywords: ['ai', 'models', 'inference'],
},
{
id: 'llm',
titleKey: 'pages.settings.ai.llm',
descriptionKey: 'pages.settings.ai.llmDesc',
route: 'llm',
sectionKey: SECTION.ai,
keywords: ['model', 'anthropic', 'openai', 'claude', 'provider', 'api key'],
},
{
id: 'embeddings',
titleKey: 'pages.settings.ai.embeddings',
descriptionKey: 'pages.settings.ai.embeddingsDesc',
route: 'embeddings',
sectionKey: SECTION.ai,
keywords: ['vector', 'embedding', 'search'],
},
{
id: 'voice',
titleKey: 'pages.settings.ai.voice',
descriptionKey: 'pages.settings.ai.voiceDesc',
route: 'voice',
sectionKey: SECTION.ai,
keywords: ['tts', 'stt', 'speech', 'dictation', 'audio'],
},
{
id: 'heartbeat',
titleKey: 'settings.heartbeat.title',
descriptionKey: 'settings.heartbeat.desc',
route: 'heartbeat',
sectionKey: SECTION.ai,
},
{
id: 'ledger-usage',
titleKey: 'settings.ledgerUsage.title',
descriptionKey: 'settings.ledgerUsage.desc',
route: 'ledger-usage',
sectionKey: SECTION.ai,
keywords: ['usage', 'tokens', 'ledger', 'cost'],
},
{
id: 'cost-dashboard',
titleKey: 'settings.costDashboard.title',
descriptionKey: 'settings.costDashboard.desc',
route: 'cost-dashboard',
sectionKey: SECTION.ai,
keywords: ['cost', 'spend', 'usage', 'billing'],
},
// --- Agents ---
{
id: 'agents-section',
titleKey: 'settings.agentsSection.title',
descriptionKey: 'settings.agentsSection.description',
route: 'agents-settings',
sectionKey: SECTION.settings,
},
{
id: 'agents',
titleKey: 'settings.agents.title',
descriptionKey: 'settings.agents.subtitle',
route: 'agents',
sectionKey: SECTION.agents,
keywords: ['agent', 'profiles'],
},
{
id: 'autonomy',
titleKey: 'settings.developerMenu.autonomy.title',
descriptionKey: 'settings.developerMenu.autonomy.desc',
route: 'autonomy',
sectionKey: SECTION.agents,
keywords: ['autonomy', 'autonomous'],
},
{
id: 'agent-access',
titleKey: 'settings.agentAccess.title',
descriptionKey: 'settings.agentAccess.menuDesc',
route: 'agent-access',
sectionKey: SECTION.agents,
keywords: ['access', 'permissions', 'tier', 'security policy'],
},
{
id: 'activity-level',
titleKey: 'activityLevel.title',
descriptionKey: 'activityLevel.description',
route: 'activity-level',
sectionKey: SECTION.agents,
keywords: ['background', 'activity', 'subconscious'],
},
{
id: 'sandbox-settings',
titleKey: 'settings.sandbox.title',
descriptionKey: 'settings.sandbox.menuDesc',
route: 'sandbox-settings',
sectionKey: SECTION.agents,
keywords: ['sandbox', 'jail', 'isolation', 'docker'],
},
// --- Composio / Integrations ---
{
id: 'composio-section',
titleKey: 'pages.settings.composioSection.title',
descriptionKey: 'pages.settings.composioSection.description',
route: 'composio',
sectionKey: SECTION.settings,
},
{
id: 'task-sources',
titleKey: 'settings.taskSources.title',
descriptionKey: 'settings.taskSources.subtitle',
route: 'task-sources',
sectionKey: SECTION.composio,
keywords: ['tasks', 'sources', 'inbox'],
},
{
id: 'composio-routing',
titleKey: 'settings.developerMenu.composioRouting.title',
descriptionKey: 'settings.developerMenu.composioRouting.desc',
route: 'composio-routing',
sectionKey: SECTION.composio,
keywords: ['composio', 'routing', 'integrations'],
},
{
id: 'webhooks-triggers',
titleKey: 'settings.developerMenu.composeioTriggers.title',
descriptionKey: 'settings.developerMenu.composeioTriggers.desc',
route: 'webhooks-triggers',
sectionKey: SECTION.composio,
keywords: ['webhooks', 'triggers', 'composio'],
},
// --- Crypto / Wallet ---
{
id: 'crypto-section',
titleKey: 'settings.cryptoSection.title',
descriptionKey: 'settings.cryptoSection.description',
route: 'crypto',
sectionKey: SECTION.settings,
keywords: ['crypto', 'wallet'],
},
{
id: 'recovery-phrase',
titleKey: 'pages.settings.account.recoveryPhrase',
descriptionKey: 'pages.settings.account.recoveryPhraseDesc',
route: 'recovery-phrase',
sectionKey: SECTION.crypto,
keywords: ['mnemonic', 'seed', 'backup', 'recovery', 'wallet'],
},
{
id: 'wallet-balances',
titleKey: 'pages.settings.account.walletBalances',
descriptionKey: 'pages.settings.account.walletBalancesDesc',
route: 'wallet-balances',
sectionKey: SECTION.crypto,
keywords: ['wallet', 'balance', 'tokens', 'crypto'],
},
// --- About ---
{
id: 'about',
titleKey: 'settings.about',
descriptionKey: 'settings.aboutDesc',
route: 'about',
sectionKey: SECTION.about,
keywords: ['version', 'build', 'update', 'developer mode'],
},
// --- Developer & Diagnostics (dev mode only) ---
{
id: 'developer-options',
titleKey: 'settings.developerDiagnostics',
descriptionKey: 'settings.developerDiagnosticsDesc',
route: 'developer-options',
sectionKey: SECTION.developer,
keywords: ['developer', 'diagnostics', 'debug'],
devOnly: true,
},
{
id: 'intelligence',
titleKey: 'settings.developerMenu.intelligence.title',
descriptionKey: 'settings.developerMenu.intelligence.desc',
route: 'intelligence',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'memory-data',
titleKey: 'devOptions.memoryInspection',
descriptionKey: 'devOptions.memoryInspectionDesc',
route: 'memory-data',
sectionKey: SECTION.developer,
keywords: ['memory', 'inspect'],
devOnly: true,
},
{
id: 'memory-debug',
titleKey: 'devOptions.debugPanels',
descriptionKey: 'devOptions.debugPanelsDesc',
route: 'memory-debug',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'analysis-views',
titleKey: 'settings.analysisViews.title',
descriptionKey: 'settings.analysisViews.menuDesc',
route: 'analysis-views',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'tool-policy-diagnostics',
titleKey: 'devOptions.diagnostics',
descriptionKey: 'devOptions.toolPolicyDiagnosticsDesc',
route: 'tool-policy-diagnostics',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'approval-history',
titleKey: 'settings.approvalHistory.title',
descriptionKey: 'settings.approvalHistory.subtitle',
route: 'approval-history',
sectionKey: SECTION.developer,
keywords: ['approval', 'history'],
devOnly: true,
},
{
id: 'permissions',
titleKey: 'settings.assistant.permissions',
descriptionKey: 'settings.assistant.permissionsDesc',
route: 'permissions',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'model-health',
titleKey: 'settings.modelHealth.title',
descriptionKey: 'settings.modelHealth.desc',
route: 'model-health',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'search-engine',
titleKey: 'settings.search.title',
descriptionKey: 'settings.search.menuDesc',
route: 'search',
sectionKey: SECTION.developer,
keywords: ['search', 'web', 'brave', 'parallel'],
devOnly: true,
},
{
id: 'agent-chat',
titleKey: 'settings.developerMenu.agentChat.title',
descriptionKey: 'settings.developerMenu.agentChat.desc',
route: 'agent-chat',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'local-model-debug',
titleKey: 'settings.developerMenu.localModelDebug.title',
descriptionKey: 'settings.developerMenu.localModelDebug.desc',
route: 'local-model-debug',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'cron-jobs',
titleKey: 'settings.developerMenu.cronJobs.title',
descriptionKey: 'settings.developerMenu.cronJobs.desc',
route: 'cron-jobs',
sectionKey: SECTION.developer,
keywords: ['cron', 'schedule', 'jobs'],
devOnly: true,
},
{
id: 'webhooks-debug',
titleKey: 'settings.developerMenu.webhooks.title',
descriptionKey: 'settings.developerMenu.webhooks.desc',
route: 'webhooks-debug',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'mcp-server',
titleKey: 'settings.developerMenu.mcpServer.title',
descriptionKey: 'settings.developerMenu.mcpServer.desc',
route: 'mcp-server',
sectionKey: SECTION.developer,
keywords: ['mcp', 'server'],
devOnly: true,
},
{
id: 'dev-workflow',
titleKey: 'settings.developerMenu.devWorkflow.title',
descriptionKey: 'settings.developerMenu.devWorkflow.desc',
route: 'dev-workflow',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'screen-awareness-debug',
titleKey: 'settings.developerMenu.screenAwareness.title',
descriptionKey: 'settings.developerMenu.screenAwareness.desc',
route: 'screen-awareness-debug',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'autocomplete',
titleKey: 'settings.developerMenu.autocomplete.title',
descriptionKey: 'settings.developerMenu.autocomplete.desc',
route: 'autocomplete',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'voice-debug',
titleKey: 'settings.developerMenu.voiceDebug.title',
descriptionKey: 'settings.developerMenu.voiceDebug.desc',
route: 'voice-debug',
sectionKey: SECTION.developer,
devOnly: true,
},
{
id: 'event-log',
titleKey: 'settings.developerMenu.eventLog.title',
descriptionKey: 'settings.developerMenu.eventLog.desc',
route: 'event-log',
sectionKey: SECTION.developer,
keywords: ['events', 'log'],
devOnly: true,
},
];
@@ -0,0 +1,95 @@
/**
* Unit tests for the pure `searchSettings` matcher that powers the Settings
* global search bar. Uses a small synthetic registry + fake translator so the
* algorithm is tested in isolation from the real (churning) entry list.
*/
import { describe, expect, test } from 'vitest';
import type { SettingsSearchEntry } from './settingsSearchRegistry';
import { searchSettings } from './useSettingsSearch';
const LABELS: Record<string, string> = {
'voice.t': 'Voice',
'voice.d': 'Text to speech and dictation',
'vd.t': 'Voice Debug',
'priv.t': 'Privacy',
'priv.d': 'Telemetry and tracking controls',
'dataPriv.t': 'Data Privacy',
'cafe.t': 'Café',
'sec.ai': 'AI',
'sec.dev': 'Developer',
'sec.acct': 'Account',
};
const t = (key: string): string => LABELS[key] ?? key;
const ENTRIES: SettingsSearchEntry[] = [
{
id: 'voice',
titleKey: 'voice.t',
descriptionKey: 'voice.d',
route: 'voice',
sectionKey: 'sec.ai',
keywords: ['speech', 'tts'],
},
{
id: 'voice-debug',
titleKey: 'vd.t',
route: 'voice-debug',
sectionKey: 'sec.dev',
devOnly: true,
},
{
id: 'privacy',
titleKey: 'priv.t',
descriptionKey: 'priv.d',
route: 'privacy',
sectionKey: 'sec.acct',
keywords: ['telemetry'],
},
{ id: 'data-privacy', titleKey: 'dataPriv.t', route: 'data-privacy', sectionKey: 'sec.acct' },
{ id: 'cafe', titleKey: 'cafe.t', route: 'cafe', sectionKey: 'sec.acct' },
];
const ids = (query: string, includeDevOnly = false) =>
searchSettings(ENTRIES, query, t, includeDevOnly).map(r => r.entry.id);
describe('searchSettings', () => {
test('empty / whitespace query returns no results', () => {
expect(searchSettings(ENTRIES, '', t, true)).toEqual([]);
expect(searchSettings(ENTRIES, ' ', t, true)).toEqual([]);
});
test('matches by title', () => {
expect(ids('voice', true)).toContain('voice');
});
test('hides devOnly entries unless developer mode is enabled', () => {
expect(ids('voice', false)).toEqual(['voice']);
expect(ids('voice', true)).toEqual(['voice', 'voice-debug']);
});
test('matches by keyword synonym not present in the title', () => {
expect(ids('tts')).toEqual(['voice']);
});
test('matches by description-only text', () => {
// "controls" appears only in the privacy description, not its title/keywords.
expect(ids('controls')).toEqual(['privacy']);
});
test('AND semantics — every token must match', () => {
// "voice" alone hits both, but only Voice Debug contains "debug".
expect(ids('voice debug', true)).toEqual(['voice-debug']);
});
test('is diacritic-insensitive', () => {
expect(ids('cafe')).toEqual(['cafe']);
expect(ids('café')).toEqual(['cafe']);
});
test('ranks a title-prefix match above a title-substring match', () => {
// "Privacy" (prefix) should outrank "Data Privacy" (substring).
expect(ids('priv')).toEqual(['privacy', 'data-privacy']);
});
});
@@ -0,0 +1,153 @@
// ---------------------------------------------------------------------------
// useSettingsSearch
//
// Filters the settings search registry against a free-text query. The matching
// core is a pure function (`searchSettings`) so it can be unit-tested without
// React; the hook wires in the live i18n translator and developer-mode flag.
//
// Matching rules:
// - The query is normalised (lowercased + diacritics stripped) and split into
// whitespace tokens. EVERY token must appear somewhere in the entry's
// haystack (title + description + section + keywords) — i.e. AND semantics,
// so "voice debug" narrows rather than widens.
// - Results are ranked: a title prefix hit beats a title substring hit, which
// beats a keyword/section hit, which beats a description-only hit. Ties fall
// back to the registry's declaration order (stable).
// ---------------------------------------------------------------------------
import { useMemo } from 'react';
import { useDeveloperMode } from '../../../hooks/useDeveloperMode';
import { useT } from '../../../lib/i18n/I18nContext';
import { SETTINGS_SEARCH_ENTRIES, type SettingsSearchEntry } from './settingsSearchRegistry';
export interface SettingsSearchResult {
entry: SettingsSearchEntry;
/** Localised title for rendering. */
title: string;
/** Localised description for rendering (may be empty). */
description: string;
/** Localised section badge label. */
section: string;
}
/** Lower-case and strip diacritics so "Tóol" matches "tool". */
export const normalize = (value: string): string =>
value
.normalize('NFD')
// Strip combining diacritical marks (U+0300U+036F) so "Tóol" matches "tool".
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
type Translate = (key: string) => string;
interface ScoredResult extends SettingsSearchResult {
score: number;
order: number;
}
// Higher = better match. Kept as named constants for readability.
const SCORE_TITLE_PREFIX = 4;
const SCORE_TITLE_INCLUDES = 3;
const SCORE_KEYWORD_OR_SECTION = 2;
const SCORE_DESCRIPTION = 1;
const SCORE_NONE = 0;
/**
* Score a single entry against the already-normalised query tokens. Returns
* `SCORE_NONE` (0) when any token is unmatched — such entries are filtered out.
*/
const scoreEntry = (
title: string,
description: string,
section: string,
keywords: string[],
tokens: string[]
): number => {
const nTitle = normalize(title);
const nDescription = normalize(description);
const nSection = normalize(section);
const nKeywords = keywords.map(normalize);
let best = SCORE_NONE;
for (const token of tokens) {
let tokenScore = SCORE_NONE;
if (nTitle.startsWith(token)) {
tokenScore = SCORE_TITLE_PREFIX;
} else if (nTitle.includes(token)) {
tokenScore = SCORE_TITLE_INCLUDES;
} else if (nKeywords.some(k => k.includes(token)) || nSection.includes(token)) {
tokenScore = SCORE_KEYWORD_OR_SECTION;
} else if (nDescription.includes(token)) {
tokenScore = SCORE_DESCRIPTION;
}
// AND semantics: a single unmatched token disqualifies the entry.
if (tokenScore === SCORE_NONE) return SCORE_NONE;
best = Math.max(best, tokenScore);
}
return best;
};
/**
* Pure search over the registry. Exposed for unit testing.
*
* @param entries registry entries to consider
* @param query raw user query
* @param t i18n translator
* @param includeDevOnly whether developer-mode-only entries are eligible
*/
export const searchSettings = (
entries: SettingsSearchEntry[],
query: string,
t: Translate,
includeDevOnly: boolean
): SettingsSearchResult[] => {
const normalizedQuery = normalize(query);
if (!normalizedQuery) return [];
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return [];
const scored: ScoredResult[] = [];
entries.forEach((entry, order) => {
if (entry.devOnly && !includeDevOnly) return;
const title = t(entry.titleKey);
const description = entry.descriptionKey ? t(entry.descriptionKey) : '';
const section = t(entry.sectionKey);
const keywords = entry.keywords ?? [];
const score = scoreEntry(title, description, section, keywords, tokens);
if (score === SCORE_NONE) return;
scored.push({ entry, title, description, section, score, order });
});
scored.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.order - b.order));
return scored.map(({ entry, title, description, section }) => ({
entry,
title,
description,
section,
}));
};
/**
* React hook: returns the ranked, localised, dev-gated results for `query`.
* An empty/whitespace query yields an empty array (caller renders the normal
* menu instead).
*/
export const useSettingsSearch = (query: string): SettingsSearchResult[] => {
const { t } = useT();
const developerMode = useDeveloperMode();
return useMemo(
() => searchSettings(SETTINGS_SEARCH_ENTRIES, query, t, developerMode),
[query, t, developerMode]
);
};
+6
View File
@@ -992,6 +992,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'كل شيء',
'settings.search.accessBlockAllHint':
'وكل الوصول إلى شبكة الإنترنت مغلق - لا يمكن للمساعد فتح أو قراءة أي موقع على شبكة الإنترنت.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'بحث في الإعدادات…',
'settings.settingsSearch.ariaLabel': 'بحث في الإعدادات',
'settings.settingsSearch.clear': 'مسح البحث',
'settings.settingsSearch.resultsLabel': 'نتائج البحث',
'settings.settingsSearch.noResults': 'لا توجد إعدادات مطابقة لـ ”{query}“',
'settings.embeddings.title': 'التضمينات',
'settings.embeddings.description':
'اختر مزود التضمينات الذي يحول الذاكرة إلى متجهات للبحث الدلالي. تغيير المزود أو النموذج أو الأبعاد يبطل المتجهات المخزنة ويتطلب إعادة تعيين كاملة للذاكرة.',
+6
View File
@@ -1007,6 +1007,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'প্রতিরোধ করো',
'settings.search.accessBlockAllHint':
'সকল ওয়েব প্রবেশাধিকার ব্লক করা হয়েছে- সহকারী কোন ওয়েবসাইট খুলতে বা পড়তে পারে না।',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'সেটিংস খুঁজুন…',
'settings.settingsSearch.ariaLabel': 'সেটিংস খুঁজুন',
'settings.settingsSearch.clear': 'অনুসন্ধান সাফ করুন',
'settings.settingsSearch.resultsLabel': 'অনুসন্ধানের ফলাফল',
'settings.settingsSearch.noResults': '“{query}” এর জন্য কোনো সেটিং পাওয়া যায়নি',
'settings.embeddings.title': 'এমবেডিংস',
'settings.embeddings.description':
'কোন এমবেডিং প্রদানকারী মেমরিকে সিমান্টিক সার্চের জন্য ভেক্টরে রূপান্তর করে তা চয়ন করুন। প্রদানকারী, মডেল বা মাত্রা পরিবর্তন করলে সংরক্ষিত ভেক্টর অবৈধ হয়ে যায় এবং সম্পূর্ণ মেমরি রিসেট প্রয়োজন।',
+6
View File
@@ -1039,6 +1039,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Alle blockieren',
'settings.search.accessBlockAllHint':
'Der gesamte Webzugriff ist blockiert der Assistent kann keine Website öffnen oder lesen.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Einstellungen durchsuchen…',
'settings.settingsSearch.ariaLabel': 'Einstellungen durchsuchen',
'settings.settingsSearch.clear': 'Suche löschen',
'settings.settingsSearch.resultsLabel': 'Suchergebnisse',
'settings.settingsSearch.noResults': 'Keine Einstellungen für „{query}“ gefunden',
'settings.embeddings.title': 'Einbettungen',
'settings.embeddings.description':
'Wählen Sie den Embedding-Anbieter, der Erinnerungen in Vektoren für die semantische Suche umwandelt. Das Ändern des Anbieters, Modells oder der Dimensionen macht gespeicherte Vektoren ungültig und erfordert einen vollständigen Speicher-Reset.',
+6
View File
@@ -1359,6 +1359,12 @@ const en: TranslationMap = {
'settings.search.accessBlockAll': 'Block all',
'settings.search.accessBlockAllHint':
'All web access is blocked — the assistant cannot open or read any website.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Search settings…',
'settings.settingsSearch.ariaLabel': 'Search settings',
'settings.settingsSearch.clear': 'Clear search',
'settings.settingsSearch.resultsLabel': 'Search results',
'settings.settingsSearch.noResults': 'No settings found for “{query}”',
// ─── Embeddings settings ───────────────────────────────────
'settings.embeddings.title': 'Embeddings',
'settings.embeddings.description':
+6
View File
@@ -1038,6 +1038,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Bloquear todo',
'settings.search.accessBlockAllHint':
'Todo el acceso web está bloqueado: el asistente no puede abrir ni leer ningún sitio web.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Buscar ajustes…',
'settings.settingsSearch.ariaLabel': 'Buscar ajustes',
'settings.settingsSearch.clear': 'Borrar búsqueda',
'settings.settingsSearch.resultsLabel': 'Resultados de búsqueda',
'settings.settingsSearch.noResults': 'No se encontraron ajustes para «{query}»',
'settings.embeddings.title': 'Incrustaciones',
'settings.embeddings.description':
'Elige qué proveedor de embeddings convierte la memoria en vectores para la búsqueda semántica. Cambiar el proveedor, modelo o dimensiones invalida los vectores almacenados y requiere un reinicio completo de la memoria.',
+6
View File
@@ -1040,6 +1040,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Bloquer tout',
'settings.search.accessBlockAllHint':
"Tout accès au web est bloqué — l'assistant ne peut ouvrir ni lire aucun site web.",
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Rechercher dans les paramètres…',
'settings.settingsSearch.ariaLabel': 'Rechercher dans les paramètres',
'settings.settingsSearch.clear': 'Effacer la recherche',
'settings.settingsSearch.resultsLabel': 'Résultats de recherche',
'settings.settingsSearch.noResults': 'Aucun paramètre trouvé pour « {query} »',
'settings.embeddings.title': 'Encastrements',
'settings.embeddings.description':
"Choisissez le fournisseur d'embeddings qui convertit la mémoire en vecteurs pour la recherche sémantique. Changer le fournisseur, le modèle ou les dimensions invalide les vecteurs stockés et nécessite une réinitialisation complète de la mémoire.",
+6
View File
@@ -1007,6 +1007,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'सभी को ब्लॉक करें',
'settings.search.accessBlockAllHint':
'सभी वेब एक्सेस अवरुद्ध है - सहायक किसी भी वेबसाइट को खोल या पढ़ नहीं सकता है।',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'सेटिंग खोजें…',
'settings.settingsSearch.ariaLabel': 'सेटिंग खोजें',
'settings.settingsSearch.clear': 'खोज साफ़ करें',
'settings.settingsSearch.resultsLabel': 'खोज परिणाम',
'settings.settingsSearch.noResults': '“{query}” के लिए कोई सेटिंग नहीं मिली',
'settings.embeddings.title': 'एम्बेडिंग्स',
'settings.embeddings.description':
'चुनें कि कौन सा एम्बेडिंग प्रदाता मेमोरी को सिमेंटिक सर्च के लिए वेक्टर में बदलता है। प्रदाता, मॉडल या आयाम बदलने से संग्रहीत वेक्टर अमान्य हो जाते हैं और पूर्ण मेमरी रीसेट की आवश्यकता होती है।',
+6
View File
@@ -1016,6 +1016,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Blokir semua',
'settings.search.accessBlockAllHint':
'Semua akses web diblokir - asisten tidak dapat membuka atau membaca website apapun.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Cari pengaturan…',
'settings.settingsSearch.ariaLabel': 'Cari pengaturan',
'settings.settingsSearch.clear': 'Hapus pencarian',
'settings.settingsSearch.resultsLabel': 'Hasil pencarian',
'settings.settingsSearch.noResults': 'Tidak ada pengaturan yang cocok untuk “{query}”',
'settings.embeddings.title': 'Sematan',
'settings.embeddings.description':
'Pilih penyedia embedding yang mengubah memori menjadi vektor untuk pencarian semantik. Mengubah penyedia, model, atau dimensi membatalkan vektor yang tersimpan dan memerlukan reset memori penuh.',
+6
View File
@@ -1031,6 +1031,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Blocca tutto',
'settings.search.accessBlockAllHint':
"Tutti gli accessi web sono bloccati — l'assistente non può aprire o leggere alcun sito web.",
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Cerca nelle impostazioni…',
'settings.settingsSearch.ariaLabel': 'Cerca nelle impostazioni',
'settings.settingsSearch.clear': 'Cancella ricerca',
'settings.settingsSearch.resultsLabel': 'Risultati della ricerca',
'settings.settingsSearch.noResults': 'Nessuna impostazione trovata per «{query}»',
'settings.embeddings.title': 'Incorporamenti',
'settings.embeddings.description':
'Scegli il fornitore di embeddings che converte la memoria in vettori per la ricerca semantica. Cambiare fornitore, modello o dimensioni invalida i vettori memorizzati e richiede un reset completo della memoria.',
+6
View File
@@ -1006,6 +1006,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': '모두 차단',
'settings.search.accessBlockAllHint':
'모든 웹 접근이 차단됩니다. 어시스턴트는 어떤 웹사이트도 열거나 읽을 수 없습니다.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': '설정 검색…',
'settings.settingsSearch.ariaLabel': '설정 검색',
'settings.settingsSearch.clear': '검색 지우기',
'settings.settingsSearch.resultsLabel': '검색 결과',
'settings.settingsSearch.noResults': '“{query}”에 대한 설정을 찾을 수 없습니다',
'settings.embeddings.title': '임베딩',
'settings.embeddings.description':
'시맨틱 검색을 위해 메모리를 벡터로 변환할 임베딩 제공자를 선택하세요. 제공자, 모델 또는 차원을 변경하면 저장된 벡터가 무효화되며 전체 메모리 초기화가 필요합니다.',
+6
View File
@@ -1026,6 +1026,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Blokuj wszystko',
'settings.search.accessBlockAllHint':
'Cały dostęp do sieci jest zablokowany — asystent nie może otwierać ani czytać żadnej witryny.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Szukaj w ustawieniach…',
'settings.settingsSearch.ariaLabel': 'Szukaj w ustawieniach',
'settings.settingsSearch.clear': 'Wyczyść wyszukiwanie',
'settings.settingsSearch.resultsLabel': 'Wyniki wyszukiwania',
'settings.settingsSearch.noResults': 'Nie znaleziono ustawień dla „{query}”',
'settings.embeddings.title': 'Embeddings',
'settings.embeddings.description':
'Wybierz, który dostawca embeddings konwertuje pamięć na wektory do wyszukiwania semantycznego. Zmiana dostawcy, modelu lub wymiarów unieważnia zapisane wektory i wymaga pełnego resetu pamięci.',
+6
View File
@@ -1039,6 +1039,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Bloquear tudo',
'settings.search.accessBlockAllHint':
'Todo o acesso à web está bloqueado — o assistente não pode abrir ou ler nenhum site.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Pesquisar configurações…',
'settings.settingsSearch.ariaLabel': 'Pesquisar configurações',
'settings.settingsSearch.clear': 'Limpar pesquisa',
'settings.settingsSearch.resultsLabel': 'Resultados da pesquisa',
'settings.settingsSearch.noResults': 'Nenhuma configuração encontrada para “{query}”',
'settings.embeddings.title': 'Incorporações',
'settings.embeddings.description':
'Escolha qual provedor de embeddings converte memória em vetores para busca semântica. Alterar o provedor, modelo ou dimensões invalida vetores armazenados e requer uma redefinição completa da memória.',
+6
View File
@@ -1023,6 +1023,12 @@ const messages: TranslationMap = {
'settings.search.accessBlockAll': 'Блокировать все',
'settings.search.accessBlockAllHint':
'Весь веб-доступ заблокирован — помощник не может открыть или прочитать какой-либо веб-сайт.',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': 'Поиск в настройках…',
'settings.settingsSearch.ariaLabel': 'Поиск в настройках',
'settings.settingsSearch.clear': 'Очистить поиск',
'settings.settingsSearch.resultsLabel': 'Результаты поиска',
'settings.settingsSearch.noResults': 'Настройки по запросу «{query}» не найдены',
'settings.embeddings.title': 'Эмбеддинги',
'settings.embeddings.description':
'Выберите провайдера эмбеддингов, который преобразует память в векторы для семантического поиска. Изменение провайдера, модели или размерности делает сохранённые векторы недействительными и требует полного сброса памяти.',
+6
View File
@@ -956,6 +956,12 @@ const messages: TranslationMap = {
'settings.search.accessCustom': '自定义',
'settings.search.accessBlockAll': '全部阻止',
'settings.search.accessBlockAllHint': '所有网页访问都已阻止,助手无法打开或阅读任何网站。',
// ─── Settings global search bar ────────────────────────────
'settings.settingsSearch.placeholder': '搜索设置…',
'settings.settingsSearch.ariaLabel': '搜索设置',
'settings.settingsSearch.clear': '清除搜索',
'settings.settingsSearch.resultsLabel': '搜索结果',
'settings.settingsSearch.noResults': '未找到与“{query}”匹配的设置',
'settings.embeddings.title': '向量嵌入',
'settings.embeddings.description':
'选择将记忆转换为语义搜索向量的嵌入提供商。更改提供商、模型或维度会使已存储的向量无效,需要完全重置记忆。',
@@ -2,10 +2,17 @@ import { expect, test } from '@playwright/test';
import { bootAuthenticatedPage } from '../helpers/core-rpc';
// The command-palette input is cmdk's `Command.Input`, which renders with a
// `cmdk-input` attribute. We target that specifically rather than a generic
// `input[role="combobox"]` — other pages (e.g. Settings, which now has a global
// search bar) legitimately render their own combobox, so the broad selector
// would no longer uniquely identify the palette.
const PALETTE_INPUT = 'input[cmdk-input]';
async function openPalette(page: import('@playwright/test').Page) {
const shortcut = process.platform === 'darwin' ? 'Meta+K' : 'Control+K';
await page.keyboard.press(shortcut);
await expect(page.locator('input[role="combobox"]')).toBeVisible();
await expect(page.locator(PALETTE_INPUT)).toBeVisible();
}
test.describe('Command Palette', () => {
@@ -16,7 +23,7 @@ test.describe('Command Palette', () => {
test('opens via mod+K, navigates to settings, and closes', async ({ page }) => {
await openPalette(page);
const input = page.locator('input[role="combobox"]');
const input = page.locator(PALETTE_INPUT);
await input.fill('settings');
await page.keyboard.press('Enter');
@@ -36,6 +43,6 @@ test.describe('Command Palette', () => {
await expect(page.getByText('Open Settings')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('input[role="combobox"]')).toHaveCount(0);
await expect(page.locator(PALETTE_INPUT)).toHaveCount(0);
});
});