feat: refactor settings components and sidebar layout
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user