import React from 'react'; import { cn, getModifierLabel } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; 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 { 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 { RemoteInstancesSidebar } from '@/components/sections/remote-instances/RemoteInstancesSidebar'; 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 type { OpenChamberSection } from '@/components/sections/openchamber/types'; import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage'; import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { SETTINGS_PAGE_METADATA, getSettingsPageMeta, resolveSettingsSlug, type SettingsPageSlug, type SettingsRuntimeContext, type SettingsPageMeta, } from '@/lib/settings/metadata'; // Same constraints as main sidebar const SETTINGS_NAV_MIN_WIDTH = 176; const SETTINGS_NAV_MAX_WIDTH = 280; const SETTINGS_NAV_RESIZE_STEP = 8; const SETTINGS_DETAIL_HISTORY_KEY = '__openchamberSettingsDetail'; function clampSettingsNavWidth(width: number): number { return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, width)); } 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; } const pageOrder: SettingsPageSlug[] = [ 'appearance', 'chat', 'notifications', 'sessions', 'shortcuts', 'git', 'magic-prompts', 'snippets', 'projects', 'remote-instances', 'agents', 'behavior', 'commands', 'mcp', 'providers', 'usage', 'skills.installed', 'skills.catalog', 'voice', 'tunnel', ]; const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const; function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext { const isVSCode = isVSCodeRuntime(); const isWeb = !isDesktop && isWebRuntime(); return { isVSCode, isWeb, isDesktop }; } function isPageAvailable(page: SettingsPageMeta, ctx: SettingsRuntimeContext): boolean { if (!page.isAvailable) { return true; } return page.isAvailable(ctx); } function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } 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 { if (typeof window === 'undefined' || !isObjectRecord(window.history.state)) { return {}; } return window.history.state; } // eslint-disable-next-line react-refresh/only-export-components export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null { switch (slug) { case 'projects': return 'folders'; case 'remote-instances': return 'server'; case 'appearance': return 'palette'; case 'chat': return 'chat-ai-3'; case 'magic-prompts': return 'ai-generate-2'; case 'snippets': return SNIPPETS_SETTINGS_ICON.icon; case 'notifications': return 'notification-3'; case 'shortcuts': return 'command'; case 'sessions': return 'chat-history'; case 'providers': return 'cloud'; case 'agents': return 'ai-agent'; case 'behavior': return 'brain'; case 'commands': return 'slash-commands-2'; case 'mcp': return 'plug-2'; case 'skills.installed': return 'book-open'; case 'skills.catalog': return 'book'; case 'git': return 'git-branch'; case 'usage': return 'bar-chart-2'; case 'voice': return 'mic'; case 'tunnel': return 'global'; case 'home': return null; default: return 'robot-2'; } } const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({ onOpen }) => { const { t } = useI18n(); return (

{t('settings.view.home.title')}

{t('settings.view.home.description')}

); }; export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed }) => { const { t } = useI18n(); const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; const settingsPageRaw = useUIStore((state) => state.settingsPage); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const settingsSlug = resolveSettingsSlug(settingsPageRaw); const [mobileStage, setMobileStage] = React.useState('nav'); const autoNavSlugRef = React.useRef(null); const [navWidth, setNavWidth] = React.useState(216); 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(null); const isDesktopApp = React.useMemo(() => { return isDesktopShell(); }, []); // keep platform check available for future window chrome tweaks const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp), [isDesktopApp]); const visiblePages = React.useMemo(() => { return SETTINGS_PAGE_METADATA .filter((page) => page.slug !== 'home') .filter((page) => isPageAvailable(page, runtimeCtx)) .filter((page) => !(runtimeCtx.isVSCode && page.slug === 'projects')) .filter((page) => !(isMobile && page.slug === 'shortcuts')); }, [runtimeCtx, isMobile]); const sortedFilteredPages = React.useMemo(() => { const rank = new Map(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); React.useEffect(() => { if (typeof window === 'undefined') return; const handleResize = () => { if (!hasManuallyResized) { const proportionalWidth = clampSettingsNavWidth(Math.floor(window.innerWidth * 0.12)); setNavWidth(proportionalWidth); } }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [hasManuallyResized]); React.useEffect(() => { if (!isResizing) return; const handlePointerMove = (event: PointerEvent) => { const delta = event.clientX - startXRef.current; const nextWidth = clampSettingsNavWidth(startWidthRef.current + delta); setNavWidth(nextWidth); setHasManuallyResized(true); }; const handlePointerUp = () => setIsResizing(false); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp, { once: true }); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); }; }, [isResizing]); const handlePointerDown = (event: React.PointerEvent) => { setIsResizing(true); startXRef.current = event.clientX; startWidthRef.current = navWidth; event.preventDefault(); }; const handleResizeKeyDown = (event: React.KeyboardEvent) => { const step = event.shiftKey ? SETTINGS_NAV_RESIZE_STEP * 4 : SETTINGS_NAV_RESIZE_STEP; let nextWidth: number; switch (event.key) { case 'ArrowLeft': nextWidth = navWidth - step; break; case 'ArrowRight': nextWidth = navWidth + step; break; case 'Home': nextWidth = SETTINGS_NAV_MIN_WIDTH; break; case 'End': nextWidth = SETTINGS_NAV_MAX_WIDTH; break; default: return; } event.preventDefault(); setNavWidth(clampSettingsNavWidth(nextWidth)); setHasManuallyResized(true); }; // Load stores when project changes or when a page becomes active. React.useEffect(() => { if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) { return; } if (settingsSlug === 'agents') { void useAgentsStore.getState().loadAgents(); return; } if (settingsSlug === 'commands') { void useCommandsStore.getState().loadCommands(); return; } if (settingsSlug === 'mcp') { void useMcpConfigStore.getState().loadMcpConfigs(); return; } if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') { void useSkillsStore.getState().loadSkills(); void useSkillsCatalogStore.getState().loadCatalog(); } if (settingsSlug === 'snippets') { void useSnippetsStore.getState().loadSnippets(); } }, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, 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> = React.useMemo(() => ({ appearance: 'visual', chat: 'chat', shortcuts: 'shortcuts', sessions: 'sessions', notifications: 'notifications', voice: 'voice', tunnel: 'tunnel', }), []); const getPageTitle = React.useCallback((slug: SettingsPageSlug): string => { switch (slug) { 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 '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 '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 'home': default: return t('settings.view.home.title'); } }, [t]); const renderUnavailable = React.useCallback(() => { return (
{t('settings.view.unavailable.title')}

{t('settings.view.unavailable.description')}

); }, [t]); const renderPageSidebar = React.useCallback((slug: SettingsPageSlug, opts: { onItemSelect?: () => void }) => { switch (slug) { case 'projects': return ; case 'remote-instances': return ; case 'agents': return ; case 'commands': return ; case 'mcp': return ; case 'skills.installed': return ; case 'providers': return ; case 'usage': return ; case 'magic-prompts': return ; case 'snippets': return ; default: return null; } }, []); const renderPageContent = React.useCallback((slug: SettingsPageSlug) => { const meta = getSettingsPageMeta(slug); if (meta && !isPageAvailable(meta, runtimeCtx)) { return renderUnavailable(); } switch (slug) { case 'home': return ; case 'projects': return ; case 'remote-instances': return ; case 'agents': return ; case 'behavior': return ; case 'commands': return ; case 'mcp': return ; case 'skills.installed': return ; case 'skills.catalog': return ; case 'providers': return ; case 'usage': return ; case 'magic-prompts': return ; case 'snippets': return ; case 'git': return ; case 'appearance': case 'chat': case 'shortcuts': case 'sessions': case 'notifications': case 'voice': case 'tunnel': { const section = openChamberSectionBySlug[slug] ?? 'visual'; return ; } default: return ; } }, [openChamberSectionBySlug, openPage, renderUnavailable, runtimeCtx]); // 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'; const backButtonTargetsPageSidebar = isMobile && mobileStage === 'page-content' && settingsSlug === 'skills.installed'; const showOpenPageSidebarButton = mobileStage === 'page-content' && activePageMeta?.kind === 'split' && !backButtonTargetsPageSidebar; const mobileBackButtonLabel = backButtonTargetsPageSidebar ? t('settings.view.actions.back') : showBackButton ? t('settings.view.actions.backToSettings') : t('settings.view.actions.closeSettings'); const shortcutKey = getModifierLabel(); 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(() => { setMobileStage('page-content'); if (settingsSlug === 'skills.installed') { pushMobileSplitDetailHistory(settingsSlug); } }, [pushMobileSplitDetailHistory, 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]); React.useEffect(() => { if (!isMobile || runtimeCtx.isVSCode) { return; } const handlePopState = (event: PopStateEvent) => { if (settingsSlug !== 'skills.installed') { return; } const detail = getSettingsDetailHistoryEntry(event.state); if (detail?.page === 'skills.installed') { 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 handleOpenPageSidebar = React.useCallback(() => { setMobileStage('page-sidebar'); }, []); const renderSettingsNav = () => { return (
{/* Scrollable nav items */}
{sortedFilteredPages.map((page) => { const selected = settingsSlug === page.slug; const iconName = getSettingsNavIcon(page.slug); if (!iconName) return null; return ( ); })}
{/* Footer */}
{!runtimeCtx.isVSCode && ( {t('settings.view.actions.reloadOpenCodeTooltip')} )}
); }; const renderMobileStage = () => { if (mobileStage === 'nav') { return (
{renderSettingsNav()}
); } if (!activePageMeta) { return
; } if (mobileStage === 'page-sidebar') { if (activePageMeta.kind !== 'split') { // No sidebar available; fall back to direct content. const fallback = renderPageContent(settingsSlug); return (
{fallback}
); } return (
{renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })}
); } // page-content const content = renderPageContent(settingsSlug); return (
{content}
); }; const renderDesktopContent = () => { if (!activePageMeta || settingsSlug === 'home') { return ; } if (activePageMeta.kind === 'split') { return (
{renderPageSidebar(settingsSlug, {})}
{renderPageContent(settingsSlug)}
); } return (
{renderPageContent(settingsSlug)}
); }; return (
{isMobile ? (
{mobileStage === 'nav' ? t('settings.view.home.title') : (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
{showOpenPageSidebarButton && ( )} {onClose && ( )}
) : ( <> {showBackButton && (
)} {onClose && (
)} )}
{isMobile ? ( renderMobileStage() ) : ( <>
{renderSettingsNav()}
{renderDesktopContent()}
)}
); };