From 0fefc1b301c6b467fba4a75f8400790c7a2fc8a5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Dec 2025 02:17:09 +0200 Subject: [PATCH] feat: refactor settings components and sidebar layout --- AGENTS.md | 25 +- CHANGELOG.md | 6 +- .../ui/src/components/layout/MainLayout.tsx | 67 ++- .../src/components/layout/SettingsDialog.tsx | 199 +++------ .../ui/src/components/layout/VSCodeLayout.tsx | 4 +- .../sections/agents/AgentsSidebar.tsx | 89 ++-- .../sections/commands/CommandsSidebar.tsx | 178 ++++---- .../git-identities/GitIdentitiesSidebar.tsx | 150 +++---- .../sections/openchamber/AboutSettings.tsx | 237 +++++++++++ .../DefaultsSettings.tsx | 0 .../sections/openchamber/OpenChamberPage.tsx | 87 ++++ .../openchamber/OpenChamberSidebar.tsx | 93 +++++ .../OpenChamberVisualSettings.tsx} | 228 ++++++----- .../SessionRetentionSettings.tsx | 0 .../sections/providers/ProvidersSidebar.tsx | 98 +++-- .../sections/settings/AboutSettings.tsx | 120 ------ .../sections/settings/SettingsPage.tsx | 33 -- .../sections/shared/SettingsPageLayout.tsx | 44 ++ .../sections/shared/SettingsSection.tsx | 62 +++ .../sections/shared/SettingsSidebarHeader.tsx | 63 +++ .../sections/shared/SettingsSidebarItem.tsx | 134 ++++++ .../sections/shared/SettingsSidebarLayout.tsx | 65 +++ .../src/components/sections/shared/index.ts | 56 +++ .../ui/src/components/views/SettingsView.tsx | 386 ++++++++++++++++++ packages/ui/src/components/views/index.ts | 1 + packages/ui/src/constants/sidebar.ts | 8 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 30 +- 27 files changed, 1788 insertions(+), 675 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/AboutSettings.tsx rename packages/ui/src/components/sections/{settings => openchamber}/DefaultsSettings.tsx (100%) create mode 100644 packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx create mode 100644 packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx rename packages/ui/src/components/sections/{settings/AppearanceSettings.tsx => openchamber/OpenChamberVisualSettings.tsx} (52%) rename packages/ui/src/components/sections/{settings => openchamber}/SessionRetentionSettings.tsx (100%) delete mode 100644 packages/ui/src/components/sections/settings/AboutSettings.tsx delete mode 100644 packages/ui/src/components/sections/settings/SettingsPage.tsx create mode 100644 packages/ui/src/components/sections/shared/SettingsPageLayout.tsx create mode 100644 packages/ui/src/components/sections/shared/SettingsSection.tsx create mode 100644 packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx create mode 100644 packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx create mode 100644 packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx create mode 100644 packages/ui/src/components/sections/shared/index.ts create mode 100644 packages/ui/src/components/views/SettingsView.tsx diff --git a/AGENTS.md b/AGENTS.md index f7607a62..23f61f2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,11 +28,12 @@ Workspaces: ### Core Components (UI) In `packages/ui/src/components/chat/`: ChatContainer, ChatEmptyState, ChatErrorBoundary, ChatInput, ChatMessage, FileAttachment, MarkdownRenderer, MessageList, ModelControls, PermissionCard, PermissionRequest, ServerFilePicker, StreamingTextDiff, AgentMentionAutocomplete, CommandAutocomplete, FileMentionAutocomplete. In `packages/ui/src/components/chat/message/`: MessageBody, MessageHeader, ToolOutputDialog, DiffViewToggle, FadeInOnReveal; parts/ (AssistantTextPart, ReasoningPart, ToolPart, UserTextPart, etc.) -In `packages/ui/src/components/layout/`: MainLayout, Header, Sidebar, SidebarContextSummary, SettingsDialog, VSCodeLayout. -In `packages/ui/src/components/sections/`: AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SettingsPage (with subsections for agents/, commands/, git-identities/, providers/, settings/). +In `packages/ui/src/components/layout/`: MainLayout, Header, Sidebar, SidebarContextSummary, VSCodeLayout. +In `packages/ui/src/components/views/`: ChatView, GitView, DiffView, TerminalView, SettingsView. +In `packages/ui/src/components/sections/`: subsections agents/, commands/, git-identities/, openchamber/, providers/, shared/. In `packages/ui/src/components/session/`: DirectoryTree, DirectoryExplorerDialog, SessionDialogs, SessionSidebar. In `packages/ui/src/components/ui/`: CommandPalette, HelpDialog, ConfigUpdateOverlay, ContextUsageDisplay, ErrorBoundary, MemoryDebugPanel, MobileOverlayPanel, FireworksAnimation, OpenChamberLogo, OpenCodeIcon, ProviderLogo, ScrollShadow, OverlayScrollbar, plus Radix-based primitives (button, dialog, input, select, etc.) -In `packages/ui/src/components/views/`: ChatView, GitView, DiffView, PierreDiffViewer, TerminalView. + In `packages/ui/src/components/terminal/`: TerminalViewport In `packages/ui/src/components/onboarding/`: OnboardingScreen In `packages/ui/src/components/providers/`: ThemeProvider @@ -78,8 +79,19 @@ bun run vscode:build # Build VS Code extension ## Key Patterns -### Section-Based Navigation -Modular section architecture with dedicated pages and sidebars. Sections: Agents, Commands, Git Identities, Providers, Sessions, Settings. Independent state management and routing. +### Settings View Architecture +Full-screen settings view (`SettingsView.tsx`) with tabbed navigation. Desktop: sidebar + page side-by-side with resizable sidebar. Mobile: drill-down pattern (sidebar → page with back button). Tabs: OpenChamber, Agents, Commands, Providers, Git Identities. + +Each settings section has a sidebar (`*Sidebar.tsx`) and page (`*Page.tsx`) in `packages/ui/src/components/sections/`. + +**Shared boilerplate components** in `packages/ui/src/components/sections/shared/`: +- `SettingsSidebarLayout` - wrapper with bg, scroll, header/footer slots +- `SettingsSidebarHeader` - "Total X" + add button +- `SettingsSidebarItem` - list item with title, metadata, selection, optional actions dropdown +- `SettingsPageLayout` - centered max-w-3xl scrollable container +- `SettingsSection` - section with optional title, description, divider + +Use these as reference when adding new settings sections. See `index.ts` for usage examples. ### File Attachments Drag-and-drop upload with 10MB limit (`FileAttachment.tsx`), Data URL encoding, type validation with fallbacks, integrated via `useFileStore.addAttachedFile()`. @@ -129,7 +141,8 @@ Backend: `packages/web/server/index.js` with `listLocalDirectory()`, `getFilesys `SessionSwitcherDialog.tsx`: Collapsible date groups, mobile parity with MobileOverlayPanel, Git worktree and shared session chips, streaming indicators. ### Settings & Configuration -`packages/ui/src/components/sections/`: AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SessionsPage, SettingsPage +`packages/ui/src/components/sections/`: AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SessionsPage, OpenChamberPage +`packages/ui/src/components/sections/shared/`: Boilerplate components for new sections Related stores: useAgentsStore, useCommandsStore, useConfigStore, useGitIdentitiesStore ### Git Operations diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f01df87..5305ad17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Optimized session activity status handling. +- Redesigned Settings as a full-screen view with tabbed navigation. +- Added mobile-friendly drill-down navigation for settings. +- ESC key now closes settings; double-ESC abort only works on chat tab without overlays. +- Added responsive tab labels in settings header (icons only at narrow widths). +- Improved session activity status handling and message step completion logic. ## [1.3.6] - 2025-12-27 diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index f5f01b07..7e06faaf 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Header, FixedSessionsButton } from './Header'; import { Sidebar } from './Sidebar'; -import { SettingsDialog } from './SettingsDialog'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { CommandPalette } from '../ui/CommandPalette'; import { HelpDialog } from '../ui/HelpDialog'; @@ -16,7 +15,7 @@ import { useDeviceInfo } from '@/lib/device'; import { useEdgeSwipe } from '@/hooks/useEdgeSwipe'; import { cn } from '@/lib/utils'; -import { ChatView, GitView, DiffView, TerminalView } from '@/components/views'; +import { ChatView, GitView, DiffView, TerminalView, SettingsView } from '@/components/views'; export const MainLayout: React.FC = () => { const { @@ -218,6 +217,7 @@ export const MainLayout: React.FC = () => { }, [activeMainTab]); const isChatActive = activeMainTab === 'chat'; + const isSettingsActive = isSettingsDialogOpen && !isMobile; return ( @@ -231,13 +231,16 @@ export const MainLayout: React.FC = () => { - setSettingsDialogOpen(false)} /> {isMobile ? ( <> -
+ {/* Mobile: Header + content + settings overlay */} + {!isSettingsDialogOpen &&
}
@@ -259,33 +262,53 @@ export const MainLayout: React.FC = () => { > + + {/* Mobile settings: full screen */} + {isSettingsDialogOpen && ( +
+ setSettingsDialogOpen(false)} /> +
+ )} ) : ( <> - - - + {!isSettingsActive && ( + + + + )} -
-
- -
-
-
- -
- {secondaryView && ( -
- {secondaryView} + {/* Main content area */} +
+ {/* Normal view: Header + content */} +
+
+
+
+
+
- )} -
+ {secondaryView && ( +
+ {secondaryView} +
+ )} +
+
+ + {/* Settings view: full screen overlay */} + {isSettingsActive && ( +
+ setSettingsDialogOpen(false)} /> +
+ )} )} - + {/* Hide fixed sessions button when settings is open */} + {!isSettingsActive && } ); diff --git a/packages/ui/src/components/layout/SettingsDialog.tsx b/packages/ui/src/components/layout/SettingsDialog.tsx index 223ec644..cd6e73ee 100644 --- a/packages/ui/src/components/layout/SettingsDialog.tsx +++ b/packages/ui/src/components/layout/SettingsDialog.tsx @@ -1,11 +1,4 @@ import React from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; import type { SidebarSection } from '@/constants/sidebar'; @@ -19,10 +12,8 @@ import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSideb import { ProvidersPage } from '@/components/sections/providers/ProvidersPage'; import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar'; import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage'; -import { SettingsPage } from '@/components/sections/settings/SettingsPage'; -import { useDeviceInfo } from '@/lib/device'; +import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; -import { SIDEBAR_CONTENT_WIDTH } from '@/components/layout/Sidebar'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; interface SettingsDialogProps { @@ -37,10 +28,13 @@ const SETTINGS_SECTIONS = (() => { return settingsSection ? [settingsSection, ...otherSections] : filtered; })(); +/** + * Mobile-only settings dialog using MobileOverlayPanel. + * Desktop uses SettingsView rendered inline in MainLayout. + */ export const SettingsDialog: React.FC = ({ isOpen, onClose }) => { const [activeTab, setActiveTab] = React.useState('settings'); const [showPageContent, setShowPageContent] = React.useState(false); - const { isMobile } = useDeviceInfo(); React.useEffect(() => { if (isOpen) { @@ -51,19 +45,14 @@ export const SettingsDialog: React.FC = ({ isOpen, onClose const handleTabChange = React.useCallback((tab: SidebarSection) => { setActiveTab(tab); - if (isMobile) { - setShowPageContent(false); - } - }, [isMobile]); + setShowPageContent(false); + }, []); const handleItemSelect = React.useCallback(() => { - if (isMobile) { - setShowPageContent(true); - } - }, [isMobile]); + setShowPageContent(true); + }, []); const renderSidebarContent = () => { - if (activeTab === 'settings') { return null; } @@ -73,21 +62,17 @@ export const SettingsDialog: React.FC = ({ isOpen, onClose case 'agents': return ; case 'commands': - return ; - case 'providers': - return ; - case 'git-identities': - return ; - default: - return null; - } + return ; + case 'providers': + return ; + case 'git-identities': + return ; + default: + return null; + } })(); - if (isMobile) { - return
{content}
; - } - - return content; + return
{content}
; }; const renderPageContent = () => { @@ -97,50 +82,19 @@ export const SettingsDialog: React.FC = ({ isOpen, onClose case 'commands': return ; case 'providers': - return ; - case 'git-identities': - return ; - case 'settings': - return ; + return ; + case 'git-identities': + return ; + case 'settings': + return ; default: return null; } }; - const activeSection = SETTINGS_SECTIONS.find(s => s.id === activeTab); - const useMobileOverlay = isMobile; - - const headerContent = ( - -
- {isMobile && showPageContent && ( - - )} - Settings -
- {activeSection && ( - - {activeSection.description} - - )} -
- ); - const mainContent = ( -
+
+ {/* Tab bar */}
{SETTINGS_SECTIONS.map(({ id, label, icon: Icon }) => { const isActive = activeTab === id; @@ -157,35 +111,22 @@ export const SettingsDialog: React.FC = ({ isOpen, onClose aria-pressed={isActive} aria-label={label} > - - {label} + ); })}
+ {/* Content area - mobile drill-down pattern */}
- {activeTab !== 'settings' && (!isMobile || !showPageContent) && ( -
+ {activeTab !== 'settings' && !showPageContent && ( +
{renderSidebarContent()}
)} - {(activeTab === 'settings' || !isMobile || showPageContent) && ( -
+ {(activeTab === 'settings' || showPageContent) && ( +
{renderPageContent()}
)} @@ -193,56 +134,34 @@ export const SettingsDialog: React.FC = ({ isOpen, onClose
); - const panelContent = ( -
- {!useMobileOverlay && headerContent} - {mainContent} -
- ); - - if (useMobileOverlay) { - return ( - ( -
-
- {showPageContent && ( - - )} - Settings -
- {closeButton} -
- )} - > - {mainContent} -
- ); - } - return ( - !open && onClose()}> - - {panelContent} - - + ( +
+
+ {showPageContent && ( + + )} + Settings +
+ {closeButton} +
+ )} + > + {mainContent} +
); }; diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 83085a16..215ab2ba 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -6,7 +6,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react'; -import { SettingsPage } from '@/components/sections/settings/SettingsPage'; +import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage'; type VSCodeView = 'sessions' | 'chat' | 'settings'; @@ -229,7 +229,7 @@ const VSCodeSettingsView: React.FC = () => {
{/* Content */}
- +
); diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index b144b89e..1c44480f 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -26,7 +26,11 @@ import { cn } from '@/lib/utils'; import type { Agent } from '@opencode-ai/sdk'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -export const AgentsSidebar: React.FC = () => { +interface AgentsSidebarProps { + onItemSelect?: () => void; +} + +export const AgentsSidebar: React.FC = ({ onItemSelect }) => { const [newAgentName, setNewAgentName] = React.useState(''); const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); @@ -41,6 +45,16 @@ export const AgentsSidebar: React.FC = () => { const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); + const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { + if (typeof window === 'undefined') return false; + return typeof window.opencodeDesktop !== 'undefined'; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); + }, []); + React.useEffect(() => { loadAgents(); }, [loadAgents]); @@ -118,19 +132,16 @@ export const AgentsSidebar: React.FC = () => { const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent)); return ( -
+
-

Agents

-
- {visibleAgents.length} - - - -
+ Total {visibleAgents.length} + + +
@@ -155,6 +166,7 @@ export const AgentsSidebar: React.FC = () => { isSelected={selectedAgentName === agent.name} onSelect={() => { setSelectedAgent(agent.name); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } @@ -178,6 +190,7 @@ export const AgentsSidebar: React.FC = () => { isSelected={selectedAgentName === agent.name} onSelect={() => { setSelectedAgent(agent.name); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } @@ -247,38 +260,33 @@ const AgentListItem: React.FC = ({ getAgentModeIcon, }) => { return ( -
-
-
- - {} - {agent.description && ( -
- {agent.description} -
- )} - - - +
); diff --git a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx index 470a40f9..bc8045fd 100644 --- a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx +++ b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx @@ -25,7 +25,11 @@ import { useDeviceInfo } from '@/lib/device'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { cn } from '@/lib/utils'; -export const CommandsSidebar: React.FC = () => { +interface CommandsSidebarProps { + onItemSelect?: () => void; +} + +export const CommandsSidebar: React.FC = ({ onItemSelect }) => { const [newCommandName, setNewCommandName] = React.useState(''); const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); @@ -40,6 +44,16 @@ export const CommandsSidebar: React.FC = () => { const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); + const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { + if (typeof window === 'undefined') return false; + return typeof window.opencodeDesktop !== 'undefined'; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); + }, []); + React.useEffect(() => { loadCommands(); }, [loadCommands]); @@ -96,19 +110,16 @@ export const CommandsSidebar: React.FC = () => { }; return ( -
+
-

Commands

-
- {commands.length} - - - -
+ Total {commands.length} + + +
@@ -121,21 +132,22 @@ export const CommandsSidebar: React.FC = () => {
) : ( <> - {[...commands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => ( - { - setSelectedCommand(command.name); - if (isMobile) { - setSidebarOpen(false); - } - }} - onDelete={() => handleDeleteCommand(command)} - onDuplicate={() => handleDuplicateCommand(command)} - /> - ))} + {[...commands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => ( + { + setSelectedCommand(command.name); + onItemSelect?.(); + if (isMobile) { + setSidebarOpen(false); + } + }} + onDelete={() => handleDeleteCommand(command)} + onDuplicate={() => handleDuplicateCommand(command)} + /> + ))} )} @@ -192,68 +204,64 @@ const CommandListItem: React.FC = ({ onDuplicate, }) => { return ( -
-
-
- - {} - {command.description && ( -
- {command.description} -
- )} - + + + + + + { + e.stopPropagation(); + onDuplicate(); + }} + > + + Duplicate + - - - - - - { - e.stopPropagation(); - onDuplicate(); - }} - > - - Duplicate - - - { - e.stopPropagation(); - onDelete(); - }} - className="text-destructive focus:text-destructive" - > - - Delete - - - -
+ { + e.stopPropagation(); + onDelete(); + }} + className="text-destructive focus:text-destructive" + > + + Delete + + +
); diff --git a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx index 88521b29..5a16fa58 100644 --- a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx +++ b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx @@ -42,7 +42,11 @@ const COLOR_MAP: Record = { type: 'var(--syntax-type)', }; -export const GitIdentitiesSidebar: React.FC = () => { +interface GitIdentitiesSidebarProps { + onItemSelect?: () => void; +} + +export const GitIdentitiesSidebar: React.FC = ({ onItemSelect }) => { const { selectedProfileId, profiles, @@ -56,6 +60,16 @@ export const GitIdentitiesSidebar: React.FC = () => { const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); + const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { + if (typeof window === 'undefined') return false; + return typeof window.opencodeDesktop !== 'undefined'; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); + }, []); + React.useEffect(() => { loadProfiles(); loadGlobalIdentity(); @@ -63,6 +77,7 @@ export const GitIdentitiesSidebar: React.FC = () => { const handleCreateProfile = () => { setSelectedProfile('new'); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } @@ -80,22 +95,19 @@ export const GitIdentitiesSidebar: React.FC = () => { }; return ( -
+
-

Git Profiles

-
- {profiles.length} - -
+ Total {profiles.length} +
@@ -111,6 +123,7 @@ export const GitIdentitiesSidebar: React.FC = () => { isSelected={selectedProfileId === 'global'} onSelect={() => { setSelectedProfile('global'); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } @@ -143,6 +156,7 @@ export const GitIdentitiesSidebar: React.FC = () => { isSelected={selectedProfileId === profile.id} onSelect={() => { setSelectedProfile(profile.id); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } @@ -172,68 +186,62 @@ const ProfileListItem: React.FC = ({ onDelete, isReadOnly = false, }) => { - const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine; - const iconColor = COLOR_MAP[profile.color || '']; return ( -
-
-
- +
+ {profile.userEmail} +
+ - {!isReadOnly && onDelete && ( - - - - - - { - e.stopPropagation(); - onDelete(); - }} - className="text-destructive focus:text-destructive" - > - - Delete - - - - )} -
+ {!isReadOnly && onDelete && ( + + + + + + { + e.stopPropagation(); + onDelete(); + }} + className="text-destructive focus:text-destructive" + > + + Delete + + + + )}
); diff --git a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx new file mode 100644 index 00000000..9580aa0c --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { RiDiscordFill, RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react'; +import { useUpdateStore } from '@/stores/useUpdateStore'; +import { UpdateDialog } from '@/components/ui/UpdateDialog'; +import { useDeviceInfo } from '@/lib/device'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; + +const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber'; + +const MIN_CHECKING_DURATION = 800; // ms + +export const AboutSettings: React.FC = () => { + const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); + const [showChecking, setShowChecking] = React.useState(false); + const updateStore = useUpdateStore(); + const { isMobile } = useDeviceInfo(); + + const currentVersion = updateStore.info?.currentVersion || 'unknown'; + + // Track if we initiated a check to show toast on completion + const didInitiateCheck = React.useRef(false); + + // Ensure minimum visible duration for checking animation + React.useEffect(() => { + if (updateStore.checking) { + setShowChecking(true); + didInitiateCheck.current = true; + } else if (showChecking) { + const timer = setTimeout(() => { + setShowChecking(false); + // Show toast if check completed with no update available + if (didInitiateCheck.current && !updateStore.available && !updateStore.error) { + toast.success('You are on the latest version'); + didInitiateCheck.current = false; + } + }, MIN_CHECKING_DURATION); + return () => clearTimeout(timer); + } + }, [updateStore.checking, showChecking, updateStore.available, updateStore.error]); + + const isChecking = updateStore.checking || showChecking; + + // Compact mobile layout for sidebar footer + if (isMobile) { + return ( +
+ {/* Version row with update status */} +
+ + v{currentVersion} + + + {!updateStore.available && !updateStore.error && ( + + )} + + {!isChecking && updateStore.available && ( + + )} +
+ + {updateStore.error && ( +

{updateStore.error}

+ )} + + {/* Links row */} + + + +
+ ); + } + + // Desktop layout (unchanged) + return ( +
+
+

+ About OpenChamber +

+
+ + {/* Version and Update */} +
+
+
+
Version
+
{currentVersion}
+
+ + {updateStore.checking && ( +
+ + Checking... +
+ )} + + {!updateStore.checking && updateStore.available && ( + + )} + + {!updateStore.checking && !updateStore.available && !updateStore.error && ( + Up to date + )} +
+ + {updateStore.error && ( +

{updateStore.error}

+ )} + + +
+ + {/* Links */} + + + {/* Update Dialog */} + +
+ ); +}; diff --git a/packages/ui/src/components/sections/settings/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx similarity index 100% rename from packages/ui/src/components/sections/settings/DefaultsSettings.tsx rename to packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx new file mode 100644 index 00000000..043c79ec --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { OpenChamberVisualSettings } from './OpenChamberVisualSettings'; +import { AboutSettings } from './AboutSettings'; +import { SessionRetentionSettings } from './SessionRetentionSettings'; +import { DefaultsSettings } from './DefaultsSettings'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useDeviceInfo } from '@/lib/device'; +import { isWebRuntime } from '@/lib/desktop'; +import type { OpenChamberSection } from './OpenChamberSidebar'; + +interface OpenChamberPageProps { + /** Which section to display. If undefined, shows all sections (mobile/legacy behavior) */ + section?: OpenChamberSection; +} + +export const OpenChamberPage: React.FC = ({ section }) => { + const { isMobile } = useDeviceInfo(); + const showAbout = isMobile && isWebRuntime(); + + // If no section specified, show all (mobile/legacy behavior) + if (!section) { + return ( + + +
+ +
+
+ +
+ {showAbout && ( +
+ +
+ )} +
+ ); + } + + // Show specific section content + const renderSectionContent = () => { + switch (section) { + case 'visual': + return ; + case 'chat': + return ; + case 'sessions': + return ; + default: + return null; + } + }; + + return ( + + {renderSectionContent()} + + ); +}; + +// Visual section: Theme Mode, Font Size, Spacing +const VisualSectionContent: React.FC = () => { + return ; +}; + +// Chat section: Default Tool Output, Diff layout, Show reasoning traces +const ChatSectionContent: React.FC = () => { + return ; +}; + +// Sessions section: Default model & agent, Session retention +const SessionsSectionContent: React.FC = () => { + return ( +
+ +
+ +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx new file mode 100644 index 00000000..058cced2 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useDeviceInfo } from '@/lib/device'; +import { isWebRuntime } from '@/lib/desktop'; +import { AboutSettings } from './AboutSettings'; +import { cn } from '@/lib/utils'; + +export type OpenChamberSection = 'visual' | 'chat' | 'sessions'; + +interface OpenChamberSidebarProps { + selectedSection: OpenChamberSection; + onSelectSection: (section: OpenChamberSection) => void; +} + +interface SectionGroup { + id: OpenChamberSection; + label: string; + items: string[]; +} + +const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ + { + id: 'visual', + label: 'Visual', + items: ['Theme', 'Font', 'Spacing'], + }, + { + id: 'chat', + label: 'Chat', + items: ['Tools', 'Diff', 'Reasoning'], + }, + { + id: 'sessions', + label: 'Sessions', + items: ['Defaults', 'Retention'], + }, +]; + +export const OpenChamberSidebar: React.FC = ({ + selectedSection, + onSelectSection, +}) => { + const { isMobile } = useDeviceInfo(); + const showAbout = isMobile && isWebRuntime(); + + const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { + if (typeof window === 'undefined') return false; + return typeof window.opencodeDesktop !== 'undefined'; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); + }, []); + + return ( +
+ + {OPENCHAMBER_SECTION_GROUPS.map((group) => { + const isSelected = selectedSection === group.id; + return ( +
+ +
+ ); + })} +
+ + {/* Mobile footer: About section */} + {showAbout && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/sections/settings/AppearanceSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx similarity index 52% rename from packages/ui/src/components/sections/settings/AppearanceSettings.tsx rename to packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index dcb11419..3e47ba7b 100644 --- a/packages/ui/src/components/sections/settings/AppearanceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -55,7 +55,14 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [ }, ]; -export const AppearanceSettings: React.FC = () => { +export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'toolOutput' | 'diffLayout' | 'reasoning'; + +interface OpenChamberVisualSettingsProps { + /** Which settings to show. If undefined, shows all. */ + visibleSettings?: VisibleSetting[]; +} + +export const OpenChamberVisualSettings: React.FC = ({ visibleSettings }) => { const { isMobile } = useDeviceInfo(); const showReasoningTraces = useUIStore(state => state.showReasoningTraces); const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces); @@ -72,9 +79,14 @@ export const AppearanceSettings: React.FC = () => { setThemeMode, } = useThemeSystem(); + const shouldShow = (setting: VisibleSetting): boolean => { + if (!visibleSettings) return true; + return visibleSettings.includes(setting); + }; + return (
- {!isVSCodeRuntime() && ( + {shouldShow('theme') && !isVSCodeRuntime() && (

@@ -97,7 +109,7 @@ export const AppearanceSettings: React.FC = () => {

)} - {!isMobile && ( + {shouldShow('fontSize') && !isMobile && (

@@ -138,102 +150,105 @@ export const AppearanceSettings: React.FC = () => {

)} -
-
-

- Spacing -

+ {shouldShow('spacing') && ( +
+
+

+ Spacing +

-
- - {isMobile ? ( -
- setPadding(Number(e.target.value))} - className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - aria-label="Spacing percentage" - /> - - - {padding} - - - setPadding(100)} - disabled={padding === 100} - className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background" - aria-label="Reset spacing" - title="Reset" - > - -
- ) : ( -
- setPadding(Number(e.target.value))} - className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - setPadding(100)} - disabled={padding === 100} - className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background" - aria-label="Reset spacing" - title="Reset" - > - - + + {isMobile ? ( +
+ setPadding(Number(e.target.value))} + className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" + aria-label="Spacing percentage" + /> + + + {padding} + + + setPadding(100)} + disabled={padding === 100} + className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background" + aria-label="Reset spacing" + title="Reset" + > + + +
+ ) : ( +
+ setPadding(Number(e.target.value))} + className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" + /> + + setPadding(100)} + disabled={padding === 100} + className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background" + aria-label="Reset spacing" + title="Reset" + > + + +
+ )} +
+ )} + + {shouldShow('toolOutput') && ( +
+
+

+ Default Tool Output +

+

+ {TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description} +

+
+
+ {TOOL_EXPANSION_OPTIONS.map((option) => ( + setToolCallExpansion(option.value)} + > + {option.label} + + ))}
- )} -
- -
-
-

- Default Tool Output -

-

- {TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description} -

-
- {TOOL_EXPANSION_OPTIONS.map((option) => ( - setToolCallExpansion(option.value)} - > - {option.label} - - ))} -
-
+ )} - {} - {!isMobile && ( + {shouldShow('diffLayout') && !isMobile && (

@@ -264,18 +279,19 @@ export const AppearanceSettings: React.FC = () => {

)} - {} - + {shouldShow('reasoning') && ( + + )}
); }; diff --git a/packages/ui/src/components/sections/settings/SessionRetentionSettings.tsx b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx similarity index 100% rename from packages/ui/src/components/sections/settings/SessionRetentionSettings.tsx rename to packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx index dd6129a1..66d27596 100644 --- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx @@ -9,31 +9,45 @@ import { cn } from '@/lib/utils'; const ADD_PROVIDER_ID = '__add_provider__'; -export const ProvidersSidebar: React.FC = () => { +interface ProvidersSidebarProps { + onItemSelect?: () => void; +} + +export const ProvidersSidebar: React.FC = ({ onItemSelect }) => { const providers = useConfigStore((state) => state.providers); const selectedProviderId = useConfigStore((state) => state.selectedProviderId); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const { isMobile } = useDeviceInfo(); + const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { + if (typeof window === 'undefined') return false; + return typeof window.opencodeDesktop !== 'undefined'; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); + }, []); + return ( -
+
-

Providers

-
- {providers.length} - -
+ Total {providers.length} +
@@ -50,32 +64,30 @@ export const ProvidersSidebar: React.FC = () => { const isSelected = provider.id === selectedProviderId; return ( -
-
-
- -
-
+
+
); }) diff --git a/packages/ui/src/components/sections/settings/AboutSettings.tsx b/packages/ui/src/components/sections/settings/AboutSettings.tsx deleted file mode 100644 index 65998de5..00000000 --- a/packages/ui/src/components/sections/settings/AboutSettings.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React from 'react'; -import { RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react'; -import { useUpdateStore } from '@/stores/useUpdateStore'; -import { UpdateDialog } from '@/components/ui/UpdateDialog'; -import { cn } from '@/lib/utils'; - -const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber'; - -export const AboutSettings: React.FC = () => { - const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); - const updateStore = useUpdateStore(); - - const currentVersion = updateStore.info?.currentVersion || 'unknown'; - - return ( -
-
-

- About OpenChamber -

-
- - {/* Version and Update */} -
-
-
-
Version
-
{currentVersion}
-
- - {updateStore.checking && ( -
- - Checking... -
- )} - - {!updateStore.checking && updateStore.available && ( - - )} - - {!updateStore.checking && !updateStore.available && !updateStore.error && ( - Up to date - )} -
- - {updateStore.error && ( -

{updateStore.error}

- )} - - -
- - {/* Links */} - - - {/* Update Dialog */} - -
- ); -}; diff --git a/packages/ui/src/components/sections/settings/SettingsPage.tsx b/packages/ui/src/components/sections/settings/SettingsPage.tsx deleted file mode 100644 index 61ba3a68..00000000 --- a/packages/ui/src/components/sections/settings/SettingsPage.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { AppearanceSettings } from './AppearanceSettings'; -import { AboutSettings } from './AboutSettings'; -import { SessionRetentionSettings } from './SessionRetentionSettings'; -import { DefaultsSettings } from './DefaultsSettings'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { useDeviceInfo } from '@/lib/device'; -import { isWebRuntime } from '@/lib/desktop'; - -export const SettingsPage: React.FC = () => { - const { isMobile } = useDeviceInfo(); - const showAbout = isMobile && isWebRuntime(); - - return ( - - -
- -
-
- -
- {showAbout && ( -
- -
- )} -
- ); -}; diff --git a/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx b/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx new file mode 100644 index 00000000..050e323a --- /dev/null +++ b/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { cn } from '@/lib/utils'; + +interface SettingsPageLayoutProps { + /** Page content */ + children: React.ReactNode; + /** Additional className for the content container */ + className?: string; + /** Additional className for the outer ScrollableOverlay */ + outerClassName?: string; +} + +/** + * Standard layout wrapper for settings page content. + * Provides scrolling and centered max-width container. + * + * @example + * + * + * + * + * + * + * + * + */ +export const SettingsPageLayout: React.FC = ({ + children, + className, + outerClassName, +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/components/sections/shared/SettingsSection.tsx b/packages/ui/src/components/sections/shared/SettingsSection.tsx new file mode 100644 index 00000000..6b4e3484 --- /dev/null +++ b/packages/ui/src/components/sections/shared/SettingsSection.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface SettingsSectionProps { + /** Section content */ + children: React.ReactNode; + /** Optional section title */ + title?: string; + /** Optional section description */ + description?: string; + /** If true, adds a top border divider */ + divider?: boolean; + /** Additional className */ + className?: string; +} + +/** + * Standard section wrapper for settings page content. + * Provides consistent spacing and optional divider. + * + * @example + * + * + * + * + * + * + * + * + */ +export const SettingsSection: React.FC = ({ + children, + title, + description, + divider = false, + className, +}) => { + return ( +
+ {(title || description) && ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ )} + {children} +
+ ); +}; diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx new file mode 100644 index 00000000..edefed28 --- /dev/null +++ b/packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { RiAddLine } from '@remixicon/react'; +import { useDeviceInfo } from '@/lib/device'; +import { cn } from '@/lib/utils'; + +interface SettingsSidebarHeaderProps { + /** Total count to display (e.g., "Total 5") */ + count: number; + /** Callback when add button is clicked. If undefined, no add button is shown. */ + onAdd?: () => void; + /** Custom label prefix (default: "Total") */ + label?: string; + /** Aria label for the add button */ + addButtonLabel?: string; +} + +/** + * Standard header for settings sidebars. + * Displays "Total X" on the left and an optional add button on the right. + * + * @example + * setCreateDialogOpen(true)} + * addButtonLabel="Create new agent" + * /> + */ +export const SettingsSidebarHeader: React.FC = ({ + count, + onAdd, + label = 'Total', + addButtonLabel = 'Add new item', +}) => { + const { isMobile } = useDeviceInfo(); + + return ( +
+
+ + {label} {count} + + {onAdd && ( + + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx new file mode 100644 index 00000000..70bfefb2 --- /dev/null +++ b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { RiMore2Line } from '@remixicon/react'; +import { cn } from '@/lib/utils'; + +export interface SettingsSidebarItemAction { + /** Label shown in dropdown menu */ + label: string; + /** Icon component to show before label */ + icon?: React.ComponentType<{ className?: string }>; + /** Callback when action is clicked */ + onClick: () => void; + /** If true, uses destructive styling (red text) */ + destructive?: boolean; +} + +interface SettingsSidebarItemProps { + /** Primary title text */ + title: React.ReactNode; + /** Secondary metadata text (shown below title) */ + metadata?: React.ReactNode; + /** Whether this item is currently selected */ + selected?: boolean; + /** Callback when item is clicked */ + onSelect: () => void; + /** Optional icon to show before title */ + icon?: React.ReactNode; + /** Actions shown in dropdown menu. If empty/undefined, no dropdown is shown. */ + actions?: SettingsSidebarItemAction[]; + /** Additional className for the outer container */ + className?: string; +} + +/** + * Standard list item for settings sidebars. + * Provides consistent styling for title, metadata, selection state, and optional actions dropdown. + * + * @example + * setSelectedId(agent.id)} + * icon={} + * actions={[ + * { label: 'Duplicate', icon: RiFileCopyLine, onClick: handleDuplicate }, + * { label: 'Delete', icon: RiDeleteBinLine, onClick: handleDelete, destructive: true }, + * ]} + * /> + */ +export const SettingsSidebarItem: React.FC = ({ + title, + metadata, + selected = false, + onSelect, + icon, + actions, + className, +}) => { + const hasActions = actions && actions.length > 0; + + return ( +
+
+ + + {hasActions && ( + + + + + + {actions.map((action, index) => { + const Icon = action.icon; + return ( + { + e.stopPropagation(); + action.onClick(); + }} + className={cn( + action.destructive && 'text-destructive focus:text-destructive' + )} + > + {Icon && } + {action.label} + + ); + })} + + + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx new file mode 100644 index 00000000..94dbdef3 --- /dev/null +++ b/packages/ui/src/components/sections/shared/SettingsSidebarLayout.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { cn } from '@/lib/utils'; + +interface SettingsSidebarLayoutProps { + /** Header content (typically SettingsSidebarHeader) */ + header?: React.ReactNode; + /** Footer content (e.g., AboutSettings on mobile) */ + footer?: React.ReactNode; + /** Main scrollable content */ + children: React.ReactNode; + /** Additional className for the outer container */ + className?: string; +} + +/** + * Standard layout wrapper for settings sidebars. + * Provides consistent background, scrolling, and header/footer slots. + * + * @example + * } + * > + * {items.map(item => ( + * + * ))} + * + */ +export const SettingsSidebarLayout: React.FC = ({ + header, + footer, + children, + className, +}) => { + const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { + if (typeof window === 'undefined') return false; + return typeof window.opencodeDesktop !== 'undefined'; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); + }, []); + + return ( +
+ {header} + + + {children} + + + {footer} +
+ ); +}; diff --git a/packages/ui/src/components/sections/shared/index.ts b/packages/ui/src/components/sections/shared/index.ts new file mode 100644 index 00000000..b6b36f44 --- /dev/null +++ b/packages/ui/src/components/sections/shared/index.ts @@ -0,0 +1,56 @@ +/** + * Shared boilerplate components for settings sections. + * + * These components provide consistent styling and behavior for settings sidebars and pages. + * Use them as building blocks when creating new settings sections. + * + * @example Sidebar usage: + * ```tsx + * import { + * SettingsSidebarLayout, + * SettingsSidebarHeader, + * SettingsSidebarItem, + * } from '@/components/sections/shared'; + * + * export const MySidebar = () => ( + * } + * > + * {items.map(item => ( + * setSelectedId(item.id)} + * actions={[ + * { label: 'Delete', onClick: () => handleDelete(item.id), destructive: true } + * ]} + * /> + * ))} + * + * ); + * ``` + * + * @example Page usage: + * ```tsx + * import { SettingsPageLayout, SettingsSection } from '@/components/sections/shared'; + * + * export const MyPage = () => ( + * + * + * + * + * + * + * + * + * ); + * ``` + */ + +export { SettingsSidebarLayout } from './SettingsSidebarLayout'; +export { SettingsSidebarHeader } from './SettingsSidebarHeader'; +export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem'; +export { SettingsPageLayout } from './SettingsPageLayout'; +export { SettingsSection } from './SettingsSection'; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx new file mode 100644 index 00000000..fa561920 --- /dev/null +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -0,0 +1,386 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; +import type { SidebarSection } from '@/constants/sidebar'; +import { RiArrowLeftSLine, RiCloseLine } from '@remixicon/react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +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 { 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'; + +const SETTINGS_SECTIONS = (() => { + const filtered = SIDEBAR_SECTIONS.filter(section => section.id !== 'sessions'); + 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; + +interface SettingsViewProps { + onClose?: () => void; +} + +export const SettingsView: React.FC = ({ onClose }) => { + const { isMobile } = useDeviceInfo(); + 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 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 || ''); + }, []); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + setIsDesktopApp(typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'); + }, []); + + // 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 '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 'providers': + return ; + case 'git-identities': + return ; + case 'settings': + return ; + default: + return null; + } + }; + + // Keyboard shortcut display based on platform + const shortcutKey = isMacPlatform ? '⌘' : 'Ctrl'; + + // 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()} +
+ + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/views/index.ts b/packages/ui/src/components/views/index.ts index be187b98..52ba942d 100644 --- a/packages/ui/src/components/views/index.ts +++ b/packages/ui/src/components/views/index.ts @@ -2,3 +2,4 @@ export { ChatView } from './ChatView'; export { GitView } from './GitView'; export { DiffView, useDiffFileCount } from './DiffView'; export { TerminalView } from './TerminalView'; +export { SettingsView } from './SettingsView'; diff --git a/packages/ui/src/constants/sidebar.ts b/packages/ui/src/constants/sidebar.ts index 34f7424a..9fd73caa 100644 --- a/packages/ui/src/constants/sidebar.ts +++ b/packages/ui/src/constants/sidebar.ts @@ -1,4 +1,4 @@ -import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiPaintBrushLine, RiStackLine } from '@remixicon/react'; +import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine } from '@remixicon/react'; import type { ComponentType } from 'react'; export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings'; @@ -46,9 +46,9 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [ }, { id: 'settings', - label: 'Appearance', - description: 'Fine-tune themes, fonts, and typography across the interface.', - icon: RiPaintBrushLine, + label: 'OpenChamber', + description: 'OpenChamber app settings: themes, fonts, and preferences.', + icon: RiSettings3Line, }, ]; diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index e0c0ae8a..eb0cf384 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -118,7 +118,7 @@ export const useKeyboardShortcuts = () => { return; } - if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === ',') { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === ',') { e.preventDefault(); const { isSettingsDialogOpen } = useUIStore.getState(); setSettingsDialogOpen(!isSettingsDialogOpen); @@ -144,6 +144,34 @@ export const useKeyboardShortcuts = () => { } if (e.key === 'Escape') { + const { + isSettingsDialogOpen, + isCommandPaletteOpen, + isHelpDialogOpen, + isSessionSwitcherOpen, + isSessionCreateDialogOpen, + isAboutDialogOpen, + activeMainTab, + } = useUIStore.getState(); + + // If settings is open, close it + if (isSettingsDialogOpen) { + e.preventDefault(); + setSettingsDialogOpen(false); + resetAbortPriming(); + return; + } + + // Check if any overlay is open or not on chat tab - don't process abort + const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen; + const isChatActive = activeMainTab === 'chat'; + + if (hasOverlay || !isChatActive) { + resetAbortPriming(); + return; + } + + // Double-ESC abort logic - only when on chat tab with no overlays const sessionId = currentSessionId; const canAbortNow = working.canAbort && Boolean(sessionId); if (!canAbortNow) {