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
@@ -226,6 +226,58 @@ For dense icon/color pickers in settings:
- **Avoid show/hide button pairs** when a checkbox maps directly to the boolean.
- **Do not couple unrelated toggles** under one synthetic section header; keep hierarchy clear.
## Settings Search Integration
Every Settings UI addition must preserve item search. The registry is explicit: search does not scrape JSX or infer fields automatically.
### Required Files
- Add or update search items in `packages/ui/src/lib/settings/search.ts`.
- Add matching `data-settings-item="..."` anchors in the rendered Settings UI.
- Reuse existing localized labels/descriptions where possible; otherwise add keys to all `packages/ui/src/lib/i18n/messages/*.settings.ts` files.
- If adding a new top-level Settings page, add metadata in `packages/ui/src/lib/settings/metadata.ts` and at least one searchable item unless the page is purely navigational like `home`.
### What To Index
- Index stable user-facing controls, section headers, and static create/connect actions.
- Use item IDs that match the page and target, for example `appearance.language`, `agents.mode`, `remote-instances.client-auth`.
- Prefer the exact visible label key as `titleKey`; use a concise visible/help text key as `descriptionKey` only when it adds useful context.
- Add `keywords` for common synonyms, acronyms, and words users may type that are not in the label.
### What Not To Index
- Do not generate search items from dynamic entities: individual agents, commands, MCP servers, snippets, plugins, skills, providers, projects, catalog rows, remote hosts, or SSH instances.
- Do not index controls hidden behind selected-entity dialogs unless search selection prepares the required state before highlighting.
- Do not add a registry entry for a conditional control unless its `isAvailable` guard matches actual render visibility.
### Split Page Pattern
For split pages, search should target predictable static surfaces only.
- Index sidebar create/connect actions like `agents.create` or `providers.connect`.
- Index editor fields/sections that exist after the existing search preparation opens a draft.
- If a new create result needs draft setup, update `prepareSettingsSearchTarget` in `SettingsView.tsx` so the target is rendered before highlight runs.
### Availability Guards
- Match runtime/page availability exactly: VS Code, web, desktop, mobile, and local desktop origin when relevant.
- Page-level guards belong in `metadata.ts`; item-specific guards belong in `search.ts`.
- If a target renders only inside desktop shell UI, guard it with `ctx.isDesktop` or `ctx.isDesktopLocalOrigin` as appropriate.
### Highlight Target Rules
- Put `data-settings-item` on the smallest stable container that visually owns the setting.
- Avoid adding layout-only wrappers just for search anchors.
- Highlight styling is intentionally subtle and lives in `packages/ui/src/index.css` under `[data-settings-search-highlight="true"]`; keep it token-based and non-aggressive.
### Audit Checklist
- All registry IDs have matching anchors.
- All `titleKey` and `descriptionKey` values exist in every settings locale file.
- Every non-navigational `SettingsPageSlug` has item coverage.
- Search results respect platform/runtime/mobile visibility.
- Query-empty Settings navigation behavior is unchanged.
## Best Practices
- **Density**: Keep options compact; avoid oversized rows/chips in dense settings pages.
- **Consistency**: Reuse shared controls (`Checkbox`, `Radio`, `ButtonSmall size="xs"`) instead of inline icon logic.
@@ -236,3 +288,4 @@ For dense icon/color pickers in settings:
- **Helper blocks**: For small notes/errors under a section, use `mt-1 px-2` with `typography-meta text-muted-foreground/70` (and status token for errors).
- **Truncation**: Always consider long text. Use `min-w-0 flex-1 truncate` on text containers that sit next to buttons or icons to prevent layout breakage.
- **Theme Variables**: *Always* use CSS variables for colors (e.g., `var(--status-success)`) rather than hardcoded hex values or generic Tailwind colors when indicating semantic states.
- **Search compatibility**: When adding or moving a Settings control, update the search registry and anchor in the same change.
+150
View File
@@ -0,0 +1,150 @@
# Settings Item Search Plan
## Goal
Add Settings search that finds individual settings items, not only top-level pages.
The search should behave like this:
- User types a query in the Settings navigation area.
- Results show matching concrete settings, grouped or labeled by their Settings page.
- Each result shows the item title and, when available, its description.
- Clicking a result opens the correct Settings page.
- After the page renders, the matching row/card/section scrolls into view.
- The matched item gets a short visual highlight so the user can see where they landed.
## Current Architecture Notes
- Settings shell lives in `packages/ui/src/components/views/SettingsView.tsx`.
- Page metadata and slugs live in `packages/ui/src/lib/settings/metadata.ts`.
- Settings localization lives in `packages/ui/src/lib/i18n/messages/*.settings.ts`.
- Settings UI text is read through `useI18n()` and `t(key)`.
- Standard page wrappers live in `packages/ui/src/components/sections/shared/`.
## Proposed Architecture
Use an explicit searchable item registry instead of scraping React or the DOM.
Each searchable item should contain:
- `id`: stable item id, for example `appearance.language`.
- `page`: target `SettingsPageSlug`, for example `appearance`.
- `titleKey`: localized title key.
- `descriptionKey`: optional localized description key.
- `keywords`: optional non-visible search helpers.
- `isAvailable`: optional runtime/mobile guard for item-level availability.
Example:
```ts
{
id: 'appearance.language',
page: 'appearance',
titleKey: 'settings.appearance.language.label',
descriptionKey: 'settings.appearance.language.description',
keywords: ['locale', 'translation', 'ui language'],
}
```
## Implementation Steps
1. Create `packages/ui/src/lib/settings/search.ts`.
- Export `SETTINGS_SEARCH_ITEMS`.
- Export a helper to build localized search results from `t()`.
- Filter by page availability and `visiblePageSlugs`.
2. Add search UI to `SettingsView.tsx`.
- Search input should live in the left Settings navigation area on desktop.
- On mobile, keep behavior simple: show results in the nav stage and open target page on select.
- When query is empty, keep the existing navigation list.
- When query has text, replace the normal nav list with concrete search results.
3. Add click behavior for a search result.
- Set `settingsPage` to the result page.
- Store pending target item id in component state/ref.
- After content renders, find `[data-settings-item="<id>"]`.
- Scroll it into view.
- Add a temporary highlight using a data attribute or CSS class.
4. Add a tiny shared anchor/highlight pattern.
- Prefer adding `data-settings-item="..."` to existing row/card containers.
- Avoid wrappers that change layout.
- Keep highlight styling generic, for example a short ring/background transition.
5. Add initial searchable coverage.
- Start with high-value pages that already use many localized strings:
- `appearance`
- `chat`
- `sessions`
- `notifications`
- `git`
- `providers`
- `agents`
- Add more pages incrementally.
6. Validation.
- Run `bun run type-check`.
- Run `bun run lint`.
- Manually verify search result navigation for at least one single page and one split page.
## Current Implementation Status
Done:
- `packages/ui/src/lib/settings/search.ts` exists and exports the explicit registry plus localized result builder.
- Search input is wired into `SettingsView.tsx`.
- Results are grouped by page header.
- ArrowUp, ArrowDown, Enter, and Escape work while the search input is focused.
- Result click opens the target page and scrolls to `[data-settings-item="..."]`.
- Matching target gets a temporary highlight via `data-settings-search-highlight`.
- Search respects page availability, `visiblePageSlugs`, and item-level platform/runtime/mobile guards.
- Initial anchors exist for `appearance`, `chat`, `sessions`, `notifications`, `git`, and `usage`.
Covered pages/items so far:
- `appearance`: themes, localization, PWA/mobile-only controls, layout controls, navigation controls, usage reports.
- `chat`: render mode, transport, reasoning, layout/message toggles, mobile status bar, dotfiles, queue/draft/spellcheck.
- `sessions`: defaults, retention, desktop network controls, OpenCode CLI controls.
- `notifications`: delivery, events, background push.
- `git`: GitHub account, identities, changes view, Gitmoji, gitignored files.
- `usage`: header menu visibility, model quotas section.
- `agents`: create action plus static editor fields for name, mode, model, temperature, Top P, system prompt, and permissions.
- `commands`: create action plus static editor fields for name, agent, model, and template.
- `mcp`: create action plus static editor sections for server, command/URL, environment variables, and advanced remote options.
- `plugins`: add action plus static editor fields for spec, options JSON, and file content.
- `snippets`: create action plus snippet content editor.
- `providers`: connect action plus auth, connection details, and models sections.
- `skills.installed`: create action plus basic information, instructions, and supporting files sections.
- `behavior`: global AGENTS.md and response style sections.
- `projects`: static project metadata fields and worktree section, excluding individual projects.
- `skills.catalog`: source repository, catalog search, and add catalog action, excluding individual catalog skills/sources.
- `magic-prompts`: visible prompt, instructions, and reset-all action, excluding individual prompt result generation beyond the selected editor page.
- `shortcuts`: keyboard shortcut editor section.
- `voice`: voice setup, speech recognition, and playback sections.
- `tunnel`: provider, tunnel type, TTLs, managed remote/local configuration, and start/connect link sections.
- `remote-instances`: client auth/pairing and desktop direct-host sections; SSH instance dialog fields stay out of search because they require selected-instance state.
Still pending:
- Add state-aware filtering for settings that are hidden based on current settings values, not just platform. Examples: `chat.activity-default-mode`, `chat.collapsible-reasoning`.
- Add focused tests for `buildSettingsSearchResults`, especially runtime/mobile filtering.
Out of scope by decision:
- Do not generate search results from dynamic store entities such as individual agents, commands, MCP servers, snippets, plugins, skills, providers, or projects.
- For split pages, search should cover predictable static create actions, editor fields, and sections only.
## Important Constraints
- Do not rely on localized key naming alone for navigation. The registry is the source of truth.
- Do not parse JSX or scrape the DOM to discover settings automatically.
- Search should use current locale strings, with English fallback already handled by i18n.
- Do not introduce broad Zustand state for transient search query/highlight state. Keep it local to `SettingsView` unless another surface needs it.
- Keep page behavior unchanged when the query is empty.
- If a page is unavailable in the current runtime, its search items must not appear.
## Future Improvements
- Add fuzzy ranking instead of simple substring matching.
- Support deep-linking to settings items from URLs or app commands.
- Add complete registry coverage for all Settings pages.
@@ -655,7 +655,7 @@ export const AgentsPage: React.FC = () => {
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewAgent && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="agents.name" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.agentName')}</span>
</div>
@@ -705,7 +705,7 @@ export const AgentsPage: React.FC = () => {
</div>
</div>
<div className="pb-1.5 pt-0.5">
<div data-settings-item="agents.mode" className="pb-1.5 pt-0.5">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.mode')}</span>
@@ -763,7 +763,7 @@ export const AgentsPage: React.FC = () => {
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="agents.model" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.overrideModel')}</span>
</div>
@@ -782,7 +782,7 @@ export const AgentsPage: React.FC = () => {
</div>
</div>
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div data-settings-item="agents.temperature" className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "sm:w-56 shrink-0")}>
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.temperature')}</span>
@@ -826,7 +826,7 @@ export const AgentsPage: React.FC = () => {
</div>
</div>
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div data-settings-item="agents.top-p" className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "sm:w-56 shrink-0")}>
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.topP')}</span>
@@ -874,7 +874,7 @@ export const AgentsPage: React.FC = () => {
</div>
{/* System Prompt */}
<div className="mb-8">
<div data-settings-item="agents.system-prompt" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.agents.page.section.systemPrompt')}
@@ -893,7 +893,7 @@ export const AgentsPage: React.FC = () => {
</div>
{/* Tool Permissions */}
<div className="mb-2">
<div data-settings-item="agents.permissions" className="mb-2">
<div className="mb-1 px-1 flex items-center justify-between gap-4">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.agents.page.section.toolPermissions')}
@@ -334,6 +334,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.agents.sidebar.total', { count: visibleAgents.length })}</span>
<Button size="sm"
data-settings-item="agents.create"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
@@ -243,7 +243,7 @@ export const BehaviorPage: React.FC = () => {
</h2>
</div>
<div>
<div data-settings-item="behavior.system-prompt">
<div className="mb-1 px-1">
<div className="flex items-center gap-1.5">
<h3 className="typography-ui-header font-medium text-foreground">
@@ -288,7 +288,7 @@ export const BehaviorPage: React.FC = () => {
</section>
</div>
<div>
<div data-settings-item="behavior.response-style">
<div className="mb-1 px-1">
<div className="flex items-center gap-1.5">
<h3 className="typography-ui-header font-medium text-foreground">
@@ -215,7 +215,7 @@ export const CommandsPage: React.FC = () => {
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewCommand && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="commands.name" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.commandName')}</span>
</div>
@@ -278,7 +278,7 @@ export const CommandsPage: React.FC = () => {
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="commands.agent" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.overrideAgent')}</span>
</div>
@@ -290,7 +290,7 @@ export const CommandsPage: React.FC = () => {
</div>
</div>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="commands.model" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.overrideModel')}</span>
</div>
@@ -313,7 +313,7 @@ export const CommandsPage: React.FC = () => {
</div>
{/* Command Template */}
<div className="mb-2">
<div data-settings-item="commands.template" className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.commands.page.section.template')}
@@ -231,6 +231,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.commands.sidebar.total', { count: commandOnlyItems.length })}</span>
<Button size="sm"
data-settings-item="commands.create"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
@@ -117,10 +117,12 @@ export const GitPage: React.FC = () => {
<>
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
<GitHubSettings />
<div data-settings-item="git.github-account">
<GitHubSettings />
</div>
{/* Identities Section */}
<div className="border-t border-border/40 pt-6">
<div data-settings-item="git.identities" className="border-t border-border/40 pt-6">
<div className="mb-3 px-1 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.gitIdentities.page.section.title')}</h3>
@@ -326,6 +326,7 @@ export const MagicPromptsPage: React.FC = () => {
</div>
</div>
<Button
data-settings-item="magic-prompts.reset-overrides"
variant="outline"
size="sm"
onClick={() => {
@@ -348,7 +349,11 @@ export const MagicPromptsPage: React.FC = () => {
const resetting = resettingIds[block.id] === true;
return (
<section key={block.id} className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'}>
<section
key={block.id}
data-settings-item={isVisiblePromptId(block.id) ? 'magic-prompts.visible-prompt' : 'magic-prompts.instructions'}
className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'}
>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="typography-ui-label text-foreground">{tUnsafe(block.titleKey)}</h3>
@@ -1493,7 +1493,7 @@ export const McpPage: React.FC = () => {
)}
{/* Server Identity */}
<div className="mb-6">
<div data-settings-item="mcp.server" className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.server.title')}</h3>
</div>
@@ -1604,7 +1604,7 @@ export const McpPage: React.FC = () => {
</div>
{/* Connection */}
<div className="mb-6">
<div data-settings-item="mcp.command" className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
@@ -1634,7 +1634,7 @@ export const McpPage: React.FC = () => {
</div>
{mcpType === 'remote' && (
<div className="mb-6">
<div data-settings-item="mcp.advanced" className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.advanced.title')}</h3>
</div>
@@ -1787,7 +1787,7 @@ export const McpPage: React.FC = () => {
)}
{/* Environment Variables */}
<div className="mb-2">
<div data-settings-item="mcp.environment" className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.mcp.page.env.title')}
@@ -186,6 +186,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
{t('settings.mcp.sidebar.total', { count: mcpServers.length })}
</span>
<Button size="sm"
data-settings-item="mcp.create"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
@@ -245,7 +245,7 @@ export const DefaultsSettings: React.FC = () => {
)}
</div>
<div className={cn('flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8')}>
<div data-settings-item="sessions.default-model" className={cn('flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8')}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultModel')}</span>
</div>
@@ -254,7 +254,7 @@ export const DefaultsSettings: React.FC = () => {
</div>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="sessions.default-thinking" className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultThinking')}</span>
</div>
@@ -277,7 +277,7 @@ export const DefaultsSettings: React.FC = () => {
</div>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="sessions.default-agent" className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultAgent')}</span>
</div>
@@ -287,6 +287,7 @@ export const DefaultsSettings: React.FC = () => {
</div>
<div
data-settings-item="sessions.deletion-dialog"
className="group flex cursor-pointer items-center gap-2 py-1"
role="button"
tabIndex={0}
@@ -216,6 +216,7 @@ export const DesktopNetworkSettings: React.FC = () => {
<section className="space-y-2 px-2 pb-2 pt-0">
{launchAtLoginSupported ? (
<div
data-settings-item="sessions.desktop-launch-at-login"
className="group flex cursor-pointer items-start gap-2 py-1.5"
role="button"
tabIndex={0}
@@ -242,7 +243,7 @@ export const DesktopNetworkSettings: React.FC = () => {
</div>
) : null}
<div className="space-y-1 py-1.5">
<div data-settings-item="sessions.desktop-ui-password" className="space-y-1 py-1.5">
<label className="typography-ui-label text-foreground" htmlFor="desktop-ui-password">
{t('settings.openchamber.desktopPassword.field.password')}
</label>
@@ -261,6 +262,7 @@ export const DesktopNetworkSettings: React.FC = () => {
</div>
<div
data-settings-item="sessions.desktop-lan-access"
className="group flex cursor-pointer items-start gap-2 py-1.5"
role="button"
tabIndex={0}
@@ -122,7 +122,7 @@ export const GitSettings: React.FC = () => {
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div className="pt-1 pb-1">
<div data-settings-item="git.changes-view" className="pt-1 pb-1">
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.changesViewTitle')}</h4>
<div role="radiogroup" aria-label={t('settings.openchamber.git.changesViewAria')} className="mt-0.5 space-y-0">
{viewOptions.map((option) => {
@@ -157,6 +157,7 @@ export const GitSettings: React.FC = () => {
</div>
<div
data-settings-item="git.gitmoji"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
@@ -182,6 +183,7 @@ export const GitSettings: React.FC = () => {
</div>
<div
data-settings-item="git.gitignored-files"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
@@ -150,7 +150,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
return (
<div className="mb-8">
<div data-settings-item="shortcuts.keyboard-shortcuts" className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.keyboardShortcuts.title')}</h3>
@@ -441,7 +441,7 @@ export const NotificationSettings: React.FC = () => {
<div className="space-y-8">
{/* --- Global Delivery Settings --- */}
<div className="mb-8">
<div data-settings-item="notifications.delivery" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.notifications.page.delivery.title')}
@@ -540,7 +540,7 @@ export const NotificationSettings: React.FC = () => {
{nativeNotificationsEnabled && canShowNotifications && (
<>
{/* --- Events --- */}
<div className="mb-8">
<div data-settings-item="notifications.events" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.notifications.page.events.title')}
@@ -672,7 +672,7 @@ export const NotificationSettings: React.FC = () => {
{/* --- Background Push Notifications --- */}
{isBrowser && (
<div className="mb-8">
<div data-settings-item="notifications.push" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.notifications.page.push.title')}
@@ -727,7 +727,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
<div className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div className="flex min-w-0 items-center gap-2">
<div data-settings-item="appearance.light-theme" className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.lightTheme')}</span>
<Select value={selectedLightTheme?.metadata.id ?? ''} onValueChange={setLightThemePreference}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectLightThemeAria')} className="w-fit">
@@ -746,7 +746,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SelectContent>
</Select>
</div>
<div className="flex min-w-0 items-center gap-2">
<div data-settings-item="appearance.dark-theme" className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.darkTheme')}</span>
<Select value={selectedDarkTheme?.metadata.id ?? ''} onValueChange={setDarkThemePreference}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectDarkThemeAria')} className="w-fit">
@@ -861,7 +861,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<section className="px-2 pb-2 pt-0 space-y-2">
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.localization')}</h4>
<div className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div data-settings-item="appearance.language" className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground shrink-0">{t('settings.appearance.language.label')}</span>
<span className="typography-meta text-muted-foreground">{t('settings.appearance.language.description')}</span>
@@ -883,7 +883,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{(shouldShow('timeFormat') || shouldShow('weekStart')) && (
<div className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
{shouldShow('timeFormat') && (
<div className="flex min-w-0 items-center gap-2">
<div data-settings-item="appearance.time-format" className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.timeFormat')}</span>
<Select value={timeFormatPreference} onValueChange={(value: 'auto' | '12h' | '24h') => handleTimeFormatPreferenceChange(value)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectTimeFormatAria')} className="w-fit">
@@ -899,7 +899,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{shouldShow('weekStart') && (
<div className="flex min-w-0 items-center gap-2">
<div data-settings-item="appearance.week-start" className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.weekStartsOn')}</span>
<Select value={weekStartPreference} onValueChange={(value: 'auto' | 'monday' | 'sunday') => handleWeekStartPreferenceChange(value)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectWeekStartAria')} className="w-fit">
@@ -922,7 +922,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<section className="px-2 pb-2 pt-0 space-y-2">
{showPwaInstallNameSetting && (
<div className="py-1.5 space-y-1.5">
<div data-settings-item="appearance.pwa-install-name" className="py-1.5 space-y-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.installAppName')}</span>
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.installAppNameHint')}</span>
@@ -964,7 +964,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{showPwaOrientationSetting && (
<div className="py-1.5 space-y-1.5">
<div data-settings-item="appearance.pwa-orientation" className="py-1.5 space-y-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.installOrientation')}</span>
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.installOrientationHint')}</span>
@@ -1010,7 +1010,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{showMobileKeyboardModeSetting && (
<div className="py-1.5 space-y-1.5">
<div data-settings-item="appearance.mobile-keyboard-mode" className="py-1.5 space-y-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.mobileKeyboardMode')}</span>
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.mobileKeyboardModeHint')}</span>
@@ -1067,7 +1067,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<div className="pl-2">
{shouldShow('fontSize') && !isMobile && (
<div className="flex items-center gap-8 py-1">
<div data-settings-item="appearance.interface-font-size" className="flex items-center gap-8 py-1">
<div className="flex min-w-0 flex-col w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.interfaceFont')}</span>
</div>
@@ -1163,7 +1163,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{shouldShow('terminalFontSize') && (
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div data-settings-item="appearance.terminal-font-size" className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.terminalFontSize')}</span>
</div>
@@ -1192,7 +1192,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{shouldShow('spacing') && (
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div data-settings-item="appearance.spacing-density" className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.spacingDensity')}</span>
</div>
@@ -1221,7 +1221,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{shouldShow('inputBarOffset') && (
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div data-settings-item="appearance.input-bar-offset" className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.inputBarOffset')}</span>
@@ -1271,7 +1271,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<section className="px-2 pb-2 pt-0">
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.navigation')}</h4>
{shouldShow('fileEditorKeymap') && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-start sm:gap-8">
<div data-settings-item="appearance.file-editor-keymap" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-start sm:gap-8">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">
{t('settings.openchamber.visual.field.fileEditorKeymap')}
</span>
@@ -1314,6 +1314,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<div
data-settings-item="appearance.terminal-quick-keys"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
@@ -1354,7 +1355,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-[minmax(0,16rem)_minmax(0,16rem)] md:justify-start md:gap-x-2">
{shouldShow('chatRenderMode') && (
<section className="p-2 md:col-span-2">
<section data-settings-item="chat.render-mode" className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.chatRenderMode')}</h4>
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.chatRenderModeAria')} className="mt-1 grid w-full max-w-[26rem] grid-cols-1 gap-3 sm:grid-cols-2">
{CHAT_RENDER_MODE_OPTIONS.map((option) => {
@@ -1438,7 +1439,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
{shouldShow('messageTransport') && (
<section className="p-2 md:col-span-2">
<section data-settings-item="chat.message-transport" className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.messageStreamTransport')}</h4>
<div className="mt-1 flex max-w-[24rem] flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
@@ -1699,6 +1700,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<section className="p-2 space-y-0.5">
{shouldShow('reasoning') && (
<div
data-settings-item="chat.reasoning-traces"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1745,6 +1747,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('stickyUserHeader') && (
<div
data-settings-item="chat.sticky-user-header"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1768,6 +1771,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('wideChatLayout') && (
<div
data-settings-item="chat.wide-layout"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1791,6 +1795,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('splitAssistantMessageActions') && (
<div
data-settings-item="chat.inline-assistant-actions"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1824,6 +1829,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('showToolFileIcons') && (
<div
data-settings-item="chat.tool-file-icons"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1847,6 +1853,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('showTurnChangedFiles') && (
<div
data-settings-item="chat.changed-files"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1870,6 +1877,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('dotfiles') && !isVSCodeRuntime() && (
<div
data-settings-item="chat.dotfiles"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1918,6 +1926,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('queueMode') && (
<div
data-settings-item="chat.queue-mode"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1951,6 +1960,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('persistDraft') && (
<div
data-settings-item="chat.persist-drafts"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
@@ -1974,6 +1984,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{!isMobile && shouldShow('inputSpellcheck') && (
<div
data-settings-item="chat.spellcheck"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
@@ -2006,7 +2017,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<div className="space-y-3">
<section className="px-2 pb-2 pt-0">
<h4 className="typography-ui-header font-medium text-foreground mb-2">{t('settings.openchamber.visual.section.privacy')}</h4>
<div className="flex items-start gap-2 py-1.5">
<div data-settings-item="appearance.usage-reports" className="flex items-start gap-2 py-1.5">
<Checkbox
checked={reportUsage}
onChange={handleReportUsageChange}
@@ -109,7 +109,7 @@ export const OpenCodeCliSettings: React.FC = () => {
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3">
<div data-settings-item="sessions.opencode-binary" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-col shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.opencodeCli.field.binaryPath')}</span>
</div>
@@ -149,7 +149,7 @@ export const OpenCodeCliSettings: React.FC = () => {
</div>
</div>
<label className="flex cursor-pointer items-center gap-2 py-1.5">
<label data-settings-item="sessions.opencode-update-notifications" className="flex cursor-pointer items-center gap-2 py-1.5">
<Checkbox
checked={showOpenCodeUpdateNotifications}
onChange={handleShowUpdateNotificationsChange}
@@ -76,6 +76,7 @@ export const SessionRetentionSettings: React.FC = () => {
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
data-settings-item="sessions.auto-cleanup"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
@@ -96,7 +97,7 @@ export const SessionRetentionSettings: React.FC = () => {
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}</span>
</div>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="sessions.retention-period" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.retentionPeriod')}</span>
</div>
@@ -125,7 +126,7 @@ export const SessionRetentionSettings: React.FC = () => {
</div>
</div>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div data-settings-item="sessions.retention-action" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}</span>
</div>
@@ -1322,7 +1322,7 @@ export const TunnelSettings: React.FC = () => {
{(
<section className="space-y-4 px-2 pb-2 pt-0">
<div className="space-y-3">
<div className="space-y-1.5">
<div data-settings-item="tunnel.provider" className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.provider')}</p>
<Select
value={tunnelProvider}
@@ -1353,7 +1353,7 @@ export const TunnelSettings: React.FC = () => {
</Select>
</div>
<div className="space-y-1.5">
<div data-settings-item="tunnel.type" className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.tunnelType')}</p>
<div className="flex flex-wrap items-center gap-1">
{tunnelModeOptions.map((option) => (
@@ -1381,7 +1381,7 @@ export const TunnelSettings: React.FC = () => {
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div data-settings-item="tunnel.ttl" className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.connectLinkTtl')}</span>
<Select
@@ -1446,7 +1446,7 @@ export const TunnelSettings: React.FC = () => {
)}
{tunnelMode === 'managed-remote' && (
<div className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
<div data-settings-item="tunnel.managed-remote" className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
{typeof suggestedConnectorPort === 'number' && (
<div className="rounded-md border border-[var(--status-info-border)] bg-[var(--status-info-background)]/35 px-2 py-1.5">
<p className="typography-meta text-[var(--status-info)]">
@@ -1657,7 +1657,7 @@ export const TunnelSettings: React.FC = () => {
)}
{tunnelMode === 'managed-local' && (
<div className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
<div data-settings-item="tunnel.managed-local-config" className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
<div className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.configurationFile')}</p>
<input
@@ -1722,7 +1722,7 @@ export const TunnelSettings: React.FC = () => {
)}
{!isSelectedModeTunnelReady && (
<div className="space-y-6">
<div data-settings-item="tunnel.start" className="space-y-6">
<div className="rounded-lg border border-[var(--status-info-border)] bg-[var(--status-info-background)] p-3">
<div className="flex items-start gap-2">
<Icon name="information" className="mt-0.5 size-4 shrink-0 text-[var(--status-info)]" />
@@ -1832,7 +1832,7 @@ export const TunnelSettings: React.FC = () => {
)}
{isSelectedModeTunnelReady && tunnelInfo && (
<section className="space-y-4 px-2 pb-2 pt-0">
<section data-settings-item="tunnel.start" className="space-y-4 px-2 pb-2 pt-0">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="size-2 shrink-0 rounded-full bg-[var(--status-success)]" />
@@ -499,7 +499,7 @@ export const VoiceSettings: React.FC = () => {
<div className="space-y-8">
{/* Voice Setup */}
<div className="mb-8">
<div data-settings-item="voice.voice-setup" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.voice.page.section.voiceSetup')}
@@ -827,7 +827,7 @@ export const VoiceSettings: React.FC = () => {
{/* Speech Recognition */}
{voiceModeEnabled && (
<div className="mb-8">
<div data-settings-item="voice.speech-recognition" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.voice.page.section.speechRecognition')}
@@ -1035,7 +1035,7 @@ export const VoiceSettings: React.FC = () => {
)}
{/* Playback & Summarization */}
<div className="mb-8">
<div data-settings-item="voice.playback" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.voice.page.section.playbackAndSummary')}
@@ -157,7 +157,7 @@ export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
<div className="flex flex-col gap-4">
{(tab === 'npm' || tab === 'path') && (
<>
<div className="flex flex-col gap-1.5">
<div data-settings-item="plugins.spec" className="flex flex-col gap-1.5">
<label htmlFor="plugin-spec" className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.spec')}
</label>
@@ -176,7 +176,7 @@ export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
)}
</div>
<div className="flex flex-col gap-1.5">
<div data-settings-item="plugins.options" className="flex flex-col gap-1.5">
<label htmlFor="plugin-options" className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.options')}
</label>
@@ -200,7 +200,7 @@ export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
{tab === 'file' && (
<>
<div className="flex flex-col gap-1.5">
<div data-settings-item="plugins.content" className="flex flex-col gap-1.5">
<label htmlFor="plugin-filename" className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.fileName')}
</label>
@@ -213,7 +213,7 @@ export const PluginsPage: React.FC = () => {
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
<div className="space-y-1.5">
<div data-settings-item="plugins.spec" className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.spec')}
</label>
@@ -228,7 +228,7 @@ export const PluginsPage: React.FC = () => {
/>
</div>
<div className="space-y-1.5">
<div data-settings-item="plugins.options" className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.options')}
</label>
@@ -338,7 +338,7 @@ export const PluginsPage: React.FC = () => {
</div>
</div>
<div className="space-y-1.5">
<div data-settings-item="plugins.content" className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.content')}
</label>
@@ -68,6 +68,12 @@ export const PluginsSidebar: React.FC<PluginsSidebarProps> = ({
void loadPlugins();
}, [loadPlugins]);
React.useEffect(() => {
const handleOpenAdd = () => setIsAddOpen(true);
window.addEventListener('openchamber:settings-open-plugin-add', handleOpenAdd);
return () => window.removeEventListener('openchamber:settings-open-plugin-add', handleOpenAdd);
}, []);
const updateCounts = React.useMemo(() => {
const counts = { userEntries: 0, projectEntries: 0 };
for (const entry of entries) {
@@ -282,6 +288,7 @@ export const PluginsSidebar: React.FC<PluginsSidebarProps> = ({
<div className="flex items-center gap-1">
<Button
type="button"
data-settings-item="plugins.create"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
@@ -259,7 +259,7 @@ export const ProjectsPage: React.FC = () => {
<section className="px-2 pb-2 pt-0 space-y-0.5">
{/* Name */}
<div className="py-1.5">
<div data-settings-item="projects.name" className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectName')}</span>
</div>
@@ -274,7 +274,7 @@ export const ProjectsPage: React.FC = () => {
</div>
{/* Color */}
<div className="py-1.5">
<div data-settings-item="projects.accent-color" className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.accentColor')}</span>
</div>
@@ -311,7 +311,7 @@ export const ProjectsPage: React.FC = () => {
</div>
{/* Icon */}
<div className="py-1.5">
<div data-settings-item="projects.icon" className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectIcon')}</span>
</div>
@@ -482,7 +482,7 @@ export const ProjectsPage: React.FC = () => {
</div>
{/* Worktree Group */}
<div className="mb-8">
<div data-settings-item="projects.worktree" className="mb-8">
<section className="px-2 pb-2 pt-0">
{selectedProjectRef && <ProjectActionsSection projectRef={selectedProjectRef} />}
</section>
@@ -500,7 +500,7 @@ export const ProvidersPage: React.FC = () => {
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="mb-4">
<div data-settings-item="providers.connect" className="mb-4">
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.providers.page.connect.title')}</h1>
</div>
@@ -599,7 +599,7 @@ export const ProvidersPage: React.FC = () => {
</div>
{candidateProviderId && (
<div className="mb-8">
<div data-settings-item="providers.auth" className="mb-8">
<div className="mb-1 px-1">
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h2>
</div>
@@ -787,7 +787,7 @@ export const ProvidersPage: React.FC = () => {
</div>
{/* Authentication */}
<div className="mb-8">
<div data-settings-item="providers.auth" className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h3>
<Button
@@ -934,7 +934,7 @@ export const ProvidersPage: React.FC = () => {
</div>
{/* Connection Details */}
<div className="mb-8">
<div data-settings-item="providers.connection-details" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connectionDetails.title')}</h3>
</div>
@@ -971,7 +971,7 @@ export const ProvidersPage: React.FC = () => {
</div>
{/* Models */}
<div className="mb-8">
<div data-settings-item="providers.models" className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.providers.page.models.title')}
@@ -956,7 +956,7 @@ export const RemoteInstancesPage: React.FC = () => {
return (
<SettingsPageLayout>
{clientAuth ? (
<div className="mb-8">
<div data-settings-item="remote-instances.client-auth" className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.clientAuth.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p>
@@ -1029,7 +1029,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
) : null}
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
{showInstanceManagement ? <div data-settings-item="remote-instances.direct-hosts" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
@@ -508,7 +508,7 @@ const SkillsInstalledPage: React.FC = () => {
</div>
{/* Basic Information */}
<div className="mb-8">
<div data-settings-item="skills.basic-information" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.basicInformation')}
@@ -583,7 +583,7 @@ const SkillsInstalledPage: React.FC = () => {
</div>
{/* Instructions */}
<div className="mb-8">
<div data-settings-item="skills.instructions" className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.instructions')}
@@ -626,7 +626,7 @@ const SkillsInstalledPage: React.FC = () => {
</div>
{/* Supporting Files */}
<div className="mb-2">
<div data-settings-item="skills.supporting-files" className="mb-2">
<div className="mb-1 px-1 flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.supportingFiles')}
@@ -236,6 +236,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.skills.sidebar.total', { count: skills.length })}</span>
<Button size="sm"
data-settings-item="skills.create"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
@@ -188,7 +188,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</div>
{/* Source & Search */}
<div className="mb-8">
<div data-settings-item="skills.catalog.source" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.skills.catalog.page.section.sourceRepository')}</h3>
</div>
@@ -244,6 +244,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
)}
<Button
data-settings-item="skills.catalog.add-catalog"
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
@@ -252,7 +253,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</Button>
</div>
<div className="py-1.5">
<div data-settings-item="skills.catalog.search" className="py-1.5">
<div className="relative">
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
@@ -152,7 +152,7 @@ export const SnippetsPage: React.FC = () => {
</div>
</div>
<div className="mb-2 px-2">
<div data-settings-item="snippets.content" className="mb-2 px-2">
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.content')}</span>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
<p className="mt-2 typography-meta text-muted-foreground">{t('settings.snippets.page.hint')}</p>
@@ -63,7 +63,7 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.snippets.sidebar.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.snippets.sidebar.total', { count: snippets.length })}</span>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew} aria-label={t('settings.snippets.sidebar.actions.create')}>
<Button size="sm" data-settings-item="snippets.create" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew} aria-label={t('settings.snippets.sidebar.actions.create')}>
<Icon name="add" className="h-3.5 w-3.5" />
</Button>
</div>
@@ -170,7 +170,7 @@ export const UsagePage: React.FC = () => {
</div>
{/* Options */}
<div className="mb-8 px-2">
<div data-settings-item="usage.header-menu" className="mb-8 px-2">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
@@ -228,7 +228,7 @@ export const UsagePage: React.FC = () => {
{/* Overall Usage Windows */}
{usage?.windows && Object.keys(usage.windows).length > 0 && (
<div className="mb-8">
<div data-settings-item="usage.model-quotas" className="mb-8">
<section className="px-2 pb-2 pt-0">
<div className="divide-y divide-[var(--surface-subtle)]">
{Object.entries(usage.windows).map(([label, window]) => (
@@ -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;
+6
View File
@@ -40,6 +40,12 @@ button,
animation: none !important;
}
[data-settings-search-highlight="true"] {
border-radius: 0.25rem;
background: color-mix(in srgb, var(--primary-base) 7%, transparent);
transition: background-color 180ms ease;
}
/* macOS vibrancy
The window is created transparent with a native 'sidebar' NSVisualEffectView
behind it. We keep the whole app opaque (bg-background covers the window)
@@ -28,6 +28,10 @@ export const settingsDict = {
'settings.view.actions.closeSettingsWithShortcut': 'Close Settings ({shortcut}+,)',
'settings.view.actions.back': 'Back',
'settings.view.actions.resizeNavigation': 'Resize settings navigation',
'settings.view.search.placeholder': 'Search settings',
'settings.view.search.aria': 'Search settings',
'settings.view.search.clear': 'Clear settings search',
'settings.view.search.noResults': 'No matching settings',
'settings.page.projects.title': 'Projects',
'settings.page.remoteInstances.title': 'Remote Instances',
'settings.page.providers.title': 'Providers',
@@ -28,6 +28,10 @@ export const settingsDict = {
"settings.view.actions.closeSettingsWithShortcut": "Cerrar configuración ({shortcut}+,)",
"settings.view.actions.back": "Atrás",
"settings.view.actions.resizeNavigation": "Ajustar tamaño de la navegación",
"settings.view.search.placeholder": "Buscar configuración",
"settings.view.search.aria": "Buscar configuración",
"settings.view.search.clear": "Borrar búsqueda de configuración",
"settings.view.search.noResults": "No hay configuraciones coincidentes",
"settings.page.projects.title": "Proyectos",
"settings.page.remoteInstances.title": "Instancias remotas",
"settings.page.providers.title": "Proveedores",
@@ -28,6 +28,10 @@ export const settingsDict = {
'settings.view.actions.closeSettingsWithShortcut': '설정 닫기 ({shortcut}+,)',
'settings.view.actions.back': '뒤로',
'settings.view.actions.resizeNavigation': '설정 내비게이션 크기 조정',
'settings.view.search.placeholder': '설정 검색',
'settings.view.search.aria': '설정 검색',
'settings.view.search.clear': '설정 검색 지우기',
'settings.view.search.noResults': '일치하는 설정이 없습니다',
'settings.page.projects.title': '프로젝트',
'settings.page.remoteInstances.title': '원격 인스턴스',
'settings.page.providers.title': '프로바이더',
@@ -1,4 +1,5 @@
export const settingsDict = {
'settings.appearance.language.label': 'Język',
'settings.appearance.language.description': 'Wybierz język interfejsu.',
'settings.agents.modelSelector.actions.addToFavorites': 'Dodaj do ulubionych',
'settings.agents.modelSelector.actions.favorite': 'Ulubione',
@@ -911,6 +912,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.interfaceFontSize': 'Rozmiar czcionki interfejsu',
'settings.openchamber.visual.field.lightTheme': 'Jasny motyw',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Renderowanie Mermaid: {option}',
'settings.openchamber.visual.field.mobileKeyboardMode': 'Tryb klawiatury mobilnej',
'settings.openchamber.visual.field.mobileKeyboardModeHint': 'Wybierz sposób zachowania widoku podczas otwartej klawiatury.',
'settings.openchamber.visual.field.persistDraftMessages': 'Zachowuj szkice wiadomości',
'settings.openchamber.visual.field.persistDraftMessagesAria': 'Zachowuj szkice wiadomości',
'settings.openchamber.visual.field.pwaInstallAppNameAria': 'Nazwa aplikacji instalacyjnej PWA',
@@ -1672,6 +1675,10 @@ export const settingsDict = {
'settings.view.actions.reloadOpenCode': 'Przeładuj OpenCode',
'settings.view.actions.reloadOpenCodeTooltip': 'Uruchom ponownie OpenCode i przeładuj jego konfigurację.',
'settings.view.actions.resizeNavigation': 'Zmień rozmiar nawigacji',
'settings.view.search.placeholder': 'Szukaj ustawień',
'settings.view.search.aria': 'Szukaj ustawień',
'settings.view.search.clear': 'Wyczyść wyszukiwanie ustawień',
'settings.view.search.noResults': 'Brak pasujących ustawień',
'settings.view.badge.beta': 'beta',
'settings.view.home.cards.agents.description': 'Prompty, narzędzia, uprawnienia',
'settings.view.home.cards.agents.title': 'Agenci',
@@ -28,6 +28,10 @@ export const settingsDict = {
"settings.view.actions.closeSettingsWithShortcut": "Fechar configurações ({shortcut}+,)",
"settings.view.actions.back": "Voltar",
"settings.view.actions.resizeNavigation": "Ajustar tamanho da navegação",
"settings.view.search.placeholder": "Buscar configurações",
"settings.view.search.aria": "Buscar configurações",
"settings.view.search.clear": "Limpar busca de configurações",
"settings.view.search.noResults": "Nenhuma configuração encontrada",
"settings.page.projects.title": "Projetos",
"settings.page.remoteInstances.title": "Instâncias remotas",
"settings.page.providers.title": "Provedores",
@@ -28,6 +28,10 @@ export const settingsDict = {
"settings.view.actions.closeSettingsWithShortcut": "Закрити налаштування ({shortcut}+,)",
"settings.view.actions.back": "Назад",
"settings.view.actions.resizeNavigation": "Змінити розмір навігації налаштувань",
"settings.view.search.placeholder": "Пошук налаштувань",
"settings.view.search.aria": "Пошук налаштувань",
"settings.view.search.clear": "Очистити пошук налаштувань",
"settings.view.search.noResults": "Нічого не знайдено",
"settings.page.projects.title": "Проєкти",
"settings.page.remoteInstances.title": "Віддалені інстанси",
"settings.page.providers.title": "Провайдери",
@@ -28,6 +28,10 @@ export const settingsDict = {
'settings.view.actions.closeSettingsWithShortcut': '关闭设置({shortcut}+,',
'settings.view.actions.back': '返回',
'settings.view.actions.resizeNavigation': '调整设置导航宽度',
'settings.view.search.placeholder': '搜索设置',
'settings.view.search.aria': '搜索设置',
'settings.view.search.clear': '清除设置搜索',
'settings.view.search.noResults': '没有匹配的设置',
'settings.page.projects.title': '项目',
'settings.page.remoteInstances.title': '远程实例',
'settings.page.providers.title': '提供商',
@@ -26,6 +26,10 @@
'settings.view.actions.closeSettingsWithShortcut': '關閉設定({shortcut}+,',
'settings.view.actions.back': '返回',
'settings.view.actions.resizeNavigation': '調整設定導覽寬度',
'settings.view.search.placeholder': '搜尋設定',
'settings.view.search.aria': '搜尋設定',
'settings.view.search.clear': '清除設定搜尋',
'settings.view.search.noResults': '沒有符合的設定',
'settings.page.projects.title': '專案',
'settings.page.remoteInstances.title': '遠端執行個體',
'settings.page.providers.title': '供應商',
+769
View File
@@ -0,0 +1,769 @@
import type { I18nKey } from '@/lib/i18n/store';
import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata';
import { getSettingsPageMeta } from './metadata';
export interface SettingsSearchItem {
id: string;
page: SettingsPageSlug;
titleKey: I18nKey;
descriptionKey?: I18nKey;
keywords?: string[];
isAvailable?: (ctx: SettingsSearchAvailabilityContext) => boolean;
}
export interface SettingsSearchResult extends SettingsSearchItem {
title: string;
description: string | null;
pageTitle: string;
}
export interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
isMobile: boolean;
isDesktopLocalOrigin: boolean;
}
export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
{
id: 'appearance.language',
page: 'appearance',
titleKey: 'settings.appearance.language.label',
descriptionKey: 'settings.appearance.language.description',
keywords: ['locale', 'translation', 'ui language'],
},
{
id: 'appearance.time-format',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.timeFormat',
keywords: ['clock', '12h', '24h'],
},
{
id: 'appearance.week-start',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.weekStartsOn',
keywords: ['calendar', 'monday', 'sunday'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'appearance.light-theme',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.lightTheme',
keywords: ['theme', 'color', 'light mode'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'appearance.dark-theme',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.darkTheme',
keywords: ['theme', 'color', 'dark mode'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'appearance.pwa-install-name',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.installAppName',
descriptionKey: 'settings.openchamber.visual.field.installAppNameHint',
keywords: ['pwa', 'installed app'],
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
},
{
id: 'appearance.pwa-orientation',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.installOrientation',
descriptionKey: 'settings.openchamber.visual.field.installOrientationHint',
keywords: ['pwa', 'portrait', 'landscape'],
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
},
{
id: 'appearance.mobile-keyboard-mode',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.mobileKeyboardMode',
descriptionKey: 'settings.openchamber.visual.field.mobileKeyboardModeHint',
keywords: ['mobile', 'keyboard', 'resize'],
isAvailable: (ctx) => ctx.isMobile && ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
},
{
id: 'appearance.interface-font-size',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.interfaceFontSize',
keywords: ['font', 'text size', 'ui scale'],
isAvailable: (ctx) => !ctx.isMobile,
},
{
id: 'appearance.terminal-font-size',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.terminalFontSize',
keywords: ['terminal', 'font', 'text size'],
},
{
id: 'appearance.spacing-density',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.spacingDensity',
keywords: ['density', 'compact', 'comfortable', 'spacing'],
},
{
id: 'appearance.input-bar-offset',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.inputBarOffset',
descriptionKey: 'settings.openchamber.visual.field.inputBarOffsetTooltip',
keywords: ['input', 'home bar', 'offset'],
},
{
id: 'appearance.file-editor-keymap',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.fileEditorKeymap',
keywords: ['editor', 'vim', 'keymap'],
},
{
id: 'appearance.terminal-quick-keys',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.terminalQuickKeys',
descriptionKey: 'settings.openchamber.visual.field.terminalQuickKeysTooltip',
keywords: ['terminal', 'keyboard', 'esc', 'ctrl', 'arrows'],
isAvailable: (ctx) => !ctx.isMobile && !ctx.isVSCode,
},
{
id: 'appearance.usage-reports',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.sendAnonymousUsageReports',
descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint',
keywords: ['telemetry', 'analytics'],
},
{
id: 'chat.render-mode',
page: 'chat',
titleKey: 'settings.openchamber.visual.section.chatRenderMode',
keywords: ['messages', 'conversation', 'rendering'],
},
{
id: 'chat.message-transport',
page: 'chat',
titleKey: 'settings.openchamber.visual.section.messageStreamTransport',
keywords: ['streaming', 'sse', 'websocket'],
},
{
id: 'chat.reasoning-traces',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.showReasoningTraces',
keywords: ['thinking', 'reasoning'],
},
{
id: 'chat.sticky-user-header',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.stickyUserHeader',
keywords: ['messages', 'header'],
},
{
id: 'chat.wide-layout',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.wideChatLayout',
keywords: ['layout', 'wide', 'messages'],
},
{
id: 'chat.inline-assistant-actions',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.showSplitAssistantMessageActions',
descriptionKey: 'settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip',
keywords: ['copy', 'save image', 'read aloud'],
},
{
id: 'chat.tool-file-icons',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.showToolFileIcons',
keywords: ['tools', 'files', 'icons'],
},
{
id: 'chat.changed-files',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.showTurnChangedFiles',
keywords: ['changed files', 'turns'],
},
{
id: 'chat.dotfiles',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.showDotfiles',
keywords: ['hidden files'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'chat.queue-mode',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.queueMessagesByDefault',
descriptionKey: 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip',
keywords: ['queue', 'enter', 'send'],
},
{
id: 'chat.persist-drafts',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.persistDraftMessages',
keywords: ['draft', 'message'],
},
{
id: 'chat.spellcheck',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.enableSpellcheckInTextInputs',
keywords: ['spelling', 'input'],
isAvailable: (ctx) => !ctx.isMobile,
},
{
id: 'sessions.default-model',
page: 'sessions',
titleKey: 'settings.openchamber.defaults.field.defaultModel',
keywords: ['model', 'provider', 'new sessions'],
},
{
id: 'sessions.default-thinking',
page: 'sessions',
titleKey: 'settings.openchamber.defaults.field.defaultThinking',
keywords: ['thinking', 'reasoning', 'variant'],
},
{
id: 'sessions.default-agent',
page: 'sessions',
titleKey: 'settings.openchamber.defaults.field.defaultAgent',
keywords: ['agent', 'new sessions'],
},
{
id: 'sessions.deletion-dialog',
page: 'sessions',
titleKey: 'settings.openchamber.defaults.field.showDeletionDialog',
keywords: ['delete', 'confirmation'],
},
{
id: 'sessions.auto-cleanup',
page: 'sessions',
titleKey: 'settings.openchamber.sessionRetention.field.enableAutoCleanup',
descriptionKey: 'settings.openchamber.sessionRetention.tooltip',
keywords: ['retention', 'archive', 'delete'],
},
{
id: 'sessions.retention-period',
page: 'sessions',
titleKey: 'settings.openchamber.sessionRetention.field.retentionPeriod',
keywords: ['days', 'cleanup', 'retention'],
},
{
id: 'sessions.retention-action',
page: 'sessions',
titleKey: 'settings.openchamber.sessionRetention.field.whenSessionsExpire',
keywords: ['archive', 'delete', 'expire'],
},
{
id: 'sessions.desktop-launch-at-login',
page: 'sessions',
titleKey: 'settings.openchamber.desktopNetwork.field.launchAtLogin',
descriptionKey: 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription',
keywords: ['desktop', 'startup', 'login'],
isAvailable: (ctx) => ctx.isDesktopLocalOrigin,
},
{
id: 'sessions.desktop-ui-password',
page: 'sessions',
titleKey: 'settings.openchamber.desktopPassword.field.password',
descriptionKey: 'settings.openchamber.desktopPassword.field.passwordDescription',
keywords: ['desktop', 'password', 'auth', 'login'],
isAvailable: (ctx) => ctx.isDesktopLocalOrigin,
},
{
id: 'sessions.desktop-lan-access',
page: 'sessions',
titleKey: 'settings.openchamber.desktopNetwork.field.allowLanAccess',
descriptionKey: 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription',
keywords: ['desktop', 'lan', 'network', 'phone', 'tablet'],
isAvailable: (ctx) => ctx.isDesktopLocalOrigin,
},
{
id: 'sessions.opencode-binary',
page: 'sessions',
titleKey: 'settings.openchamber.opencodeCli.field.binaryPath',
keywords: ['opencode', 'cli', 'binary', 'path'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'sessions.opencode-update-notifications',
page: 'sessions',
titleKey: 'settings.openchamber.opencodeCli.field.showUpdateNotifications',
keywords: ['opencode', 'cli', 'updates'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'git.github-account',
page: 'git',
titleKey: 'settings.github.page.actions.connect',
keywords: ['github', 'account', 'oauth', 'prs', 'issues'],
},
{
id: 'git.identities',
page: 'git',
titleKey: 'settings.gitIdentities.page.section.title',
descriptionKey: 'settings.gitIdentities.page.empty.description',
keywords: ['identity', 'profile', 'author', 'email', 'credentials'],
},
{
id: 'git.changes-view',
page: 'git',
titleKey: 'settings.openchamber.git.changesViewTitle',
keywords: ['changes', 'flat list', 'tree view'],
},
{
id: 'git.gitmoji',
page: 'git',
titleKey: 'settings.openchamber.git.enableGitmoji',
keywords: ['commit', 'emoji'],
},
{
id: 'git.gitignored-files',
page: 'git',
titleKey: 'settings.openchamber.git.showGitignored',
keywords: ['ignored', 'files', 'gitignore'],
},
{
id: 'usage.header-menu',
page: 'usage',
titleKey: 'settings.usage.page.options.showInHeader',
descriptionKey: 'settings.usage.page.options.showInHeaderTooltip',
keywords: ['quota', 'header', 'dropdown'],
},
{
id: 'usage.model-quotas',
page: 'usage',
titleKey: 'settings.usage.page.section.modelQuotas',
keywords: ['models', 'quota', 'limits', 'tokens'],
},
{
id: 'projects.name',
page: 'projects',
titleKey: 'settings.projects.page.field.projectName',
keywords: ['label', 'display name', 'project metadata'],
},
{
id: 'projects.accent-color',
page: 'projects',
titleKey: 'settings.projects.page.field.accentColor',
keywords: ['color', 'appearance', 'project metadata'],
},
{
id: 'projects.icon',
page: 'projects',
titleKey: 'settings.projects.page.field.projectIcon',
keywords: ['icon', 'favicon', 'upload', 'project metadata'],
},
{
id: 'projects.worktree',
page: 'projects',
titleKey: 'settings.projects.page.section.worktree',
keywords: ['worktree', 'branch', 'repository'],
},
{
id: 'remote-instances.client-auth',
page: 'remote-instances',
titleKey: 'settings.remoteInstances.clientAuth.title',
descriptionKey: 'settings.remoteInstances.clientAuth.description',
keywords: ['pairing link', 'client token', 'connect desktop', 'remote access'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'remote-instances.direct-hosts',
page: 'remote-instances',
titleKey: 'settings.remoteInstances.direct.title',
descriptionKey: 'settings.remoteInstances.direct.description',
keywords: ['server url', 'connection token', 'import link', 'host switcher'],
isAvailable: (ctx) => ctx.isDesktop,
},
{
id: 'behavior.system-prompt',
page: 'behavior',
titleKey: 'settings.behavior.page.section.systemPrompt',
descriptionKey: 'settings.behavior.page.warning.title',
keywords: ['agents.md', 'global instructions', 'system prompt'],
},
{
id: 'behavior.response-style',
page: 'behavior',
titleKey: 'settings.behavior.page.section.responseStyle',
descriptionKey: 'settings.behavior.page.responseStyle.tooltip',
keywords: ['tone', 'concise', 'detailed', 'custom instructions'],
},
{
id: 'agents.create',
page: 'agents',
titleKey: 'settings.agents.page.title.new',
keywords: ['create', 'add', 'new agent'],
},
{
id: 'agents.name',
page: 'agents',
titleKey: 'settings.agents.page.field.agentName',
keywords: ['agent', 'name'],
},
{
id: 'agents.mode',
page: 'agents',
titleKey: 'settings.agents.page.field.mode',
descriptionKey: 'settings.agents.page.field.modeTooltip',
keywords: ['primary', 'subagent', 'visibility'],
},
{
id: 'agents.model',
page: 'agents',
titleKey: 'settings.agents.page.field.overrideModel',
keywords: ['model', 'provider'],
},
{
id: 'agents.temperature',
page: 'agents',
titleKey: 'settings.agents.page.field.temperature',
descriptionKey: 'settings.agents.page.field.temperatureTooltip',
keywords: ['randomness', 'creative'],
},
{
id: 'agents.top-p',
page: 'agents',
titleKey: 'settings.agents.page.field.topP',
descriptionKey: 'settings.agents.page.field.topPTooltip',
keywords: ['sampling', 'nucleus'],
},
{
id: 'agents.system-prompt',
page: 'agents',
titleKey: 'settings.agents.page.section.systemPrompt',
keywords: ['prompt', 'instructions'],
},
{
id: 'agents.permissions',
page: 'agents',
titleKey: 'settings.agents.page.section.toolPermissions',
keywords: ['tools', 'permissions', 'allow', 'ask', 'deny'],
},
{
id: 'commands.create',
page: 'commands',
titleKey: 'settings.commands.page.title.new',
keywords: ['create', 'add', 'new command'],
},
{
id: 'commands.name',
page: 'commands',
titleKey: 'settings.commands.page.field.commandName',
keywords: ['slash command', 'name'],
},
{
id: 'commands.agent',
page: 'commands',
titleKey: 'settings.commands.page.field.overrideAgent',
keywords: ['agent', 'execution'],
},
{
id: 'commands.model',
page: 'commands',
titleKey: 'settings.agents.page.field.overrideModel',
keywords: ['model', 'provider'],
},
{
id: 'commands.template',
page: 'commands',
titleKey: 'settings.commands.page.section.template',
keywords: ['prompt', 'template', 'arguments', 'shell', 'file'],
},
{
id: 'mcp.create',
page: 'mcp',
titleKey: 'settings.mcp.sidebar.actions.addServerTitle',
keywords: ['create', 'add', 'server'],
},
{
id: 'mcp.server',
page: 'mcp',
titleKey: 'settings.mcp.page.server.title',
keywords: ['server', 'name', 'transport'],
},
{
id: 'mcp.command',
page: 'mcp',
titleKey: 'settings.mcp.page.connection.command',
keywords: ['stdio', 'local', 'command'],
},
{
id: 'mcp.environment',
page: 'mcp',
titleKey: 'settings.mcp.page.env.title',
keywords: ['env', 'variables', 'api key'],
},
{
id: 'mcp.advanced',
page: 'mcp',
titleKey: 'settings.mcp.page.advanced.title',
keywords: ['oauth', 'headers', 'timeout'],
},
{
id: 'plugins.create',
page: 'plugins',
titleKey: 'settings.plugins.sidebar.actions.addTitle',
keywords: ['add', 'plugin', 'npm', 'path', 'file'],
},
{
id: 'plugins.spec',
page: 'plugins',
titleKey: 'settings.plugins.page.field.spec',
keywords: ['npm', 'package', 'path'],
},
{
id: 'plugins.options',
page: 'plugins',
titleKey: 'settings.plugins.page.field.options',
keywords: ['json', 'configuration'],
},
{
id: 'plugins.content',
page: 'plugins',
titleKey: 'settings.plugins.page.field.content',
keywords: ['file', 'code'],
},
{
id: 'snippets.create',
page: 'snippets',
titleKey: 'settings.snippets.sidebar.actions.create',
keywords: ['add', 'new snippet'],
},
{
id: 'snippets.content',
page: 'snippets',
titleKey: 'settings.snippets.page.field.content',
keywords: ['markdown', 'prompt', 'template'],
},
{
id: 'providers.connect',
page: 'providers',
titleKey: 'settings.providers.page.connect.title',
keywords: ['add provider', 'connect provider', 'credentials'],
},
{
id: 'providers.auth',
page: 'providers',
titleKey: 'settings.providers.page.auth.title',
keywords: ['api key', 'oauth', 'credentials'],
},
{
id: 'providers.connection-details',
page: 'providers',
titleKey: 'settings.providers.page.connectionDetails.title',
keywords: ['config', 'source', 'disconnect'],
},
{
id: 'providers.models',
page: 'providers',
titleKey: 'settings.providers.page.models.title',
keywords: ['models', 'hide', 'show'],
},
{
id: 'skills.create',
page: 'skills.installed',
titleKey: 'settings.skills.page.title.newSkill',
keywords: ['create', 'add', 'new skill'],
},
{
id: 'skills.basic-information',
page: 'skills.installed',
titleKey: 'settings.skills.page.section.basicInformation',
keywords: ['name', 'location', 'description'],
},
{
id: 'skills.instructions',
page: 'skills.installed',
titleKey: 'settings.skills.page.section.instructions',
keywords: ['markdown', 'skill.md', 'content'],
},
{
id: 'skills.supporting-files',
page: 'skills.installed',
titleKey: 'settings.skills.page.section.supportingFiles',
keywords: ['files', 'resources'],
},
{
id: 'skills.catalog.source',
page: 'skills.catalog',
titleKey: 'settings.skills.catalog.page.section.sourceRepository',
keywords: ['catalog', 'repository', 'source', 'refresh'],
},
{
id: 'skills.catalog.search',
page: 'skills.catalog',
titleKey: 'settings.skills.catalog.shared.field.searchSkillsPlaceholder',
keywords: ['find skills', 'install skills', 'catalog search'],
},
{
id: 'skills.catalog.add-catalog',
page: 'skills.catalog',
titleKey: 'settings.skills.catalog.page.actions.addCatalog',
keywords: ['external repository', 'add source', 'catalog'],
},
{
id: 'magic-prompts.visible-prompt',
page: 'magic-prompts',
titleKey: 'settings.magicPrompts.page.block.visiblePrompt',
keywords: ['prompt text', 'user message', 'template'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'magic-prompts.instructions',
page: 'magic-prompts',
titleKey: 'settings.magicPrompts.page.block.instructions',
keywords: ['hidden prompt', 'instructions', 'template'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'magic-prompts.reset-overrides',
page: 'magic-prompts',
titleKey: 'settings.magicPrompts.page.actions.resetAllOverrides',
keywords: ['reset', 'default prompts', 'overrides'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'shortcuts.keyboard-shortcuts',
page: 'shortcuts',
titleKey: 'settings.openchamber.keyboardShortcuts.title',
descriptionKey: 'settings.openchamber.keyboardShortcuts.tooltip',
keywords: ['keyboard', 'hotkeys', 'bindings'],
},
{
id: 'voice.voice-setup',
page: 'voice',
titleKey: 'settings.voice.page.section.voiceSetup',
keywords: ['tts', 'voice mode', 'provider', 'speech rate', 'speech pitch', 'speech volume', 'language'],
},
{
id: 'voice.speech-recognition',
page: 'voice',
titleKey: 'settings.voice.page.section.speechRecognition',
keywords: ['stt', 'transcribe', 'whisper', 'microphone', 'silence threshold'],
},
{
id: 'voice.playback',
page: 'voice',
titleKey: 'settings.voice.page.section.playbackAndSummary',
keywords: ['read aloud', 'tts input mode', 'summary', 'markdown'],
},
{
id: 'tunnel.provider',
page: 'tunnel',
titleKey: 'settings.openchamber.tunnel.field.provider',
descriptionKey: 'settings.openchamber.tunnel.description',
keywords: ['remote access', 'cloudflare', 'ngrok'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'tunnel.type',
page: 'tunnel',
titleKey: 'settings.openchamber.tunnel.field.tunnelType',
keywords: ['quick', 'managed remote', 'managed local'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'tunnel.ttl',
page: 'tunnel',
titleKey: 'settings.openchamber.tunnel.field.connectLinkTtl',
descriptionKey: 'settings.openchamber.tunnel.field.tunnelSessionTtl',
keywords: ['expiry', 'expiration', 'session ttl', 'connect link ttl'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'tunnel.managed-remote',
page: 'tunnel',
titleKey: 'settings.openchamber.tunnel.section.savedManagedRemoteTunnels',
keywords: ['cloudflare', 'hostname', 'token', 'managed remote'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'tunnel.managed-local-config',
page: 'tunnel',
titleKey: 'settings.openchamber.tunnel.field.configurationFile',
descriptionKey: 'settings.openchamber.tunnel.note.managedLocalUsesConfig',
keywords: ['cloudflared', 'config', 'yaml', 'json', 'managed local'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'tunnel.start',
page: 'tunnel',
titleKey: 'settings.openchamber.tunnel.actions.startTunnel',
descriptionKey: 'settings.openchamber.tunnel.note.connectLinksOneTime',
keywords: ['connect link', 'qr code', 'public url', 'remote access'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'notifications.delivery',
page: 'notifications',
titleKey: 'settings.notifications.page.delivery.title',
keywords: ['desktop notifications', 'system notifications'],
},
{
id: 'notifications.events',
page: 'notifications',
titleKey: 'settings.notifications.page.events.title',
keywords: ['completion', 'subtasks', 'errors', 'questions'],
},
{
id: 'notifications.push',
page: 'notifications',
titleKey: 'settings.notifications.page.push.title',
keywords: ['background', 'push'],
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
},
] as const;
interface BuildSettingsSearchResultsOptions {
query: string;
runtimeCtx: SettingsSearchAvailabilityContext;
visiblePageSlugs?: SettingsPageSlug[];
t: (key: I18nKey) => string;
getPageTitle: (slug: SettingsPageSlug) => string;
}
function normalizeSearchText(value: string): string {
return value.trim().toLocaleLowerCase();
}
export function buildSettingsSearchResults({
query,
runtimeCtx,
visiblePageSlugs,
t,
getPageTitle,
}: BuildSettingsSearchResultsOptions): SettingsSearchResult[] {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return [];
}
const allowedPages = visiblePageSlugs ? new Set<SettingsPageSlug>(visiblePageSlugs) : null;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return SETTINGS_SEARCH_ITEMS.flatMap((item) => {
if (allowedPages && !allowedPages.has(item.page)) {
return [];
}
const pageMeta = getSettingsPageMeta(item.page);
if (!pageMeta || (pageMeta.isAvailable && !pageMeta.isAvailable(runtimeCtx)) || (item.isAvailable && !item.isAvailable(runtimeCtx))) {
return [];
}
const title = t(item.titleKey);
const description = item.descriptionKey ? t(item.descriptionKey) : null;
const haystack = normalizeSearchText([
title,
description,
getPageTitle(item.page),
...(item.keywords ?? []),
].filter(Boolean).join(' '));
if (!terms.every((term) => haystack.includes(term))) {
return [];
}
return [{
...item,
title,
description,
pageTitle: getPageTitle(item.page),
}];
});
}