feat: refactor settings components and sidebar layout
This commit is contained in:
@@ -28,11 +28,12 @@ Workspaces:
|
||||
### Core Components (UI)
|
||||
In `packages/ui/src/components/chat/`: ChatContainer, ChatEmptyState, ChatErrorBoundary, ChatInput, ChatMessage, FileAttachment, MarkdownRenderer, MessageList, ModelControls, PermissionCard, PermissionRequest, ServerFilePicker, StreamingTextDiff, AgentMentionAutocomplete, CommandAutocomplete, FileMentionAutocomplete.
|
||||
In `packages/ui/src/components/chat/message/`: MessageBody, MessageHeader, ToolOutputDialog, DiffViewToggle, FadeInOnReveal; parts/ (AssistantTextPart, ReasoningPart, ToolPart, UserTextPart, etc.)
|
||||
In `packages/ui/src/components/layout/`: MainLayout, Header, Sidebar, SidebarContextSummary, SettingsDialog, VSCodeLayout.
|
||||
In `packages/ui/src/components/sections/`: AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SettingsPage (with subsections for agents/, commands/, git-identities/, providers/, settings/).
|
||||
In `packages/ui/src/components/layout/`: MainLayout, Header, Sidebar, SidebarContextSummary, VSCodeLayout.
|
||||
In `packages/ui/src/components/views/`: ChatView, GitView, DiffView, TerminalView, SettingsView.
|
||||
In `packages/ui/src/components/sections/`: subsections agents/, commands/, git-identities/, openchamber/, providers/, shared/.
|
||||
In `packages/ui/src/components/session/`: DirectoryTree, DirectoryExplorerDialog, SessionDialogs, SessionSidebar.
|
||||
In `packages/ui/src/components/ui/`: CommandPalette, HelpDialog, ConfigUpdateOverlay, ContextUsageDisplay, ErrorBoundary, MemoryDebugPanel, MobileOverlayPanel, FireworksAnimation, OpenChamberLogo, OpenCodeIcon, ProviderLogo, ScrollShadow, OverlayScrollbar, plus Radix-based primitives (button, dialog, input, select, etc.)
|
||||
In `packages/ui/src/components/views/`: ChatView, GitView, DiffView, PierreDiffViewer, TerminalView.
|
||||
|
||||
In `packages/ui/src/components/terminal/`: TerminalViewport
|
||||
In `packages/ui/src/components/onboarding/`: OnboardingScreen
|
||||
In `packages/ui/src/components/providers/`: ThemeProvider
|
||||
@@ -78,8 +79,19 @@ bun run vscode:build # Build VS Code extension
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Section-Based Navigation
|
||||
Modular section architecture with dedicated pages and sidebars. Sections: Agents, Commands, Git Identities, Providers, Sessions, Settings. Independent state management and routing.
|
||||
### Settings View Architecture
|
||||
Full-screen settings view (`SettingsView.tsx`) with tabbed navigation. Desktop: sidebar + page side-by-side with resizable sidebar. Mobile: drill-down pattern (sidebar → page with back button). Tabs: OpenChamber, Agents, Commands, Providers, Git Identities.
|
||||
|
||||
Each settings section has a sidebar (`*Sidebar.tsx`) and page (`*Page.tsx`) in `packages/ui/src/components/sections/`.
|
||||
|
||||
**Shared boilerplate components** in `packages/ui/src/components/sections/shared/`:
|
||||
- `SettingsSidebarLayout` - wrapper with bg, scroll, header/footer slots
|
||||
- `SettingsSidebarHeader` - "Total X" + add button
|
||||
- `SettingsSidebarItem` - list item with title, metadata, selection, optional actions dropdown
|
||||
- `SettingsPageLayout` - centered max-w-3xl scrollable container
|
||||
- `SettingsSection` - section with optional title, description, divider
|
||||
|
||||
Use these as reference when adding new settings sections. See `index.ts` for usage examples.
|
||||
|
||||
### File Attachments
|
||||
Drag-and-drop upload with 10MB limit (`FileAttachment.tsx`), Data URL encoding, type validation with fallbacks, integrated via `useFileStore.addAttachedFile()`.
|
||||
@@ -129,7 +141,8 @@ Backend: `packages/web/server/index.js` with `listLocalDirectory()`, `getFilesys
|
||||
`SessionSwitcherDialog.tsx`: Collapsible date groups, mobile parity with MobileOverlayPanel, Git worktree and shared session chips, streaming indicators.
|
||||
|
||||
### Settings & Configuration
|
||||
`packages/ui/src/components/sections/`: AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SessionsPage, SettingsPage
|
||||
`packages/ui/src/components/sections/`: AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SessionsPage, OpenChamberPage
|
||||
`packages/ui/src/components/sections/shared/`: Boilerplate components for new sections
|
||||
Related stores: useAgentsStore, useCommandsStore, useConfigStore, useGitIdentitiesStore
|
||||
|
||||
### Git Operations
|
||||
|
||||
+5
-1
@@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Optimized session activity status handling.
|
||||
- Redesigned Settings as a full-screen view with tabbed navigation.
|
||||
- Added mobile-friendly drill-down navigation for settings.
|
||||
- ESC key now closes settings; double-ESC abort only works on chat tab without overlays.
|
||||
- Added responsive tab labels in settings header (icons only at narrow widths).
|
||||
- Improved session activity status handling and message step completion logic.
|
||||
|
||||
|
||||
## [1.3.6] - 2025-12-27
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Header, FixedSessionsButton } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { SettingsDialog } from './SettingsDialog';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { CommandPalette } from '../ui/CommandPalette';
|
||||
import { HelpDialog } from '../ui/HelpDialog';
|
||||
@@ -16,7 +15,7 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ChatView, GitView, DiffView, TerminalView } from '@/components/views';
|
||||
import { ChatView, GitView, DiffView, TerminalView, SettingsView } from '@/components/views';
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const {
|
||||
@@ -218,6 +217,7 @@ export const MainLayout: React.FC = () => {
|
||||
}, [activeMainTab]);
|
||||
|
||||
const isChatActive = activeMainTab === 'chat';
|
||||
const isSettingsActive = isSettingsDialogOpen && !isMobile;
|
||||
|
||||
return (
|
||||
<DiffWorkerProvider>
|
||||
@@ -231,13 +231,16 @@ export const MainLayout: React.FC = () => {
|
||||
<CommandPalette />
|
||||
<HelpDialog />
|
||||
<SessionDialogs />
|
||||
<SettingsDialog isOpen={isSettingsDialogOpen} onClose={() => setSettingsDialogOpen(false)} />
|
||||
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Header />
|
||||
{/* Mobile: Header + content + settings overlay */}
|
||||
{!isSettingsDialogOpen && <Header />}
|
||||
<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)' }}
|
||||
>
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
@@ -259,33 +262,53 @@ export const MainLayout: React.FC = () => {
|
||||
>
|
||||
<SessionSidebar mobileVariant />
|
||||
</MobileOverlayPanel>
|
||||
|
||||
{/* Mobile settings: full screen */}
|
||||
{isSettingsDialogOpen && (
|
||||
<div className="absolute inset-0 z-10 bg-background">
|
||||
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
||||
<SessionSidebar />
|
||||
</Sidebar>
|
||||
{!isSettingsActive && (
|
||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
||||
<SessionSidebar />
|
||||
</Sidebar>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden bg-background">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
{/* 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 />
|
||||
<div className="flex flex-1 overflow-hidden bg-background">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</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>
|
||||
</DiffWorkerProvider>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
@@ -19,10 +12,8 @@ import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSideb
|
||||
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
|
||||
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
|
||||
import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage';
|
||||
import { SettingsPage } from '@/components/sections/settings/SettingsPage';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { SIDEBAR_CONTENT_WIDTH } from '@/components/layout/Sidebar';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
|
||||
interface SettingsDialogProps {
|
||||
@@ -37,10 +28,13 @@ const SETTINGS_SECTIONS = (() => {
|
||||
return settingsSection ? [settingsSection, ...otherSections] : filtered;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Mobile-only settings dialog using MobileOverlayPanel.
|
||||
* Desktop uses SettingsView rendered inline in MainLayout.
|
||||
*/
|
||||
export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose }) => {
|
||||
const [activeTab, setActiveTab] = React.useState<SidebarSection>('settings');
|
||||
const [showPageContent, setShowPageContent] = React.useState(false);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -51,19 +45,14 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
|
||||
|
||||
const handleTabChange = React.useCallback((tab: SidebarSection) => {
|
||||
setActiveTab(tab);
|
||||
if (isMobile) {
|
||||
setShowPageContent(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
setShowPageContent(false);
|
||||
}, []);
|
||||
|
||||
const handleItemSelect = React.useCallback(() => {
|
||||
if (isMobile) {
|
||||
setShowPageContent(true);
|
||||
}
|
||||
}, [isMobile]);
|
||||
setShowPageContent(true);
|
||||
}, []);
|
||||
|
||||
const renderSidebarContent = () => {
|
||||
|
||||
if (activeTab === 'settings') {
|
||||
return null;
|
||||
}
|
||||
@@ -73,21 +62,17 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
|
||||
case 'agents':
|
||||
return <AgentsSidebar />;
|
||||
case 'commands':
|
||||
return <CommandsSidebar />;
|
||||
case 'providers':
|
||||
return <ProvidersSidebar />;
|
||||
case 'git-identities':
|
||||
return <GitIdentitiesSidebar />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return <CommandsSidebar />;
|
||||
case 'providers':
|
||||
return <ProvidersSidebar />;
|
||||
case 'git-identities':
|
||||
return <GitIdentitiesSidebar />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMobile) {
|
||||
return <div onClick={handleItemSelect}>{content}</div>;
|
||||
}
|
||||
|
||||
return content;
|
||||
return <div onClick={handleItemSelect}>{content}</div>;
|
||||
};
|
||||
|
||||
const renderPageContent = () => {
|
||||
@@ -97,50 +82,19 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
|
||||
case 'commands':
|
||||
return <CommandsPage />;
|
||||
case 'providers':
|
||||
return <ProvidersPage />;
|
||||
case 'git-identities':
|
||||
return <GitIdentitiesPage />;
|
||||
case 'settings':
|
||||
return <SettingsPage />;
|
||||
return <ProvidersPage />;
|
||||
case 'git-identities':
|
||||
return <GitIdentitiesPage />;
|
||||
case 'settings':
|
||||
return <OpenChamberPage />;
|
||||
default:
|
||||
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 = (
|
||||
<div className={cn(
|
||||
'flex flex-col min-h-0',
|
||||
isMobile ? 'h-full overflow-hidden' : 'flex-1 overflow-hidden'
|
||||
)}>
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<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 }) => {
|
||||
const isActive = activeTab === id;
|
||||
@@ -157,35 +111,22 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
|
||||
aria-pressed={isActive}
|
||||
aria-label={label}
|
||||
>
|
||||
<PhosphorIcon className={cn('h-5 w-5 sm:h-4 sm:w-4')} weight="regular" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
<PhosphorIcon className="h-5 w-5" weight="regular" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content area - mobile drill-down pattern */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{activeTab !== 'settings' && (!isMobile || !showPageContent) && (
|
||||
<div
|
||||
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
|
||||
}
|
||||
>
|
||||
{activeTab !== 'settings' && !showPageContent && (
|
||||
<div className="w-full overflow-hidden bg-sidebar">
|
||||
<ErrorBoundary>{renderSidebarContent()}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeTab === 'settings' || !isMobile || showPageContent) && (
|
||||
<div className={cn('flex-1 overflow-hidden bg-background', isMobile && 'w-full')}>
|
||||
{(activeTab === 'settings' || showPageContent) && (
|
||||
<div className="w-full flex-1 overflow-hidden bg-background">
|
||||
<ErrorBoundary>{renderPageContent()}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
@@ -193,56 +134,34 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose
|
||||
</div>
|
||||
);
|
||||
|
||||
const panelContent = (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{!useMobileOverlay && headerContent}
|
||||
{mainContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (useMobileOverlay) {
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title="Settings"
|
||||
className="max-w-full"
|
||||
contentMaxHeightClassName="h-[min(80dvh,720px)]"
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/40">
|
||||
<div className="relative flex flex-1 items-center justify-center">
|
||||
{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>
|
||||
)}
|
||||
<span className="typography-ui-header">Settings</span>
|
||||
</div>
|
||||
{closeButton}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{mainContent}
|
||||
</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>
|
||||
<MobileOverlayPanel
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title="Settings"
|
||||
className="max-w-full"
|
||||
contentMaxHeightClassName="h-[min(80dvh,720px)]"
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/40">
|
||||
<div className="relative flex flex-1 items-center justify-center">
|
||||
{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>
|
||||
)}
|
||||
<span className="typography-ui-header">Settings</span>
|
||||
</div>
|
||||
{closeButton}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{mainContent}
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||
import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react';
|
||||
import { SettingsPage } from '@/components/sections/settings/SettingsPage';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
|
||||
type VSCodeView = 'sessions' | 'chat' | 'settings';
|
||||
|
||||
@@ -229,7 +229,7 @@ const VSCodeSettingsView: React.FC = () => {
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<SettingsPage />
|
||||
<OpenChamberPage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,11 @@ import { cn } from '@/lib/utils';
|
||||
import type { Agent } from '@opencode-ai/sdk';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
export const AgentsSidebar: React.FC = () => {
|
||||
interface AgentsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
|
||||
const [newAgentName, setNewAgentName] = React.useState('');
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
|
||||
|
||||
@@ -41,6 +45,16 @@ export const AgentsSidebar: React.FC = () => {
|
||||
const { setSidebarOpen } = useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<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(() => {
|
||||
loadAgents();
|
||||
}, [loadAgents]);
|
||||
@@ -118,19 +132,16 @@ export const AgentsSidebar: React.FC = () => {
|
||||
const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent));
|
||||
|
||||
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}>
|
||||
<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">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Agents</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{visibleAgents.length}</span>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground">
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 -my-1 text-muted-foreground">
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,6 +166,7 @@ export const AgentsSidebar: React.FC = () => {
|
||||
isSelected={selectedAgentName === agent.name}
|
||||
onSelect={() => {
|
||||
setSelectedAgent(agent.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
@@ -178,6 +190,7 @@ export const AgentsSidebar: React.FC = () => {
|
||||
isSelected={selectedAgentName === agent.name}
|
||||
onSelect={() => {
|
||||
setSelectedAgent(agent.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
@@ -247,38 +260,33 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
getAgentModeIcon,
|
||||
}) => {
|
||||
return (
|
||||
<div className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
inputMode="none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
"typography-ui-label font-medium truncate",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||
{agent.name}
|
||||
</span>
|
||||
{getAgentModeIcon(agent.mode)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
{getAgentModeIcon(agent.mode)}
|
||||
{agent.description && (
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{}
|
||||
{agent.description && (
|
||||
<div className="typography-meta text-muted-foreground truncate mt-0.5">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -313,7 +321,6 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,11 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const CommandsSidebar: React.FC = () => {
|
||||
interface CommandsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }) => {
|
||||
const [newCommandName, setNewCommandName] = React.useState('');
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
|
||||
|
||||
@@ -40,6 +44,16 @@ export const CommandsSidebar: React.FC = () => {
|
||||
const { setSidebarOpen } = useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<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(() => {
|
||||
loadCommands();
|
||||
}, [loadCommands]);
|
||||
@@ -96,19 +110,16 @@ export const CommandsSidebar: React.FC = () => {
|
||||
};
|
||||
|
||||
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}>
|
||||
<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">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Commands</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{commands.length}</span>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground">
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">Total {commands.length}</span>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 -my-1 text-muted-foreground">
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,21 +132,22 @@ export const CommandsSidebar: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{[...commands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
|
||||
<CommandListItem
|
||||
key={command.name}
|
||||
command={command}
|
||||
isSelected={selectedCommandName === command.name}
|
||||
onSelect={() => {
|
||||
setSelectedCommand(command.name);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDelete={() => handleDeleteCommand(command)}
|
||||
onDuplicate={() => handleDuplicateCommand(command)}
|
||||
/>
|
||||
))}
|
||||
{[...commands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
|
||||
<CommandListItem
|
||||
key={command.name}
|
||||
command={command}
|
||||
isSelected={selectedCommandName === command.name}
|
||||
onSelect={() => {
|
||||
setSelectedCommand(command.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDelete={() => handleDeleteCommand(command)}
|
||||
onDuplicate={() => handleDuplicateCommand(command)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
@@ -192,68 +204,64 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
onDuplicate,
|
||||
}) => {
|
||||
return (
|
||||
<div className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
inputMode="none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"typography-ui-label font-medium truncate flex-1",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
/{command.name}
|
||||
</div>
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<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-2">
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||
/{command.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{command.description && (
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{}
|
||||
{command.description && (
|
||||
<div className="typography-meta text-muted-foreground truncate mt-0.5">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<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">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<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">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,11 @@ const COLOR_MAP: Record<string, string> = {
|
||||
type: 'var(--syntax-type)',
|
||||
};
|
||||
|
||||
export const GitIdentitiesSidebar: React.FC = () => {
|
||||
interface GitIdentitiesSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
|
||||
const {
|
||||
selectedProfileId,
|
||||
profiles,
|
||||
@@ -56,6 +60,16 @@ export const GitIdentitiesSidebar: React.FC = () => {
|
||||
const { setSidebarOpen } = useUIStore();
|
||||
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(() => {
|
||||
loadProfiles();
|
||||
loadGlobalIdentity();
|
||||
@@ -63,6 +77,7 @@ export const GitIdentitiesSidebar: React.FC = () => {
|
||||
|
||||
const handleCreateProfile = () => {
|
||||
setSelectedProfile('new');
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
@@ -80,22 +95,19 @@ export const GitIdentitiesSidebar: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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="flex items-center justify-between gap-2">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Git Profiles</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{profiles.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={handleCreateProfile}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">Total {profiles.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateProfile}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +123,7 @@ export const GitIdentitiesSidebar: React.FC = () => {
|
||||
isSelected={selectedProfileId === 'global'}
|
||||
onSelect={() => {
|
||||
setSelectedProfile('global');
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
@@ -143,6 +156,7 @@ export const GitIdentitiesSidebar: React.FC = () => {
|
||||
isSelected={selectedProfileId === profile.id}
|
||||
onSelect={() => {
|
||||
setSelectedProfile(profile.id);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
@@ -172,68 +186,62 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}) => {
|
||||
|
||||
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
|
||||
|
||||
const iconColor = COLOR_MAP[profile.color || ''];
|
||||
|
||||
return (
|
||||
<div className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
inputMode="none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
style={{ color: iconColor }}
|
||||
/>
|
||||
<div className={cn(
|
||||
"typography-ui-label font-medium truncate flex-1",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
{profile.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<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-2">
|
||||
<IconComponent
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
style={{ color: iconColor }}
|
||||
/>
|
||||
<span className="typography-ui-label font-normal truncate flex-1 text-foreground">
|
||||
{profile.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="typography-meta text-muted-foreground truncate mt-0.5">
|
||||
{profile.userEmail}
|
||||
</div>
|
||||
</button>
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||
{profile.userEmail}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!isReadOnly && onDelete && (
|
||||
<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">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{!isReadOnly && onDelete && (
|
||||
<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">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+122
-106
@@ -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 showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||
@@ -72,9 +79,14 @@ export const AppearanceSettings: React.FC = () => {
|
||||
setThemeMode,
|
||||
} = useThemeSystem();
|
||||
|
||||
const shouldShow = (setting: VisibleSetting): boolean => {
|
||||
if (!visibleSettings) return true;
|
||||
return visibleSettings.includes(setting);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-8">
|
||||
{!isVSCodeRuntime() && (
|
||||
{shouldShow('theme') && !isVSCodeRuntime() && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
@@ -97,7 +109,7 @@ export const AppearanceSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
{shouldShow('fontSize') && !isMobile && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
@@ -138,102 +150,105 @@ export const AppearanceSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Spacing
|
||||
</h3>
|
||||
{shouldShow('spacing') && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Spacing
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
|
||||
aria-label="Spacing percentage"
|
||||
/>
|
||||
|
||||
<span className="typography-ui-label font-medium text-foreground tabular-nums rounded-md border border-border bg-background px-2 py-1.5 min-w-[3.75rem] text-center">
|
||||
{padding}
|
||||
</span>
|
||||
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setPadding(100)}
|
||||
disabled={padding === 100}
|
||||
className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background"
|
||||
aria-label="Reset spacing"
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 w-full max-w-md">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={padding}
|
||||
onValueChange={setPadding}
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
aria-label="Spacing percentage"
|
||||
/>
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setPadding(100)}
|
||||
disabled={padding === 100}
|
||||
className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background"
|
||||
aria-label="Reset spacing"
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
|
||||
aria-label="Spacing percentage"
|
||||
/>
|
||||
|
||||
<span className="typography-ui-label font-medium text-foreground tabular-nums rounded-md border border-border bg-background px-2 py-1.5 min-w-[3.75rem] text-center">
|
||||
{padding}
|
||||
</span>
|
||||
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setPadding(100)}
|
||||
disabled={padding === 100}
|
||||
className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background"
|
||||
aria-label="Reset spacing"
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 w-full max-w-md">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="flex-1 min-w-0 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={padding}
|
||||
onValueChange={setPadding}
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
aria-label="Spacing percentage"
|
||||
/>
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setPadding(100)}
|
||||
disabled={padding === 100}
|
||||
className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background"
|
||||
aria-label="Reset spacing"
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('toolOutput') && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Default Tool Output
|
||||
</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 w-fit">
|
||||
{TOOL_EXPANSION_OPTIONS.map((option) => (
|
||||
<ButtonSmall
|
||||
key={option.value}
|
||||
variant={toolCallExpansion === option.value ? 'default' : 'outline'}
|
||||
className={cn(toolCallExpansion === option.value ? undefined : 'text-foreground')}
|
||||
onClick={() => setToolCallExpansion(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</ButtonSmall>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Default Tool Output
|
||||
</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 w-fit">
|
||||
{TOOL_EXPANSION_OPTIONS.map((option) => (
|
||||
<ButtonSmall
|
||||
key={option.value}
|
||||
variant={toolCallExpansion === option.value ? 'default' : 'outline'}
|
||||
className={cn(toolCallExpansion === option.value ? undefined : 'text-foreground')}
|
||||
onClick={() => setToolCallExpansion(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</ButtonSmall>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{!isMobile && (
|
||||
{shouldShow('diffLayout') && !isMobile && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
@@ -264,18 +279,19 @@ export const AppearanceSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-primary"
|
||||
checked={showReasoningTraces}
|
||||
onChange={(event) => setShowReasoningTraces(event.target.checked)}
|
||||
/>
|
||||
<span className="typography-ui-header font-semibold text-foreground">
|
||||
Show thinking / reasoning traces
|
||||
</span>
|
||||
</label>
|
||||
{shouldShow('reasoning') && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-primary"
|
||||
checked={showReasoningTraces}
|
||||
onChange={(event) => setShowReasoningTraces(event.target.checked)}
|
||||
/>
|
||||
<span className="typography-ui-header font-semibold text-foreground">
|
||||
Show thinking / reasoning traces
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,31 +9,45 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
export const ProvidersSidebar: React.FC = () => {
|
||||
interface ProvidersSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<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="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="flex items-center justify-between gap-2">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Providers</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{providers.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setSelectedProvider(ADD_PROVIDER_ID)}
|
||||
aria-label="Connect provider"
|
||||
title="Connect provider"
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">Total {providers.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={() => {
|
||||
setSelectedProvider(ADD_PROVIDER_ID);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
aria-label="Connect provider"
|
||||
title="Connect provider"
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,32 +64,30 @@ export const ProvidersSidebar: React.FC = () => {
|
||||
const isSelected = provider.id === selectedProviderId;
|
||||
|
||||
return (
|
||||
<div key={provider.id} className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedProvider(provider.id)}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className={cn(
|
||||
"typography-ui-label font-medium truncate flex-1 min-w-0",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
{provider.name || provider.id}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground flex-shrink-0">
|
||||
{modelCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key={provider.id}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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}
|
||||
>
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
|
||||
{provider.name || provider.id}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
|
||||
{modelCount}
|
||||
</span>
|
||||
</button>
|
||||
</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 { DiffView, useDiffFileCount } from './DiffView';
|
||||
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';
|
||||
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings';
|
||||
@@ -46,9 +46,9 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Appearance',
|
||||
description: 'Fine-tune themes, fonts, and typography across the interface.',
|
||||
icon: RiPaintBrushLine,
|
||||
label: 'OpenChamber',
|
||||
description: 'OpenChamber app settings: themes, fonts, and preferences.',
|
||||
icon: RiSettings3Line,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export const useKeyboardShortcuts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === ',') {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === ',') {
|
||||
e.preventDefault();
|
||||
const { isSettingsDialogOpen } = useUIStore.getState();
|
||||
setSettingsDialogOpen(!isSettingsDialogOpen);
|
||||
@@ -144,6 +144,34 @@ export const useKeyboardShortcuts = () => {
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const {
|
||||
isSettingsDialogOpen,
|
||||
isCommandPaletteOpen,
|
||||
isHelpDialogOpen,
|
||||
isSessionSwitcherOpen,
|
||||
isSessionCreateDialogOpen,
|
||||
isAboutDialogOpen,
|
||||
activeMainTab,
|
||||
} = useUIStore.getState();
|
||||
|
||||
// If settings is open, close it
|
||||
if (isSettingsDialogOpen) {
|
||||
e.preventDefault();
|
||||
setSettingsDialogOpen(false);
|
||||
resetAbortPriming();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any overlay is open or not on chat tab - don't process abort
|
||||
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen;
|
||||
const isChatActive = activeMainTab === 'chat';
|
||||
|
||||
if (hasOverlay || !isChatActive) {
|
||||
resetAbortPriming();
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-ESC abort logic - only when on chat tab with no overlays
|
||||
const sessionId = currentSessionId;
|
||||
const canAbortNow = working.canAbort && Boolean(sessionId);
|
||||
if (!canAbortNow) {
|
||||
|
||||
Reference in New Issue
Block a user