Files
openchamber/packages/ui/src/components/views/SettingsView.tsx
T
Bohdan Triapitsyn fd416a3678 feat(mobile): drill into the OpenCode settings pages from a phone
Snippets, Agents, Commands, Plugins and Skills were desktop-only, even
though the composer already reads snippets and slash commands and the chat
lets you pick an agent: you could use those features from a phone but not
manage them. They render through the same page and sidebar components, so
the mobile settings surface only had to stop filtering them out.

Making them usable needed the drill-down to be real. A split page opens
its own list on mobile, but back from an item jumped all the way out to
the settings root, and a separate header button was the only way back to
the list — except on Skills, which had a hand-rolled exception. Turn that
exception into the rule: back walks nav → page list → item in reverse on
every split page, browser and system history follow the same path, and the
extra header button goes away because back now does its job. The Android
hardware button asks Settings to step up before the shell closes it.

MCP, Providers, Usage and Magic Prompts inherit the same navigation.

Testing: package type-check, lint, settings suites under the isolated
runner; walked all six pages plus MCP in a browser at 390px (list → item →
back to list → back to root, no horizontal overflow). The hardware back
button needs a device check.
2026-09-09 17:41:14 +03:00

1167 lines
45 KiB
TypeScript

import React from 'react';
import { cn } from '@/lib/utils';
import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentsStore } from '@/stores/useAgentsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
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, TooltipTrigger } from '@/components/ui/tooltip';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
import { BehaviorPage } from '@/components/sections/behavior/BehaviorPage';
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
import { McpSidebar } from '@/components/sections/mcp/McpSidebar';
import { McpPage } from '@/components/sections/mcp/McpPage';
import { PluginsSidebar, PluginsPage } from '@/components/sections/plugins';
import { usePluginsStore } from '@/stores/usePluginsStore';
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar';
import { ProjectsPage } from '@/components/sections/projects/ProjectsPage';
import { RemoteInstancesPage } from '@/components/sections/remote-instances/RemoteInstancesPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
import { UsagePage } from '@/components/sections/usage/UsagePage';
import { MagicPromptsSidebar } from '@/components/sections/magic-prompts/MagicPromptsSidebar';
import { MagicPromptsPage } from '@/components/sections/magic-prompts/MagicPromptsPage';
import { SnippetsSidebar } from '@/components/sections/snippets/SnippetsSidebar';
import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
import { GitPage } from '@/components/sections/git-identities/GitPage';
import { IntegrationsPage } from '@/components/sections/integrations/IntegrationsPage';
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SETTINGS_SECTION_TITLE_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import { McpIcon } from '@/components/icons/McpIcon';
import { OpenCodeReloadFooterAction } from '@/components/views/OpenCodeReloadFooterAction';
import {
selectPendingOpenCodeRestartCount,
usePendingOpenCodeRestartStore,
} from '@/stores/usePendingOpenCodeRestartStore';
import {
SETTINGS_PAGE_METADATA,
getSettingsNavIcon,
getSettingsPageMeta,
resolveSettingsSlug,
type SettingsPageSlug,
type SettingsRuntimeContext,
type SettingsPageMeta,
} from '@/lib/settings/metadata';
import { buildSettingsSearchResults, type SettingsSearchResult } from '@/lib/settings/search';
// UI Kit: fixed settings navigation width
const SETTINGS_NAV_WIDTH = 256;
const SETTINGS_SPLIT_SIDEBAR_WIDTH = 280;
const SETTINGS_DETAIL_HISTORY_KEY = '__openchamberSettingsDetail';
type MobileStage = 'nav' | 'page-sidebar' | 'page-content';
type SettingsDetailHistoryEntry = {
page: SettingsPageSlug;
stage: 'page-content';
};
interface SettingsViewProps {
onClose?: () => void;
/** Force mobile layout regardless of device detection */
forceMobile?: boolean;
/** Rendered inside a window/dialog (skip traffic light padding) */
isWindowed?: boolean;
/** Restrict top-level settings navigation to a specific product surface. */
visiblePageSlugs?: SettingsPageSlug[];
/** Lets a native shell hand its hardware back button to the mobile stages:
the handler steps one level up and reports whether it consumed the press. */
registerBackHandler?: (handler: (() => boolean) | null) => void;
initialMobileStage?: MobileStage;
}
const pageOrder: SettingsPageSlug[] = [
// 'general' group — OpenChamber
'general',
'appearance',
'chat',
'notifications',
'sessions',
'shortcuts',
'voice',
'integrations',
'usage',
'about',
// 'projects' group — Workspace
'projects',
'remote-instances',
'tunnel',
'git',
// 'opencode' group — OpenCode
'providers',
'agents',
'behavior',
'commands',
'mcp',
'plugins',
// 'content' group — Library
'magic-prompts',
'snippets',
'skills.installed',
'skills.catalog',
];
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
const isVSCode = isVSCodeRuntime();
const isWeb = !isDesktop && isWebRuntime();
return { isVSCode, isWeb, isDesktop, isMobile };
}
function isPageAvailable(page: SettingsPageMeta, ctx: SettingsRuntimeContext): boolean {
if (!page.isAvailable) {
return true;
}
return page.isAvailable(ctx);
}
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;
}
const detail = state[SETTINGS_DETAIL_HISTORY_KEY];
if (!isObjectRecord(detail)) {
return null;
}
const page = detail.page;
const stage = detail.stage;
if (typeof page !== 'string' || stage !== 'page-content') {
return null;
}
const resolvedPage = resolveSettingsSlug(page);
return { page: resolvedPage, stage };
}
function getCurrentHistoryState(): Record<string, unknown> {
if (typeof window === 'undefined' || !isObjectRecord(window.history.state)) {
return {};
}
return window.history.state;
}
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav', registerBackHandler }) => {
const { t } = useI18n();
const deviceInfo = useDeviceInfo();
const isMobile = forceMobile ?? deviceInfo.isMobile;
const pendingRestartCount = usePendingOpenCodeRestartStore(selectPendingOpenCodeRestartCount);
const settingsPageRaw = useUIStore((state) => state.settingsPage);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings);
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
// Seed with the mount-time slug when opening at the nav stage: the slug
// persists across opens, and the deep-link auto-jump below must react only
// to slug CHANGES after mount — not re-enter the previously visited page
// every time settings reopen.
const autoNavSlugRef = React.useRef<string | null>(initialMobileStage === 'nav' ? settingsSlug : null);
// No starter page on desktop: 'home' (fresh state) resolves to General.
// settingsPage persists in the UI store, so subsequent opens restore the
// last visited page. Mobile keeps 'home' — its entry stage is the nav list.
React.useEffect(() => {
if (!isMobile && settingsSlug === 'home') {
setSettingsPage('general');
}
}, [isMobile, setSettingsPage, settingsSlug]);
const [settingsSearchQuery, setSettingsSearchQuery] = React.useState('');
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const shouldFocusMobilePageContentRef = React.useRef(false);
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();
}, []);
const isMac = React.useMemo(() => {
return isDesktopShell() && typeof window !== 'undefined'
&& (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'darwin';
}, []);
const isWindows = React.useMemo(() => {
return isDesktopShell() && typeof window !== 'undefined'
&& (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'win32';
}, []);
const isLinux = React.useMemo(() => {
return isDesktopShell() && typeof window !== 'undefined'
&& (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__ === 'linux';
}, []);
const isWindowsArm64 = React.useMemo(() => isWindowsArm64Platform(), []);
// keep platform check available for future window chrome tweaks
const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp, isMobile), [isDesktopApp, isMobile]);
const visiblePages = React.useMemo(() => {
const allowedPages = visiblePageSlugs ? new Set<SettingsPageSlug>(visiblePageSlugs) : null;
return SETTINGS_PAGE_METADATA
.filter((page) => page.slug !== 'home')
.filter((page) => !allowedPages || allowedPages.has(page.slug))
.filter((page) => isPageAvailable(page, runtimeCtx))
.filter((page) => !(runtimeCtx.isVSCode && page.slug === 'projects'))
.filter((page) => !(isMobile && page.slug === 'shortcuts'));
}, [runtimeCtx, isMobile, visiblePageSlugs]);
const sortedFilteredPages = React.useMemo(() => {
const rank = new Map<SettingsPageSlug, number>(pageOrder.map((s, i) => [s, i]));
return visiblePages
.slice()
.sort((a, b) => (rank.get(a.slug) ?? 999) - (rank.get(b.slug) ?? 999));
}, [visiblePages]);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const settingsDirectory = useSettingsDirectory();
// Load stores when the settings project changes or a page becomes active.
React.useEffect(() => {
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
return;
}
if (settingsSlug === 'agents') {
void useAgentsStore.getState().loadAgents(settingsDirectory);
return;
}
if (settingsSlug === 'commands') {
void useCommandsStore.getState().loadCommands(settingsDirectory);
return;
}
if (settingsSlug === 'mcp') {
void useMcpConfigStore.getState().loadMcpConfigs({ directory: settingsDirectory });
return;
}
if (settingsSlug === 'plugins') {
void usePluginsStore.getState().loadPlugins();
return;
}
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
void useSkillsStore.getState().loadSkills(settingsDirectory);
void useSkillsCatalogStore.getState().loadCatalog();
}
if (settingsSlug === 'snippets') {
void useSnippetsStore.getState().loadSnippets();
}
// `activeProjectId` still matters: the settings directory follows the active
// project until the user picks another one in the Settings selector.
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsDirectory, settingsSlug]);
const openPage = React.useCallback((slug: SettingsPageSlug) => {
setSettingsPage(slug);
autoNavSlugRef.current = slug;
if (!isMobile) {
return;
}
const def = getSettingsPageMeta(slug);
if (!def || def.slug === 'home') {
setMobileStage('nav');
return;
}
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
}, [isMobile, setSettingsPage]);
const activePageMeta = React.useMemo(() => {
return getSettingsPageMeta(settingsSlug);
}, [settingsSlug]);
// Nav is always open (collapsed state removed)
const openChamberSectionBySlug: Partial<Record<SettingsPageSlug, OpenChamberSection>> = React.useMemo(() => ({
general: 'general',
appearance: 'visual',
chat: 'chat',
shortcuts: 'shortcuts',
sessions: 'sessions',
notifications: 'notifications',
voice: 'voice',
tunnel: 'tunnel',
}), []);
const getPageTitle = React.useCallback((slug: SettingsPageSlug): string => {
switch (slug) {
case 'general':
return t('settings.page.general.title');
case 'projects':
return t('settings.page.projects.title');
case 'remote-instances':
return t('settings.page.remoteInstances.title');
case 'providers':
return t('settings.page.providers.title');
case 'usage':
return t('settings.page.usage.title');
case 'agents':
return t('settings.page.agents.title');
case 'behavior':
return t('settings.page.behavior.title');
case 'commands':
return t('settings.page.commands.title');
case 'mcp':
return t('settings.page.mcp.title');
case 'plugins':
return t('settings.page.plugins.title');
case 'skills.installed':
return t('settings.page.skills.title');
case 'skills.catalog':
return t('settings.page.skillsCatalog.title');
case 'git':
return t('settings.page.git.title');
case 'integrations':
return t('settings.page.integrations.title');
case 'appearance':
return t('settings.page.appearance.title');
case 'chat':
return t('settings.page.chat.title');
case 'shortcuts':
return t('settings.page.shortcuts.title');
case 'sessions':
return t('settings.page.sessions.title');
case 'magic-prompts':
return t('settings.page.magicPrompts.title');
case 'snippets':
return t('settings.page.snippets.title');
case 'notifications':
return t('settings.page.notifications.title');
case 'voice':
return t('settings.page.voice.title');
case 'tunnel':
return t('settings.page.tunnel.title');
case 'about':
return t('settings.page.about.title');
case 'home':
default:
return t('settings.view.home.title');
}
}, [t]);
const settingsSearchResults = React.useMemo(() => {
return buildSettingsSearchResults({
query: settingsSearchQuery,
runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows, isLinux, isWindowsArm64 },
visiblePageSlugs,
t,
getPageTitle,
});
}, [getPageTitle, isWindowsArm64, isDesktopLocalOrigin, isMac, isWindows, isLinux, 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">
<div className="max-w-md text-center">
<div className={SETTINGS_SECTION_TITLE_CLASS}>{t('settings.view.unavailable.title')}</div>
<p className="typography-ui text-muted-foreground mt-1">{t('settings.view.unavailable.description')}</p>
</div>
</div>
);
}, [t]);
const renderPageSidebar = React.useCallback((slug: SettingsPageSlug, opts: { onItemSelect?: () => void }) => {
switch (slug) {
case 'projects':
return <ProjectsSidebar onItemSelect={opts.onItemSelect} />;
case 'agents':
return <AgentsSidebar onItemSelect={opts.onItemSelect} />;
case 'commands':
return <CommandsSidebar onItemSelect={opts.onItemSelect} />;
case 'mcp':
return <McpSidebar onItemSelect={opts.onItemSelect} />;
case 'plugins':
return <PluginsSidebar onItemSelect={opts.onItemSelect} />;
case 'skills.installed':
return <SkillsSidebar onItemSelect={opts.onItemSelect} />;
case 'providers':
return <ProvidersSidebar onItemSelect={opts.onItemSelect} />;
case 'usage':
return <UsageSidebar onItemSelect={opts.onItemSelect} />;
case 'magic-prompts':
return <MagicPromptsSidebar onItemSelect={opts.onItemSelect} />;
case 'snippets':
return <SnippetsSidebar onItemSelect={opts.onItemSelect} />;
default:
return null;
}
}, []);
const renderPageContent = React.useCallback((slug: SettingsPageSlug) => {
const meta = getSettingsPageMeta(slug);
if (meta && !isPageAvailable(meta, runtimeCtx)) {
return renderUnavailable();
}
switch (slug) {
case 'projects':
return <ProjectsPage />;
case 'remote-instances':
return <RemoteInstancesPage />;
case 'agents':
return <AgentsPage />;
case 'behavior':
return <BehaviorPage />;
case 'commands':
return <CommandsPage />;
case 'mcp':
return <McpPage />;
case 'plugins':
return <PluginsPage />;
case 'skills.installed':
return <SkillsPage view="installed" />;
case 'skills.catalog':
return <SkillsPage view="catalog" />;
case 'providers':
return <ProvidersPage />;
case 'usage':
return <UsagePage />;
case 'about':
return (
<SettingsPageLayout title={t('settings.page.about.title')} showSaveStatus={false}>
<AboutSettings />
</SettingsPageLayout>
);
case 'magic-prompts':
return <MagicPromptsPage />;
case 'snippets':
return <SnippetsPage />;
case 'git':
return <GitPage />;
case 'integrations':
return <IntegrationsPage />;
case 'general':
case 'appearance':
case 'chat':
case 'shortcuts':
case 'sessions':
case 'notifications':
case 'voice':
case 'tunnel': {
const section = openChamberSectionBySlug[slug] ?? 'visual';
return <OpenChamberPage section={section} />;
}
case 'home':
default:
return null;
}
}, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
React.useEffect(() => {
if (!isMobile) {
return;
}
if (mobileStage !== 'nav') {
return;
}
if (settingsSlug === 'home') {
return;
}
if (autoNavSlugRef.current === settingsSlug) {
return;
}
const def = getSettingsPageMeta(settingsSlug);
if (!def || def.slug === 'home') {
return;
}
autoNavSlugRef.current = settingsSlug;
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
}, [isMobile, mobileStage, settingsSlug]);
const showBackButton = isMobile && mobileStage !== 'nav';
// Split pages drill down on mobile: nav → the page's own list → the item.
// Back walks that path in reverse, so it takes one tap to reach the next
// item instead of a round trip through the settings root.
const backButtonTargetsPageSidebar = isMobile
&& mobileStage === 'page-content'
&& activePageMeta?.kind === 'split';
const mobileBackButtonLabel = backButtonTargetsPageSidebar
? t('settings.view.actions.back')
: showBackButton
? t('settings.view.actions.backToSettings')
: t('settings.view.actions.closeSettings');
const openSettingsCombo = getEffectiveShortcutCombo(
'open_settings',
openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride },
);
const closeSettingsTitle = openSettingsCombo
? t('settings.view.actions.closeSettingsWithShortcut', {
shortcut: formatShortcutForDisplay(openSettingsCombo),
})
: t('settings.view.actions.closeSettings');
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
return;
}
const currentDetail = getSettingsDetailHistoryEntry(window.history.state);
if (currentDetail?.page === slug && currentDetail.stage === 'page-content') {
return;
}
window.history.pushState(
{
...getCurrentHistoryState(),
[SETTINGS_DETAIL_HISTORY_KEY]: { page: slug, stage: 'page-content' },
},
'',
window.location.href,
);
}, [runtimeCtx.isVSCode]);
const handleMobilePageSidebarItemSelect = React.useCallback(() => {
shouldFocusMobilePageContentRef.current = true;
setMobileStage('page-content');
pushMobileSplitDetailHistory(settingsSlug);
}, [pushMobileSplitDetailHistory, settingsSlug]);
React.useEffect(() => {
if (!isMobile || mobileStage !== 'page-content' || !shouldFocusMobilePageContentRef.current) {
return;
}
shouldFocusMobilePageContentRef.current = false;
const frame = window.requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLElement>('[data-settings-page-heading]')
?.focus({ preventScroll: true });
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [isMobile, mobileStage, settingsSlug]);
const handleBack = React.useCallback(() => {
if (backButtonTargetsPageSidebar) {
const currentDetail = typeof window !== 'undefined'
? getSettingsDetailHistoryEntry(window.history.state)
: null;
if (currentDetail?.page === settingsSlug && !runtimeCtx.isVSCode) {
window.history.back();
return;
}
setMobileStage('page-sidebar');
return;
}
setMobileStage('nav');
}, [backButtonTargetsPageSidebar, runtimeCtx.isVSCode, settingsSlug]);
// The Android hardware back button belongs to the same ladder as the header's
// back arrow: one level up per press, and only the press at the root falls
// through to the shell, which closes Settings.
React.useEffect(() => {
if (!registerBackHandler) {
return;
}
registerBackHandler(() => {
if (!isMobile || mobileStage === 'nav') {
return false;
}
handleBack();
return true;
});
return () => registerBackHandler(null);
}, [handleBack, isMobile, mobileStage, registerBackHandler]);
React.useEffect(() => {
if (!isMobile || runtimeCtx.isVSCode) {
return;
}
const handlePopState = (event: PopStateEvent) => {
if (getSettingsPageMeta(settingsSlug)?.kind !== 'split') {
return;
}
const detail = getSettingsDetailHistoryEntry(event.state);
if (detail?.page === settingsSlug) {
setMobileStage('page-content');
return;
}
setMobileStage((stage) => stage === 'page-content' ? 'page-sidebar' : stage);
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [isMobile, runtimeCtx.isVSCode, settingsSlug]);
const renderSettingsNav = () => {
const hasSearchQuery = settingsSearchQuery.trim().length > 0;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-4 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 */}
<ScrollableOverlay outerClassName="flex-1 min-h-0" disableHorizontal>
<div className="flex flex-col gap-0.5 px-4 pt-4 pb-2">
{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 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>
)
) : (() => {
const pagesByGroup = new Map<string, typeof sortedFilteredPages>();
for (const page of sortedFilteredPages) {
const group = page.group;
const existing = pagesByGroup.get(group);
if (existing) {
existing.push(page);
} else {
pagesByGroup.set(group, [page]);
}
}
const visibleGroups = NAV_GROUP_ORDER
.map((group) => ({ group, pages: pagesByGroup.get(group) ?? [] }))
.filter((entry) => entry.pages.length > 0);
return visibleGroups.map(({ group, pages }, groupIndex) => (
<div key={group} className="space-y-0.5">
<div
className={cn(
'px-3 pb-1 typography-micro font-semibold uppercase tracking-wide text-muted-foreground sm:px-2 sm:pb-0.5',
groupIndex === 0 ? 'pt-1' : 'pt-4 sm:pt-3',
)}
>
{t(`settings.view.nav.group.${group}`)}
</div>
{pages.map((page) => {
// On the mobile nav STAGE nothing is "current" — the user is
// choosing, and settingsSlug only remembers the last visited
// page. Keeping it highlighted read as a stuck selection.
const selected = settingsSlug === page.slug && !(isMobile && mobileStage === 'nav');
const iconName = getSettingsNavIcon(page.slug);
if (!iconName && page.slug !== 'mcp') return null;
return (
<Tooltip key={page.slug}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPage(page.slug)}
aria-current={selected ? 'page' : undefined}
className={cn(
'flex h-11 w-full items-center gap-2.5 rounded-md px-3 overflow-hidden sm:h-8 sm:gap-2 sm:px-2',
selected
? 'bg-interactive-selection text-foreground'
: 'text-foreground hover:bg-interactive-hover'
)}
>
{page.slug === 'mcp'
? <McpIcon className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
{page.slug === 'tunnel' && (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
{t('settings.view.badge.beta')}
</span>
)}
</span>
</button>
</TooltipTrigger>
</Tooltip>
);
})}
</div>
));
})()}
</div>
</ScrollableOverlay>
{/* Footer */}
<div className="overflow-hidden transition-opacity duration-150 opacity-100">
<div className="border-t border-border bg-background px-4 py-1.5 space-y-0.5 sm:bg-sidebar">
{(!runtimeCtx.isVSCode || pendingRestartCount > 0) && (
<OpenCodeReloadFooterAction />
)}
</div>
</div>
</div>
);
};
const renderMobileStage = () => {
if (mobileStage === 'nav') {
return (
<div className="flex-1 min-h-0 overflow-hidden bg-background">
<div className="flex h-full min-h-0 flex-col">
<ErrorBoundary>{renderSettingsNav()}</ErrorBoundary>
</div>
</div>
);
}
if (!activePageMeta) {
return <div className="flex-1 bg-background" />;
}
if (mobileStage === 'page-sidebar') {
if (activePageMeta.kind !== 'split') {
// No sidebar available; fall back to direct content.
const fallback = renderPageContent(settingsSlug);
return (
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="bg-background" disableHorizontal>
<ErrorBoundary>{fallback}</ErrorBoundary>
</ScrollableOverlay>
);
}
return (
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="bg-background" disableHorizontal>
<ErrorBoundary>
{renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })}
</ErrorBoundary>
</ScrollableOverlay>
);
}
// page-content
const content = renderPageContent(settingsSlug);
return (
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="bg-background" disableHorizontal>
<ErrorBoundary>{content}</ErrorBoundary>
</ScrollableOverlay>
);
};
const renderDesktopContent = () => {
if (!activePageMeta || settingsSlug === 'home') {
return null;
}
if (activePageMeta.kind === 'split') {
return (
<div className="flex h-full min-h-0 overflow-hidden">
<div className={cn('border-r', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')} style={{ width: SETTINGS_SPLIT_SIDEBAR_WIDTH, minWidth: SETTINGS_SPLIT_SIDEBAR_WIDTH, borderColor: 'var(--interactive-border)' }}>
<ErrorBoundary>{renderPageSidebar(settingsSlug, {})}</ErrorBoundary>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="bg-background" disableHorizontal>
<ErrorBoundary>{renderPageContent(settingsSlug)}</ErrorBoundary>
</ScrollableOverlay>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full min-h-0" className="bg-background" disableHorizontal>
<ErrorBoundary>{renderPageContent(settingsSlug)}</ErrorBoundary>
</ScrollableOverlay>
);
};
return (
<div ref={containerRef} data-settings-view="true" className={cn('relative flex h-full min-h-0 flex-col overflow-hidden bg-background')}>
{isMobile ? (
<div
className={cn(
'flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3',
// The root nav list reads as a single quiet page — no divider and
// no back arrow (the X on the right is the only way out); subpages
// keep both.
mobileStage !== 'nav' && 'border-b',
'bg-background'
)}
style={mobileStage !== 'nav' ? { borderColor: 'var(--interactive-border)' } : undefined}
>
{showBackButton ? (
<button
type="button"
onClick={handleBack}
aria-label={mobileBackButtonLabel}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="arrow-left-s" className="h-5 w-5" />
</button>
) : null}
<div className="min-w-0 flex-1 px-2 typography-ui-label font-medium text-foreground truncate">
{mobileStage === 'nav'
? t('settings.view.home.title')
: (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
</div>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')}
title={closeSettingsTitle}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="close" className="h-5 w-5" />
</button>
)}
</div>
) : (
<>
{showBackButton && (
<div className={cn('absolute left-3 z-50', isWindowed ? 'top-2' : 'top-3')}>
<button
type="button"
onClick={handleBack}
aria-label={t('settings.view.actions.back')}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="arrow-left-s" className="h-5 w-5" />
</button>
</div>
)}
{onClose && (
<div className={cn('absolute right-0.5 z-50', isWindowed ? 'top-0.5' : 'top-1')}>
<button
type="button"
onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')}
title={closeSettingsTitle}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="close" className="h-5 w-5" />
</button>
</div>
)}
</>
)}
<div className="flex flex-1 min-h-0 overflow-hidden">
{isMobile ? (
renderMobileStage()
) : (
<>
<div
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden border-r',
isDesktopApp
? 'bg-sidebar'
: runtimeCtx.isVSCode
? 'bg-background'
: 'bg-sidebar',
)}
style={{
width: `${SETTINGS_NAV_WIDTH}px`,
minWidth: `${SETTINGS_NAV_WIDTH}px`,
borderColor: 'var(--interactive-border)',
}}
>
<ErrorBoundary>
{renderSettingsNav()}
</ErrorBoundary>
</div>
<div className="flex-1 overflow-hidden bg-background">
{renderDesktopContent()}
</div>
</>
)}
</div>
</div>
);
};