import React from 'react'; import { cn, getModifierLabel } from '@/lib/utils'; import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; import type { SidebarSection } from '@/constants/sidebar'; import { RiArrowDownSLine, RiArrowLeftSLine, RiCloseLine, RiFolderLine } from '@remixicon/react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useAgentsStore } from '@/stores/useAgentsStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; 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 { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar'; import { SkillsPage } from '@/components/sections/skills/SkillsPage'; import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar'; import { ProvidersPage } from '@/components/sections/providers/ProvidersPage'; import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar'; import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage'; import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage'; import { OpenChamberSidebar, type OpenChamberSection } from '@/components/sections/openchamber/OpenChamberSidebar'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; const getSettingsSections = (isVSCode: boolean) => { let filtered = SIDEBAR_SECTIONS.filter(section => section.id !== 'sessions'); // Hide Git Identities tab for VS Code if (isVSCode) { filtered = filtered.filter(section => section.id !== 'git-identities'); } const settingsSection = filtered.find(s => s.id === 'settings'); const otherSections = filtered.filter(s => s.id !== 'settings'); return settingsSection ? [settingsSection, ...otherSections] : filtered; }; // Same constraints as main sidebar const SETTINGS_SIDEBAR_MIN_WIDTH = 200; const SETTINGS_SIDEBAR_MAX_WIDTH = 500; const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 264; // Width threshold for hiding tab labels (show icons only) const TAB_LABELS_MIN_WIDTH = 940; interface SettingsViewProps { onClose?: () => void; /** Force mobile layout regardless of device detection */ forceMobile?: boolean; } export const SettingsView: React.FC = ({ onClose, forceMobile }) => { const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; const [activeTab, setActiveTab] = React.useState('settings'); const [selectedOpenChamberSection, setSelectedOpenChamberSection] = React.useState('visual'); // Mobile drill-down state: show page content instead of sidebar const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); const [sidebarWidth, setSidebarWidth] = React.useState(() => { // Use proportional width like main sidebar (20% of window) if (typeof window !== 'undefined') { return Math.min( SETTINGS_SIDEBAR_MAX_WIDTH, Math.max(SETTINGS_SIDEBAR_MIN_WIDTH, Math.floor(window.innerWidth * 0.2)) ); } return SETTINGS_SIDEBAR_DEFAULT_WIDTH; }); const [hasManuallyResized, setHasManuallyResized] = React.useState(false); const [isResizing, setIsResizing] = React.useState(false); const [containerWidth, setContainerWidth] = React.useState(0); const containerRef = React.useRef(null); const startXRef = React.useRef(0); const startWidthRef = React.useRef(sidebarWidth); const [isDesktopApp, setIsDesktopApp] = React.useState(() => { if (typeof window === 'undefined') return false; return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'; }); const isMacPlatform = React.useMemo(() => { if (typeof navigator === 'undefined') return false; return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const settingsSections = React.useMemo(() => getSettingsSections(isVSCode), [isVSCode]); React.useEffect(() => { if (typeof window === 'undefined') return; setIsDesktopApp(typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'); }, []); // Track container width for responsive tab labels React.useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setContainerWidth(entry.contentRect.width); } }); observer.observe(container); setContainerWidth(container.clientWidth); return () => observer.disconnect(); }, []); const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProject = useProjectsStore((state) => state.setActiveProject); const sortedProjects = React.useMemo(() => { return [...projects].sort((a, b) => (a.label || a.path).localeCompare(b.label || b.path)); }, [projects]); const activeProject = React.useMemo(() => { if (sortedProjects.length === 0) { return null; } return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0]; }, [activeProjectId, sortedProjects]); // Format project label: kebab-case/snake_case → Title Case const formatProjectLabel = React.useCallback((label: string): string => { return label .replace(/[-_]/g, ' ') .replace(/\b\w/g, (char) => char.toUpperCase()); }, []); const activeProjectLabel = React.useMemo(() => { if (!activeProject) { return 'Project'; } const rawLabel = activeProject.label && activeProject.label.trim().length > 0 ? activeProject.label : (activeProject.path.split('/').filter(Boolean).pop() || activeProject.path); return formatProjectLabel(rawLabel); }, [activeProject, formatProjectLabel]); const showProjectSwitcher = sortedProjects.length > 0 && !isVSCode; const showTabLabels = containerWidth === 0 || containerWidth >= TAB_LABELS_MIN_WIDTH; React.useEffect(() => { // Force reload when activeProject changes to ensure scopes update if (activeTab === 'agents') { // Small delay to allow store state to propagate if needed setTimeout(() => void useAgentsStore.getState().loadAgents(), 0); return; } if (activeTab === 'commands') { setTimeout(() => void useCommandsStore.getState().loadCommands(), 0); return; } if (activeTab === 'skills') { void useSkillsStore.getState().loadSkills(); void useSkillsCatalogStore.getState().loadCatalog(); } }, [activeProjectId, activeTab]); // Update proportional width on window resize (if not manually resized) React.useEffect(() => { if (typeof window === 'undefined') return; const handleResize = () => { if (!hasManuallyResized) { const proportionalWidth = Math.min( SETTINGS_SIDEBAR_MAX_WIDTH, Math.max(SETTINGS_SIDEBAR_MIN_WIDTH, Math.floor(window.innerWidth * 0.2)) ); setSidebarWidth(proportionalWidth); } }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [hasManuallyResized]); // Resize handling React.useEffect(() => { if (!isResizing) return; const handlePointerMove = (event: PointerEvent) => { const delta = event.clientX - startXRef.current; const nextWidth = Math.min( SETTINGS_SIDEBAR_MAX_WIDTH, Math.max(SETTINGS_SIDEBAR_MIN_WIDTH, startWidthRef.current + delta) ); setSidebarWidth(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 = sidebarWidth; event.preventDefault(); }; // Draggable header for Tauri desktop const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) return; if (e.button !== 0) return; if (isDesktopApp) { try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const window = getCurrentWindow(); await window.startDragging(); } catch (error) { console.error('Failed to start window dragging:', error); } } }, [isDesktopApp]); // Active tab can be dragged (like main header) const handleActiveTabDragStart = React.useCallback(async (e: React.MouseEvent) => { if (e.button !== 0) return; if (isDesktopApp) { try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const window = getCurrentWindow(); await window.startDragging(); } catch (error) { console.error('Failed to start window dragging:', error); } } }, [isDesktopApp]); const handleTabChange = React.useCallback((tab: SidebarSection) => { setActiveTab(tab); // Reset mobile drill-down state when changing tabs setShowMobilePageContent(false); }, []); // Handle mobile sidebar item selection (drill-down to page) const handleMobileSidebarClick = React.useCallback(() => { if (isMobile) { setShowMobilePageContent(true); } }, [isMobile]); const renderSidebarContent = () => { switch (activeTab) { case 'settings': return ( { setSelectedOpenChamberSection(section); handleMobileSidebarClick(); }} /> ); case 'agents': return ; case 'commands': return ; case 'skills': return ; case 'providers': return ; case 'git-identities': return ; default: return null; } }; const hasSidebar = true; // All tabs now have sidebars const renderPageContent = () => { switch (activeTab) { case 'agents': return ; case 'commands': return ; case 'skills': return ; case 'providers': return ; case 'git-identities': return ; case 'settings': return ; default: return null; } }; // Keyboard shortcut display based on platform const shortcutKey = getModifierLabel(); // Desktop padding for Mac titlebar area const desktopPaddingClass = React.useMemo(() => { if (isDesktopApp && isMacPlatform) { return 'pl-[5.75rem]'; // Space for traffic lights } return ''; }, [isDesktopApp, isMacPlatform]); const showLeadingDivider = isDesktopApp && isMacPlatform; return (
{/* Header with tabs and close button */}
{/* Mobile: back button when drilling down */} {isMobile && showMobilePageContent && ( )} {/* Mobile: spacer to push tabs to the right */} {isMobile &&
}
{/* Leading divider before first tab - only on Mac desktop */} {!isMobile && showLeadingDivider && {/* Content area */}
{/* Mobile: show sidebar OR page content based on drill-down state */} {isMobile ? ( showMobilePageContent ? (
{renderPageContent()}
) : (
{renderSidebarContent()}
) ) : ( /* Desktop: show both sidebar and page content side by side */ <> {hasSidebar && (
{/* Resize handle */}
{renderSidebarContent()}
)}
{renderPageContent()}
)}
); };