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, RiAiGenerate2, RiArrowLeftSLine, RiBarChart2Line, RiBookLine, RiBookOpenLine, RiChatAi3Line, RiChatHistoryLine, RiCloseLine, RiCommandLine, RiCloudLine, RiFoldersLine, RiGitBranchLine, RiGlobalLine, RiMicLine, RiListUnordered, RiNotification3Line, RiPaletteLine, RiRobot2Line, RiRestartLine, RiServerLine, RiSlashCommands2, RiBrainLine, } 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 { 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 { GitPage } from '@/components/sections/git-identities/GitPage'; import type { OpenChamberSection } from '@/components/sections/openchamber/types'; import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage'; import { McpIcon } from '@/components/icons/McpIcon'; import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; 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; 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'; 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', 'projects', 'remote-instances', 'agents', 'behavior', 'commands', 'mcp', 'providers', 'usage', 'skills.installed', 'skills.catalog', 'voice', 'tunnel', ]; 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); } // eslint-disable-next-line react-refresh/only-export-components export 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 'magic-prompts': return RiAiGenerate2; case 'notifications': return RiNotification3Line; case 'shortcuts': return RiCommandLine; case 'sessions': return RiChatHistoryLine; case 'providers': return RiCloudLine; case 'agents': return RiAiAgentLine; case 'behavior': return RiBrainLine; 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 'voice': return RiMicLine; case 'tunnel': return RiGlobalLine; case 'home': return null; default: return RiRobot2Line; } } 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) { 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(); } }, [activeProjectId, isSettingsDialogOpen, 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 '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 ; 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 '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 shortcutKey = getModifierLabel(); const handleBack = React.useCallback(() => { setMobileStage('nav'); }, []); const handleOpenPageSidebar = React.useCallback(() => { setMobileStage('page-sidebar'); }, []); const renderSettingsNav = () => { return (
{/* Scrollable nav items */}
{sortedFilteredPages.map((page) => { const selected = settingsSlug === page.slug; const Icon = getSettingsNavIcon(page.slug); if (!Icon) 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: () => 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' ? t('settings.view.home.title') : (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
{mobileStage === 'page-content' && activePageMeta?.kind === 'split' && ( )} {onClose && ( )}
) : ( <> {showBackButton && (
)} {onClose && (
)} )}
{isMobile ? ( renderMobileStage() ) : ( <>
{renderSettingsNav()}
{renderDesktopContent()}
)}
); };