feat: refactor settings components and sidebar layout
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+22
-6
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user