feat: refactor settings components and sidebar layout

This commit is contained in:
Bohdan Triapitsyn
2025-12-28 02:17:09 +02:00
parent c074b9a3f2
commit 0fefc1b301
27 changed files with 1788 additions and 675 deletions
@@ -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>
);
};
@@ -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';