feat: refactor settings components and sidebar layout

This commit is contained in:
Bohdan Triapitsyn
2025-12-28 02:17:09 +02:00
parent c074b9a3f2
commit 0fefc1b301
27 changed files with 1788 additions and 675 deletions
+19 -6
View File
@@ -28,11 +28,12 @@ Workspaces:
### Core Components (UI) ### 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/`: 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/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/layout/`: MainLayout, Header, Sidebar, SidebarContextSummary, 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/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/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/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/terminal/`: TerminalViewport
In `packages/ui/src/components/onboarding/`: OnboardingScreen In `packages/ui/src/components/onboarding/`: OnboardingScreen
In `packages/ui/src/components/providers/`: ThemeProvider In `packages/ui/src/components/providers/`: ThemeProvider
@@ -78,8 +79,19 @@ bun run vscode:build # Build VS Code extension
## Key Patterns ## Key Patterns
### Section-Based Navigation ### Settings View Architecture
Modular section architecture with dedicated pages and sidebars. Sections: Agents, Commands, Git Identities, Providers, Sessions, Settings. Independent state management and routing. 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 ### File Attachments
Drag-and-drop upload with 10MB limit (`FileAttachment.tsx`), Data URL encoding, type validation with fallbacks, integrated via `useFileStore.addAttachedFile()`. 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. `SessionSwitcherDialog.tsx`: Collapsible date groups, mobile parity with MobileOverlayPanel, Git worktree and shared session chips, streaming indicators.
### Settings & Configuration ### 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 Related stores: useAgentsStore, useCommandsStore, useConfigStore, useGitIdentitiesStore
### Git Operations ### Git Operations
+5 -1
View File
@@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ## [1.3.6] - 2025-12-27
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Header, FixedSessionsButton } from './Header'; import { Header, FixedSessionsButton } from './Header';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { SettingsDialog } from './SettingsDialog';
import { ErrorBoundary } from '../ui/ErrorBoundary'; import { ErrorBoundary } from '../ui/ErrorBoundary';
import { CommandPalette } from '../ui/CommandPalette'; import { CommandPalette } from '../ui/CommandPalette';
import { HelpDialog } from '../ui/HelpDialog'; import { HelpDialog } from '../ui/HelpDialog';
@@ -16,7 +15,7 @@ import { useDeviceInfo } from '@/lib/device';
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe'; import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
import { cn } from '@/lib/utils'; 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 = () => { export const MainLayout: React.FC = () => {
const { const {
@@ -218,6 +217,7 @@ export const MainLayout: React.FC = () => {
}, [activeMainTab]); }, [activeMainTab]);
const isChatActive = activeMainTab === 'chat'; const isChatActive = activeMainTab === 'chat';
const isSettingsActive = isSettingsDialogOpen && !isMobile;
return ( return (
<DiffWorkerProvider> <DiffWorkerProvider>
@@ -231,13 +231,16 @@ export const MainLayout: React.FC = () => {
<CommandPalette /> <CommandPalette />
<HelpDialog /> <HelpDialog />
<SessionDialogs /> <SessionDialogs />
<SettingsDialog isOpen={isSettingsDialogOpen} onClose={() => setSettingsDialogOpen(false)} />
{isMobile ? ( {isMobile ? (
<> <>
<Header /> {/* Mobile: Header + content + settings overlay */}
{!isSettingsDialogOpen && <Header />}
<div <div
className="flex flex-1 overflow-hidden bg-background" className={cn(
'flex flex-1 overflow-hidden bg-background',
isSettingsDialogOpen && 'hidden'
)}
style={{ paddingTop: 'var(--oc-header-height, 56px)' }} style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
> >
<main className="flex-1 overflow-hidden bg-background relative"> <main className="flex-1 overflow-hidden bg-background relative">
@@ -259,16 +262,27 @@ export const MainLayout: React.FC = () => {
> >
<SessionSidebar mobileVariant /> <SessionSidebar mobileVariant />
</MobileOverlayPanel> </MobileOverlayPanel>
{/* Mobile settings: full screen */}
{isSettingsDialogOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div>
)}
</> </>
) : ( ) : (
<> <>
{!isSettingsActive && (
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}> <Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
<SessionSidebar /> <SessionSidebar />
</Sidebar> </Sidebar>
)}
<div className="flex flex-1 flex-col overflow-hidden"> {/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden relative">
{/* Normal view: Header + content */}
<div className={cn('absolute inset-0 flex flex-col', isSettingsActive && 'invisible')}>
<Header /> <Header />
<div className="flex flex-1 overflow-hidden bg-background"> <div className="flex flex-1 overflow-hidden bg-background">
<main className="flex-1 overflow-hidden bg-background relative"> <main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}> <div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
@@ -282,10 +296,19 @@ export const MainLayout: React.FC = () => {
</main> </main>
</div> </div>
</div> </div>
</div>
{/* Settings view: full screen overlay */}
{isSettingsActive && (
<div className={cn('absolute inset-0 z-10', isDesktopRuntime ? 'bg-transparent' : 'bg-background')}>
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div>
)}
</> </>
)} )}
<FixedSessionsButton /> {/* Hide fixed sessions button when settings is open */}
{!isSettingsActive && <FixedSessionsButton />}
</div> </div>
</DiffWorkerProvider> </DiffWorkerProvider>
); );
@@ -1,11 +1,4 @@
import React from 'react'; import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
import type { SidebarSection } 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 { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar'; import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage'; import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage';
import { SettingsPage } from '@/components/sections/settings/SettingsPage'; import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { useDeviceInfo } from '@/lib/device';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { SIDEBAR_CONTENT_WIDTH } from '@/components/layout/Sidebar';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface SettingsDialogProps { interface SettingsDialogProps {
@@ -37,10 +28,13 @@ const SETTINGS_SECTIONS = (() => {
return settingsSection ? [settingsSection, ...otherSections] : filtered; return settingsSection ? [settingsSection, ...otherSections] : filtered;
})(); })();
/**
* Mobile-only settings dialog using MobileOverlayPanel.
* Desktop uses SettingsView rendered inline in MainLayout.
*/
export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose }) => { export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = React.useState<SidebarSection>('settings'); const [activeTab, setActiveTab] = React.useState<SidebarSection>('settings');
const [showPageContent, setShowPageContent] = React.useState(false); const [showPageContent, setShowPageContent] = React.useState(false);
const { isMobile } = useDeviceInfo();
React.useEffect(() => { React.useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -51,19 +45,14 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
const handleTabChange = React.useCallback((tab: SidebarSection) => { const handleTabChange = React.useCallback((tab: SidebarSection) => {
setActiveTab(tab); setActiveTab(tab);
if (isMobile) {
setShowPageContent(false); setShowPageContent(false);
} }, []);
}, [isMobile]);
const handleItemSelect = React.useCallback(() => { const handleItemSelect = React.useCallback(() => {
if (isMobile) {
setShowPageContent(true); setShowPageContent(true);
} }, []);
}, [isMobile]);
const renderSidebarContent = () => { const renderSidebarContent = () => {
if (activeTab === 'settings') { if (activeTab === 'settings') {
return null; return null;
} }
@@ -83,11 +72,7 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
} }
})(); })();
if (isMobile) {
return <div onClick={handleItemSelect}>{content}</div>; return <div onClick={handleItemSelect}>{content}</div>;
}
return content;
}; };
const renderPageContent = () => { const renderPageContent = () => {
@@ -101,46 +86,15 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
case 'git-identities': case 'git-identities':
return <GitIdentitiesPage />; return <GitIdentitiesPage />;
case 'settings': case 'settings':
return <SettingsPage />; return <OpenChamberPage />;
default: default:
return null; return null;
} }
}; };
const activeSection = SETTINGS_SECTIONS.find(s => s.id === activeTab);
const useMobileOverlay = isMobile;
const headerContent = (
<DialogHeader
className="border-b border-border/40 px-6 pb-4 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)]"
>
<div className="relative flex items-center justify-center">
{isMobile && showPageContent && (
<Button
variant="ghost"
size="icon"
onClick={() => setShowPageContent(false)}
className="absolute left-0 h-6 w-6 flex-shrink-0"
>
<RiArrowLeftSLine className="h-5 w-5" />
<span className="sr-only">Back to sidebar</span>
</Button>
)}
<DialogTitle className="typography-ui-header">Settings</DialogTitle>
</div>
{activeSection && (
<DialogDescription className="typography-meta text-muted-foreground hidden sm:block">
{activeSection.description}
</DialogDescription>
)}
</DialogHeader>
);
const mainContent = ( const mainContent = (
<div className={cn( <div className="flex flex-col h-full overflow-hidden">
'flex flex-col min-h-0', {/* Tab bar */}
isMobile ? 'h-full overflow-hidden' : 'flex-1 overflow-hidden'
)}>
<div className="flex flex-wrap items-center gap-1 border-b border-border/40 bg-background/95 px-3 py-1.5"> <div className="flex flex-wrap items-center gap-1 border-b border-border/40 bg-background/95 px-3 py-1.5">
{SETTINGS_SECTIONS.map(({ id, label, icon: Icon }) => { {SETTINGS_SECTIONS.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id; const isActive = activeTab === id;
@@ -157,35 +111,22 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
aria-pressed={isActive} aria-pressed={isActive}
aria-label={label} aria-label={label}
> >
<PhosphorIcon className={cn('h-5 w-5 sm:h-4 sm:w-4')} weight="regular" /> <PhosphorIcon className="h-5 w-5" weight="regular" />
<span className="hidden sm:inline">{label}</span>
</button> </button>
); );
})} })}
</div> </div>
{/* Content area - mobile drill-down pattern */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{activeTab !== 'settings' && (!isMobile || !showPageContent) && ( {activeTab !== 'settings' && !showPageContent && (
<div <div className="w-full overflow-hidden bg-sidebar">
className={cn(
'overflow-hidden border-r bg-sidebar',
isMobile && 'w-full border-r-0'
)}
style={
!isMobile
? {
width: `${SIDEBAR_CONTENT_WIDTH}px`,
minWidth: `${SIDEBAR_CONTENT_WIDTH}px`,
}
: undefined
}
>
<ErrorBoundary>{renderSidebarContent()}</ErrorBoundary> <ErrorBoundary>{renderSidebarContent()}</ErrorBoundary>
</div> </div>
)} )}
{(activeTab === 'settings' || !isMobile || showPageContent) && ( {(activeTab === 'settings' || showPageContent) && (
<div className={cn('flex-1 overflow-hidden bg-background', isMobile && 'w-full')}> <div className="w-full flex-1 overflow-hidden bg-background">
<ErrorBoundary>{renderPageContent()}</ErrorBoundary> <ErrorBoundary>{renderPageContent()}</ErrorBoundary>
</div> </div>
)} )}
@@ -193,14 +134,6 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
</div> </div>
); );
const panelContent = (
<div className="flex h-full flex-col overflow-hidden">
{!useMobileOverlay && headerContent}
{mainContent}
</div>
);
if (useMobileOverlay) {
return ( return (
<MobileOverlayPanel <MobileOverlayPanel
open={isOpen} open={isOpen}
@@ -231,18 +164,4 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
{mainContent} {mainContent}
</MobileOverlayPanel> </MobileOverlayPanel>
); );
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
'flex flex-col gap-0 p-0',
'h-[88vh] w-[65vw] max-w-[900px]'
)}
>
{panelContent}
</DialogContent>
</Dialog>
);
}; };
@@ -6,7 +6,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react'; 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'; type VSCodeView = 'sessions' | 'chat' | 'settings';
@@ -229,7 +229,7 @@ const VSCodeSettingsView: React.FC = () => {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<SettingsPage /> <OpenChamberPage />
</div> </div>
</div> </div>
); );
@@ -26,7 +26,11 @@ import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk'; import type { Agent } from '@opencode-ai/sdk';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
export const AgentsSidebar: React.FC = () => { interface AgentsSidebarProps {
onItemSelect?: () => void;
}
export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
const [newAgentName, setNewAgentName] = React.useState(''); const [newAgentName, setNewAgentName] = React.useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
@@ -41,6 +45,16 @@ export const AgentsSidebar: React.FC = () => {
const { setSidebarOpen } = useUIStore(); const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
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(() => { React.useEffect(() => {
loadAgents(); loadAgents();
}, [loadAgents]); }, [loadAgents]);
@@ -118,21 +132,18 @@ export const AgentsSidebar: React.FC = () => {
const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent)); const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent));
return ( return (
<div className="flex h-full flex-col bg-sidebar"> <div className={cn('flex h-full flex-col', isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar')}>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">Agents</h2> <span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
<div className="flex items-center gap-1">
<span className="typography-meta text-muted-foreground">{visibleAgents.length}</span>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground"> <Button type="button" variant="ghost" size="icon" className="h-7 w-7 -my-1 text-muted-foreground">
<RiAddLine className="size-4" /> <RiAddLine className="size-4" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
</div> </div>
</div> </div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden"> <ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{visibleAgents.length === 0 ? ( {visibleAgents.length === 0 ? (
@@ -155,6 +166,7 @@ export const AgentsSidebar: React.FC = () => {
isSelected={selectedAgentName === agent.name} isSelected={selectedAgentName === agent.name}
onSelect={() => { onSelect={() => {
setSelectedAgent(agent.name); setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) { if (isMobile) {
setSidebarOpen(false); setSidebarOpen(false);
} }
@@ -178,6 +190,7 @@ export const AgentsSidebar: React.FC = () => {
isSelected={selectedAgentName === agent.name} isSelected={selectedAgentName === agent.name}
onSelect={() => { onSelect={() => {
setSelectedAgent(agent.name); setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) { if (isMobile) {
setSidebarOpen(false); setSidebarOpen(false);
} }
@@ -247,32 +260,27 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
getAgentModeIcon, getAgentModeIcon,
}) => { }) => {
return ( return (
<div className="group transition-all duration-200"> <div
<div className="relative"> className={cn(
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1"> 'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button <button
onClick={onSelect} onClick={onSelect}
className="flex-1 text-left overflow-hidden" className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
inputMode="none"
tabIndex={0} tabIndex={0}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className={cn( <span className="typography-ui-label font-normal truncate text-foreground">
"typography-ui-label font-medium truncate",
isSelected
? "text-primary"
: "text-foreground hover:text-primary/80"
)}>
{agent.name} {agent.name}
</div> </span>
{}
{getAgentModeIcon(agent.mode)} {getAgentModeIcon(agent.mode)}
</div> </div>
{}
{agent.description && ( {agent.description && (
<div className="typography-meta text-muted-foreground truncate mt-0.5"> <div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{agent.description} {agent.description}
</div> </div>
)} )}
@@ -315,6 +323,5 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</div>
); );
}; };
@@ -25,7 +25,11 @@ import { useDeviceInfo } from '@/lib/device';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export const CommandsSidebar: React.FC = () => { interface CommandsSidebarProps {
onItemSelect?: () => void;
}
export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }) => {
const [newCommandName, setNewCommandName] = React.useState(''); const [newCommandName, setNewCommandName] = React.useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
@@ -40,6 +44,16 @@ export const CommandsSidebar: React.FC = () => {
const { setSidebarOpen } = useUIStore(); const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
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(() => { React.useEffect(() => {
loadCommands(); loadCommands();
}, [loadCommands]); }, [loadCommands]);
@@ -96,21 +110,18 @@ export const CommandsSidebar: React.FC = () => {
}; };
return ( return (
<div className="flex h-full flex-col bg-sidebar"> <div className={cn('flex h-full flex-col', isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar')}>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">Commands</h2> <span className="typography-meta text-muted-foreground">Total {commands.length}</span>
<div className="flex items-center gap-1">
<span className="typography-meta text-muted-foreground">{commands.length}</span>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground"> <Button type="button" variant="ghost" size="icon" className="h-7 w-7 -my-1 text-muted-foreground">
<RiAddLine className="size-4" /> <RiAddLine className="size-4" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
</div> </div>
</div> </div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2"> <ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{commands.length === 0 ? ( {commands.length === 0 ? (
@@ -128,6 +139,7 @@ export const CommandsSidebar: React.FC = () => {
isSelected={selectedCommandName === command.name} isSelected={selectedCommandName === command.name}
onSelect={() => { onSelect={() => {
setSelectedCommand(command.name); setSelectedCommand(command.name);
onItemSelect?.();
if (isMobile) { if (isMobile) {
setSidebarOpen(false); setSidebarOpen(false);
} }
@@ -192,29 +204,26 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
onDuplicate, onDuplicate,
}) => { }) => {
return ( return (
<div className="group transition-all duration-200"> <div
<div className="relative"> className={cn(
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1"> 'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button <button
onClick={onSelect} onClick={onSelect}
className="flex-1 text-left overflow-hidden" className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
inputMode="none"
tabIndex={0} tabIndex={0}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={cn( <span className="typography-ui-label font-normal truncate text-foreground">
"typography-ui-label font-medium truncate flex-1",
isSelected
? "text-primary"
: "text-foreground hover:text-primary/80"
)}>
/{command.name} /{command.name}
</div> </span>
</div> </div>
{}
{command.description && ( {command.description && (
<div className="typography-meta text-muted-foreground truncate mt-0.5"> <div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{command.description} {command.description}
</div> </div>
)} )}
@@ -255,6 +264,5 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</div>
); );
}; };
@@ -42,7 +42,11 @@ const COLOR_MAP: Record<string, string> = {
type: 'var(--syntax-type)', type: 'var(--syntax-type)',
}; };
export const GitIdentitiesSidebar: React.FC = () => { interface GitIdentitiesSidebarProps {
onItemSelect?: () => void;
}
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
const { const {
selectedProfileId, selectedProfileId,
profiles, profiles,
@@ -56,6 +60,16 @@ export const GitIdentitiesSidebar: React.FC = () => {
const { setSidebarOpen } = useUIStore(); const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
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(() => { React.useEffect(() => {
loadProfiles(); loadProfiles();
loadGlobalIdentity(); loadGlobalIdentity();
@@ -63,6 +77,7 @@ export const GitIdentitiesSidebar: React.FC = () => {
const handleCreateProfile = () => { const handleCreateProfile = () => {
setSelectedProfile('new'); setSelectedProfile('new');
onItemSelect?.();
if (isMobile) { if (isMobile) {
setSidebarOpen(false); setSidebarOpen(false);
} }
@@ -80,24 +95,21 @@ export const GitIdentitiesSidebar: React.FC = () => {
}; };
return ( return (
<div className="flex h-full flex-col bg-sidebar"> <div className={cn('flex h-full flex-col', isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar')}>
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">Git Profiles</h2> <span className="typography-meta text-muted-foreground">Total {profiles.length}</span>
<div className="flex items-center gap-1">
<span className="typography-meta text-muted-foreground">{profiles.length}</span>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground" className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateProfile} onClick={handleCreateProfile}
> >
<RiAddLine className="size-4" /> <RiAddLine className="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2"> <ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{} {}
@@ -111,6 +123,7 @@ export const GitIdentitiesSidebar: React.FC = () => {
isSelected={selectedProfileId === 'global'} isSelected={selectedProfileId === 'global'}
onSelect={() => { onSelect={() => {
setSelectedProfile('global'); setSelectedProfile('global');
onItemSelect?.();
if (isMobile) { if (isMobile) {
setSidebarOpen(false); setSidebarOpen(false);
} }
@@ -143,6 +156,7 @@ export const GitIdentitiesSidebar: React.FC = () => {
isSelected={selectedProfileId === profile.id} isSelected={selectedProfileId === profile.id}
onSelect={() => { onSelect={() => {
setSelectedProfile(profile.id); setSelectedProfile(profile.id);
onItemSelect?.();
if (isMobile) { if (isMobile) {
setSidebarOpen(false); setSidebarOpen(false);
} }
@@ -172,19 +186,20 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
onDelete, onDelete,
isReadOnly = false, isReadOnly = false,
}) => { }) => {
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine; const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
const iconColor = COLOR_MAP[profile.color || '']; const iconColor = COLOR_MAP[profile.color || ''];
return ( return (
<div className="group transition-all duration-200"> <div
<div className="relative"> className={cn(
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1"> 'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button <button
onClick={onSelect} onClick={onSelect}
className="flex-1 text-left overflow-hidden" className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
inputMode="none"
tabIndex={0} tabIndex={0}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -192,18 +207,12 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
className="w-4 h-4 flex-shrink-0" className="w-4 h-4 flex-shrink-0"
style={{ color: iconColor }} style={{ color: iconColor }}
/> />
<div className={cn( <span className="typography-ui-label font-normal truncate flex-1 text-foreground">
"typography-ui-label font-medium truncate flex-1",
isSelected
? "text-primary"
: "text-foreground hover:text-primary/80"
)}>
{profile.name} {profile.name}
</div> </span>
</div> </div>
{} <div className="typography-micro text-muted-foreground/60 truncate leading-tight">
<div className="typography-meta text-muted-foreground truncate mt-0.5">
{profile.userEmail} {profile.userEmail}
</div> </div>
</button> </button>
@@ -235,6 +244,5 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
)} )}
</div> </div>
</div> </div>
</div>
); );
}; };
@@ -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 (
<div className="w-full space-y-2">
{/* Version row with update status */}
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground">
v{currentVersion}
</span>
{!updateStore.available && !updateStore.error && (
<button
onClick={() => updateStore.checkForUpdates()}
disabled={isChecking}
className={cn(
'typography-meta text-muted-foreground/60 hover:text-muted-foreground disabled:cursor-default',
isChecking && 'animate-pulse [animation-duration:1s]'
)}
>
Check updates
</button>
)}
{!isChecking && updateStore.available && (
<button
onClick={() => setUpdateDialogOpen(true)}
className="flex items-center gap-1 typography-meta text-primary hover:underline"
>
<RiDownloadLine className="h-3.5 w-3.5" />
Update
</button>
)}
</div>
{updateStore.error && (
<p className="typography-micro text-destructive truncate">{updateStore.error}</p>
)}
{/* Links row */}
<div className="flex items-center gap-3">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
>
<RiGithubFill className="h-3.5 w-3.5" />
<span>GitHub</span>
</a>
<a
href="https://discord.gg/ZYRSdnwwKA"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
>
<RiDiscordFill className="h-3.5 w-3.5" />
<span>Discord</span>
</a>
<a
href="https://x.com/btriapitsyn"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
>
<RiTwitterXFill className="h-3.5 w-3.5" />
<span>@btriapitsyn</span>
</a>
</div>
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</div>
);
}
// Desktop layout (unchanged)
return (
<div className="w-full space-y-6">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
About OpenChamber
</h3>
</div>
{/* Version and Update */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="typography-ui-label text-muted-foreground">Version</div>
<div className="typography-ui-header font-mono">{currentVersion}</div>
</div>
{updateStore.checking && (
<div className="flex items-center gap-2 text-muted-foreground">
<RiLoaderLine className="h-4 w-4 animate-spin" />
<span className="typography-meta">Checking...</span>
</div>
)}
{!updateStore.checking && updateStore.available && (
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
>
<RiDownloadLine className="h-4 w-4" />
Update to {updateStore.info?.version}
</button>
)}
{!updateStore.checking && !updateStore.available && !updateStore.error && (
<span className="typography-meta text-muted-foreground">Up to date</span>
)}
</div>
{updateStore.error && (
<p className="typography-meta text-destructive">{updateStore.error}</p>
)}
<button
onClick={() => updateStore.checkForUpdates()}
disabled={updateStore.checking}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground',
'underline-offset-2 hover:underline',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Check for updates
</button>
</div>
{/* Links */}
<div className="flex items-center gap-4">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiGithubFill className="h-4 w-4" />
<span>GitHub</span>
</a>
<a
href="https://x.com/btriapitsyn"
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiTwitterXFill className="h-4 w-4" />
<span>@btriapitsyn</span>
</a>
</div>
{/* Update Dialog */}
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</div>
);
};
@@ -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<OpenChamberPageProps> = ({ section }) => {
const { isMobile } = useDeviceInfo();
const showAbout = isMobile && isWebRuntime();
// If no section specified, show all (mobile/legacy behavior)
if (!section) {
return (
<ScrollableOverlay
outerClassName="h-full"
className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
>
<OpenChamberVisualSettings />
<div className="border-t border-border/40 pt-6">
<DefaultsSettings />
</div>
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
</div>
)}
</ScrollableOverlay>
);
}
// Show specific section content
const renderSectionContent = () => {
switch (section) {
case 'visual':
return <VisualSectionContent />;
case 'chat':
return <ChatSectionContent />;
case 'sessions':
return <SessionsSectionContent />;
default:
return null;
}
};
return (
<ScrollableOverlay
outerClassName="h-full"
className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6"
>
{renderSectionContent()}
</ScrollableOverlay>
);
};
// Visual section: Theme Mode, Font Size, Spacing
const VisualSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'spacing']} />;
};
// Chat section: Default Tool Output, Diff layout, Show reasoning traces
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning']} />;
};
// Sessions section: Default model & agent, Session retention
const SessionsSectionContent: React.FC = () => {
return (
<div className="space-y-6">
<DefaultsSettings />
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
</div>
);
};
@@ -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<OpenChamberSidebarProps> = ({
selectedSection,
onSelectSection,
}) => {
const { isMobile } = useDeviceInfo();
const showAbout = isMobile && isWebRuntime();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
return (
<div className={cn('flex h-full flex-col', isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar')}>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{OPENCHAMBER_SECTION_GROUPS.map((group) => {
const isSelected = selectedSection === group.id;
return (
<div
key={group.id}
className={cn(
'group relative rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<button
onClick={() => onSelectSection(group.id)}
className="w-full text-left flex flex-col gap-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<span className="typography-ui-label font-normal text-foreground">
{group.label}
</span>
<div className="typography-micro text-muted-foreground/60 leading-tight">
{group.items.join(' · ')}
</div>
</button>
</div>
);
})}
</ScrollableOverlay>
{/* Mobile footer: About section */}
{showAbout && (
<div className="border-t border-border/40 px-3 py-4">
<AboutSettings />
</div>
)}
</div>
);
};
@@ -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<OpenChamberVisualSettingsProps> = ({ visibleSettings }) => {
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const showReasoningTraces = useUIStore(state => state.showReasoningTraces); const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces); const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
@@ -72,9 +79,14 @@ export const AppearanceSettings: React.FC = () => {
setThemeMode, setThemeMode,
} = useThemeSystem(); } = useThemeSystem();
const shouldShow = (setting: VisibleSetting): boolean => {
if (!visibleSettings) return true;
return visibleSettings.includes(setting);
};
return ( return (
<div className="w-full space-y-8"> <div className="w-full space-y-8">
{!isVSCodeRuntime() && ( {shouldShow('theme') && !isVSCodeRuntime() && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
@@ -97,7 +109,7 @@ export const AppearanceSettings: React.FC = () => {
</div> </div>
)} )}
{!isMobile && ( {shouldShow('fontSize') && !isMobile && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
@@ -138,6 +150,7 @@ export const AppearanceSettings: React.FC = () => {
</div> </div>
)} )}
{shouldShow('spacing') && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
@@ -208,7 +221,9 @@ export const AppearanceSettings: React.FC = () => {
</div> </div>
)} )}
</div> </div>
)}
{shouldShow('toolOutput') && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
@@ -231,9 +246,9 @@ export const AppearanceSettings: React.FC = () => {
))} ))}
</div> </div>
</div> </div>
)}
{} {shouldShow('diffLayout') && !isMobile && (
{!isMobile && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
@@ -264,7 +279,7 @@ export const AppearanceSettings: React.FC = () => {
</div> </div>
)} )}
{} {shouldShow('reasoning') && (
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -276,6 +291,7 @@ export const AppearanceSettings: React.FC = () => {
Show thinking / reasoning traces Show thinking / reasoning traces
</span> </span>
</label> </label>
)}
</div> </div>
); );
}; };
@@ -9,25 +9,40 @@ import { cn } from '@/lib/utils';
const ADD_PROVIDER_ID = '__add_provider__'; const ADD_PROVIDER_ID = '__add_provider__';
export const ProvidersSidebar: React.FC = () => { interface ProvidersSidebarProps {
onItemSelect?: () => void;
}
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
const providers = useConfigStore((state) => state.providers); const providers = useConfigStore((state) => state.providers);
const selectedProviderId = useConfigStore((state) => state.selectedProviderId); const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
return ( return (
<div className="flex h-full flex-col bg-sidebar"> <div className={cn('flex h-full flex-col', isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar')}>
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">Providers</h2> <span className="typography-meta text-muted-foreground">Total {providers.length}</span>
<div className="flex items-center gap-1">
<span className="typography-meta text-muted-foreground">{providers.length}</span>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground" className="h-7 w-7 -my-1 text-muted-foreground"
onClick={() => setSelectedProvider(ADD_PROVIDER_ID)} onClick={() => {
setSelectedProvider(ADD_PROVIDER_ID);
onItemSelect?.();
}}
aria-label="Connect provider" aria-label="Connect provider"
title="Connect provider" title="Connect provider"
> >
@@ -35,7 +50,6 @@ export const ProvidersSidebar: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden"> <ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{providers.length === 0 ? ( {providers.length === 0 ? (
@@ -50,33 +64,31 @@ export const ProvidersSidebar: React.FC = () => {
const isSelected = provider.id === selectedProviderId; const isSelected = provider.id === selectedProviderId;
return ( return (
<div key={provider.id} className="group transition-all duration-200"> <div
<div className="relative"> key={provider.id}
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1"> className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<button <button
type="button" type="button"
onClick={() => setSelectedProvider(provider.id)} onClick={() => {
className="flex-1 text-left overflow-hidden" setSelectedProvider(provider.id);
onItemSelect?.();
}}
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0} tabIndex={0}
> >
<div className="flex items-center gap-2">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" /> <ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className={cn( <span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
"typography-ui-label font-medium truncate flex-1 min-w-0",
isSelected
? "text-primary"
: "text-foreground hover:text-primary/80"
)}>
{provider.name || provider.id} {provider.name || provider.id}
</span> </span>
<span className="typography-meta text-muted-foreground flex-shrink-0"> <span className="typography-micro text-muted-foreground/60 flex-shrink-0">
{modelCount} {modelCount}
</span> </span>
</div>
</button> </button>
</div> </div>
</div>
</div>
); );
}) })
)} )}
@@ -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 (
<div className="w-full space-y-6">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
About OpenChamber
</h3>
</div>
{/* Version and Update */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="typography-ui-label text-muted-foreground">Version</div>
<div className="typography-ui-header font-mono">{currentVersion}</div>
</div>
{updateStore.checking && (
<div className="flex items-center gap-2 text-muted-foreground">
<RiLoaderLine className="h-4 w-4 animate-spin" />
<span className="typography-meta">Checking...</span>
</div>
)}
{!updateStore.checking && updateStore.available && (
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
>
<RiDownloadLine className="h-4 w-4" />
Update to {updateStore.info?.version}
</button>
)}
{!updateStore.checking && !updateStore.available && !updateStore.error && (
<span className="typography-meta text-muted-foreground">Up to date</span>
)}
</div>
{updateStore.error && (
<p className="typography-meta text-destructive">{updateStore.error}</p>
)}
<button
onClick={() => updateStore.checkForUpdates()}
disabled={updateStore.checking}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground',
'underline-offset-2 hover:underline',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Check for updates
</button>
</div>
{/* Links */}
<div className="flex items-center gap-4">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiGithubFill className="h-4 w-4" />
<span>GitHub</span>
</a>
<a
href="https://x.com/btriapitsyn"
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
>
<RiTwitterXFill className="h-4 w-4" />
<span>@btriapitsyn</span>
</a>
</div>
{/* Update Dialog */}
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</div>
);
};
@@ -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 (
<ScrollableOverlay
outerClassName="h-full"
className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
>
<AppearanceSettings />
<div className="border-t border-border/40 pt-6">
<DefaultsSettings />
</div>
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
</div>
)}
</ScrollableOverlay>
);
};
@@ -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
* <SettingsPageLayout>
* <SettingsSection title="General">
* <SomeSettingsForm />
* </SettingsSection>
* <SettingsSection title="Advanced" divider>
* <OtherSettingsForm />
* </SettingsSection>
* </SettingsPageLayout>
*/
export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
children,
className,
outerClassName,
}) => {
return (
<ScrollableOverlay
outerClassName={cn('h-full', outerClassName)}
className={cn(
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6',
className
)}
>
{children}
</ScrollableOverlay>
);
};
@@ -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
* <SettingsSection title="Appearance" description="Customize the look and feel">
* <ThemeSelector />
* <FontSizeSelector />
* </SettingsSection>
*
* <SettingsSection divider>
* <DangerZoneSettings />
* </SettingsSection>
*/
export const SettingsSection: React.FC<SettingsSectionProps> = ({
children,
title,
description,
divider = false,
className,
}) => {
return (
<div
className={cn(
divider && 'border-t border-border/40 pt-6',
className
)}
>
{(title || description) && (
<div className="mb-4 space-y-1">
{title && (
<h3 className="typography-ui-header font-semibold text-foreground">
{title}
</h3>
)}
{description && (
<p className="typography-meta text-muted-foreground">
{description}
</p>
)}
</div>
)}
{children}
</div>
);
};
@@ -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
* <SettingsSidebarHeader
* count={agents.length}
* onAdd={() => setCreateDialogOpen(true)}
* addButtonLabel="Create new agent"
* />
*/
export const SettingsSidebarHeader: React.FC<SettingsSidebarHeaderProps> = ({
count,
onAdd,
label = 'Total',
addButtonLabel = 'Add new item',
}) => {
const { isMobile } = useDeviceInfo();
return (
<div
className={cn(
'border-b border-border/40 px-3 dark:border-white/10',
isMobile ? 'mt-2 py-3' : 'py-3'
)}
>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">
{label} {count}
</span>
{onAdd && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={onAdd}
aria-label={addButtonLabel}
>
<RiAddLine className="size-4" />
</Button>
)}
</div>
</div>
);
};
@@ -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
* <SettingsSidebarItem
* title={agent.name}
* metadata={agent.description}
* selected={selectedId === agent.id}
* onSelect={() => setSelectedId(agent.id)}
* icon={<RiRobotLine className="h-4 w-4" />}
* actions={[
* { label: 'Duplicate', icon: RiFileCopyLine, onClick: handleDuplicate },
* { label: 'Delete', icon: RiDeleteBinLine, onClick: handleDelete, destructive: true },
* ]}
* />
*/
export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
title,
metadata,
selected = false,
onSelect,
icon,
actions,
className,
}) => {
const hasActions = actions && actions.length > 0;
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
selected
? 'dark:bg-accent/80 bg-primary/12'
: 'hover:dark:bg-accent/40 hover:bg-primary/6',
className
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
{icon}
<span className="typography-ui-label font-normal truncate text-foreground">
{title}
</span>
</div>
{metadata && (
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{metadata}
</div>
)}
</button>
{hasActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{actions.map((action, index) => {
const Icon = action.icon;
return (
<DropdownMenuItem
key={index}
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
className={cn(
action.destructive && 'text-destructive focus:text-destructive'
)}
>
{Icon && <Icon className="h-4 w-4 mr-px" />}
{action.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
};
@@ -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
* <SettingsSidebarLayout
* header={<SettingsSidebarHeader count={items.length} onAdd={handleAdd} />}
* >
* {items.map(item => (
* <SettingsSidebarItem key={item.id} ... />
* ))}
* </SettingsSidebarLayout>
*/
export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
header,
footer,
children,
className,
}) => {
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
return (
<div
className={cn(
'flex h-full flex-col',
isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar',
className
)}
>
{header}
<ScrollableOverlay
outerClassName="flex-1 min-h-0"
className="space-y-1 px-3 py-2 overflow-x-hidden"
>
{children}
</ScrollableOverlay>
{footer}
</div>
);
};
@@ -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 = () => (
* <SettingsSidebarLayout
* header={<SettingsSidebarHeader count={items.length} onAdd={handleAdd} />}
* >
* {items.map(item => (
* <SettingsSidebarItem
* key={item.id}
* title={item.name}
* metadata={item.description}
* selected={selectedId === item.id}
* onSelect={() => setSelectedId(item.id)}
* actions={[
* { label: 'Delete', onClick: () => handleDelete(item.id), destructive: true }
* ]}
* />
* ))}
* </SettingsSidebarLayout>
* );
* ```
*
* @example Page usage:
* ```tsx
* import { SettingsPageLayout, SettingsSection } from '@/components/sections/shared';
*
* export const MyPage = () => (
* <SettingsPageLayout>
* <SettingsSection title="General Settings">
* <MySettingsForm />
* </SettingsSection>
* <SettingsSection title="Advanced" divider>
* <AdvancedSettingsForm />
* </SettingsSection>
* </SettingsPageLayout>
* );
* ```
*/
export { SettingsSidebarLayout } from './SettingsSidebarLayout';
export { SettingsSidebarHeader } from './SettingsSidebarHeader';
export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem';
export { SettingsPageLayout } from './SettingsPageLayout';
export { SettingsSection } from './SettingsSection';
@@ -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<SettingsViewProps> = ({ onClose }) => {
const { isMobile } = useDeviceInfo();
const [activeTab, setActiveTab] = React.useState<SidebarSection>('settings');
const [selectedOpenChamberSection, setSelectedOpenChamberSection] = React.useState<OpenChamberSection>('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<boolean>(() => {
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 (
<OpenChamberSidebar
selectedSection={selectedOpenChamberSection}
onSelectSection={(section) => {
setSelectedOpenChamberSection(section);
handleMobileSidebarClick();
}}
/>
);
case 'agents':
return <AgentsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'commands':
return <CommandsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'providers':
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
case 'git-identities':
return <GitIdentitiesSidebar onItemSelect={handleMobileSidebarClick} />;
default:
return null;
}
};
const hasSidebar = true; // All tabs now have sidebars
const renderPageContent = () => {
switch (activeTab) {
case 'agents':
return <AgentsPage />;
case 'commands':
return <CommandsPage />;
case 'providers':
return <ProvidersPage />;
case 'git-identities':
return <GitIdentitiesPage />;
case 'settings':
return <OpenChamberPage section={selectedOpenChamberSection} />;
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 (
<div className={cn('flex h-full flex-col overflow-hidden', isDesktopApp ? 'bg-transparent' : 'bg-background')}>
{/* Header with tabs and close button */}
<div
onMouseDown={!isMobile ? handleDragStart : undefined}
className={cn(
'flex select-none items-center justify-between border-b',
isMobile ? 'h-auto px-3 py-2' : 'app-region-drag h-12',
!isMobile && desktopPaddingClass,
isDesktopApp ? 'bg-background' : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
)}
style={{ borderColor: 'var(--interactive-border)' }}
>
{/* Mobile: back button when drilling down */}
{isMobile && showMobilePageContent && (
<button
type="button"
onClick={() => setShowMobilePageContent(false)}
aria-label="Back"
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
)}
{/* Mobile: spacer to push tabs to the right */}
{isMobile && <div className="flex-1" />}
<div className={cn('flex items-center', isMobile ? 'gap-1' : 'h-full')}>
{/* Leading divider before first tab - only on Mac desktop */}
{!isMobile && showLeadingDivider && <div className="h-full w-px bg-border" aria-hidden="true" />}
{SETTINGS_SECTIONS.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id;
const PhosphorIcon = Icon as React.ComponentType<{ className?: string; weight?: string }>;
if (isMobile) {
// Mobile: icon-only buttons
return (
<Tooltip key={id} delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={() => handleTabChange(id)}
className={cn(
'relative flex h-9 w-9 items-center justify-center rounded-md transition-colors',
'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}
aria-pressed={isActive}
aria-label={label}
>
<PhosphorIcon className="h-5 w-5" weight="regular" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}
// Desktop: full tabs with text and dividers
return (
<React.Fragment key={id}>
<button
onClick={() => handleTabChange(id)}
onMouseDown={isActive ? handleActiveTabDragStart : undefined}
className={cn(
'relative flex h-full items-center gap-2 px-4 typography-ui-label font-medium transition-colors',
isActive ? 'app-region-drag' : 'app-region-no-drag',
'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}
aria-pressed={isActive}
aria-label={label}
>
<PhosphorIcon className="h-4 w-4" weight="regular" />
<span className="header-tab-label">{label}</span>
</button>
{/* Vertical divider after each tab */}
<div className="h-full w-px bg-border" aria-hidden="true" />
</React.Fragment>
);
})}
</div>
{onClose && (
<div className={cn('flex items-center', isMobile ? '' : 'pr-3')}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClose}
aria-label="Close settings"
className={cn(
'inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
!isMobile && 'app-region-no-drag'
)}
>
<RiCloseLine className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close Settings ({shortcutKey}+,)</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
{/* Content area */}
<div className="flex flex-1 overflow-hidden">
{/* Mobile: show sidebar OR page content based on drill-down state */}
{isMobile ? (
showMobilePageContent ? (
<div className="flex-1 overflow-hidden bg-background">
<ErrorBoundary>{renderPageContent()}</ErrorBoundary>
</div>
) : (
<div className="flex-1 overflow-hidden bg-sidebar">
<ErrorBoundary>{renderSidebarContent()}</ErrorBoundary>
</div>
)
) : (
/* Desktop: show both sidebar and page content side by side */
<>
{hasSidebar && (
<div
className={cn(
'relative overflow-hidden border-r',
isDesktopApp
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar',
isResizing ? 'transition-none' : ''
)}
style={{
width: `${sidebarWidth}px`,
minWidth: `${sidebarWidth}px`,
borderColor: 'var(--interactive-border)',
}}
>
{/* Resize handle */}
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[6px] -mr-[3px] cursor-col-resize',
isResizing ? 'bg-primary/30' : 'bg-transparent hover:bg-primary/20'
)}
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="vertical"
aria-label="Resize settings sidebar"
/>
<ErrorBoundary>{renderSidebarContent()}</ErrorBoundary>
</div>
)}
<div className="flex-1 overflow-hidden bg-background">
<ErrorBoundary>{renderPageContent()}</ErrorBoundary>
</div>
</>
)}
</div>
</div>
);
};
@@ -2,3 +2,4 @@ export { ChatView } from './ChatView';
export { GitView } from './GitView'; export { GitView } from './GitView';
export { DiffView, useDiffFileCount } from './DiffView'; export { DiffView, useDiffFileCount } from './DiffView';
export { TerminalView } from './TerminalView'; export { TerminalView } from './TerminalView';
export { SettingsView } from './SettingsView';
+4 -4
View File
@@ -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'; import type { ComponentType } from 'react';
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings'; export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings';
@@ -46,9 +46,9 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
}, },
{ {
id: 'settings', id: 'settings',
label: 'Appearance', label: 'OpenChamber',
description: 'Fine-tune themes, fonts, and typography across the interface.', description: 'OpenChamber app settings: themes, fonts, and preferences.',
icon: RiPaintBrushLine, icon: RiSettings3Line,
}, },
]; ];
+29 -1
View File
@@ -118,7 +118,7 @@ export const useKeyboardShortcuts = () => {
return; return;
} }
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === ',') { if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === ',') {
e.preventDefault(); e.preventDefault();
const { isSettingsDialogOpen } = useUIStore.getState(); const { isSettingsDialogOpen } = useUIStore.getState();
setSettingsDialogOpen(!isSettingsDialogOpen); setSettingsDialogOpen(!isSettingsDialogOpen);
@@ -144,6 +144,34 @@ export const useKeyboardShortcuts = () => {
} }
if (e.key === 'Escape') { 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 sessionId = currentSessionId;
const canAbortNow = working.canAbort && Boolean(sessionId); const canAbortNow = working.canAbort && Boolean(sessionId);
if (!canAbortNow) { if (!canAbortNow) {