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 { useSkillsStore } from '@/stores/useSkillsStore'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { RiAiAgentLine, RiArrowLeftSLine, RiBarChart2Line, RiBookLine, RiBookOpenLine, RiChatAi3Line, RiChatHistoryLine, RiCloseLine, RiCommandLine, RiCloudLine, RiFoldersLine, RiGitBranchLine, RiNotification3Line, RiPaletteLine, RiListUnordered, RiRobot2Line, RiRestartLine, RiServerLine, RiSlashCommands2, } from '@remixicon/react'; 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 { 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 { GitPage } from '@/components/sections/git-identities/GitPage'; import type { OpenChamberSection } from '@/components/sections/openchamber/types'; import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage'; import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; import { McpIcon } from '@/components/icons/McpIcon'; import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; 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_RAIL_WIDTH = 48; type MobileStage = 'nav' | 'page-sidebar' | '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', 'projects', 'remote-instances', 'agents', 'commands', 'mcp', 'providers', 'usage', 'skills.installed', 'skills.catalog', ]; 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 getSettingsNavIcon(slug: SettingsPageSlug): React.ComponentType<{ className?: string }> | null { switch (slug) { case 'projects': return RiFoldersLine; case 'remote-instances': return RiServerLine; case 'appearance': return RiPaletteLine; case 'chat': return RiChatAi3Line; case 'notifications': return RiNotification3Line; case 'shortcuts': return RiCommandLine; case 'sessions': return RiChatHistoryLine; case 'providers': return RiCloudLine; case 'agents': return RiAiAgentLine; case 'commands': return RiSlashCommands2; case 'mcp': return McpIcon; case 'skills.installed': return RiBookOpenLine; case 'skills.catalog': return RiBookLine; case 'git': return RiGitBranchLine; case 'usage': return RiBarChart2Line; case 'home': return null; default: return RiRobot2Line; } } const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({ onOpen }) => { return (

Settings

Jump to common pages.

); }; export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed }) => { const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; const settingsPageRaw = useUIStore((state) => state.settingsPage); 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 = Math.min( SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, 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 = Math.min( SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, 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(); }; // Load stores when project changes or when a page becomes active. React.useEffect(() => { if (settingsSlug === 'agents') { setTimeout(() => void useAgentsStore.getState().loadAgents(), 0); return; } if (settingsSlug === 'commands') { setTimeout(() => void useCommandsStore.getState().loadCommands(), 0); return; } if (settingsSlug === 'mcp') { setTimeout(() => void useMcpConfigStore.getState().loadMcpConfigs(), 0); return; } if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') { setTimeout(() => { void useSkillsStore.getState().loadSkills(); void useSkillsCatalogStore.getState().loadCatalog(); }, 0); } }, [activeProjectId, 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]); // Collapse main nav to icon rail when active page has its own sidebar const isNavCollapsed = !isMobile && activePageMeta?.kind === 'split'; const openChamberSectionBySlug: Partial> = React.useMemo(() => ({ appearance: 'visual', chat: 'chat', shortcuts: 'shortcuts', sessions: 'sessions', notifications: 'notifications', }), []); const renderUnavailable = React.useCallback(() => { return (
Not available

This settings page is not available in this runtime.

); }, []); 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 ; 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 'commands': return ; case 'mcp': return ; case 'skills.installed': return ; case 'skills.catalog': return ; case 'providers': return ; case 'usage': return ; case 'git': return ; case 'appearance': case 'chat': case 'shortcuts': case 'sessions': case 'notifications': { 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 shortcutKey = getModifierLabel(); const handleBack = React.useCallback(() => { setMobileStage('nav'); }, []); const handleOpenPageSidebar = React.useCallback(() => { setMobileStage('page-sidebar'); }, []); const renderSettingsNav = (collapsed: boolean) => { return (
{/* Scrollable nav items */}
{sortedFilteredPages.map((page) => { const selected = settingsSlug === page.slug; const Icon = getSettingsNavIcon(page.slug); if (!Icon) return null; return ( {collapsed && ( {page.title} )} ); })}
{/* Footer — hidden when collapsed via overflow on parent */}
{!runtimeCtx.isVSCode && ( Restart OpenCode and reload its configuration. )} {isMobile && runtimeCtx.isWeb && (
)}
); }; const renderMobileStage = () => { if (mobileStage === 'nav') { return (
{renderSettingsNav(false)}
); } 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: () => setMobileStage('page-content') })}
); } // 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' ? 'Settings' : (activePageMeta?.title ?? 'Settings')}
{mobileStage === 'page-content' && activePageMeta?.kind === 'split' && ( )} {onClose && ( )}
) : ( <> {showBackButton && (
)} {onClose && (
)} )}
{isMobile ? ( renderMobileStage() ) : ( <>
{!isNavCollapsed && (
)} {renderSettingsNav(isNavCollapsed)}
{renderDesktopContent()}
)}
); };