Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
@@ -0,0 +1,649 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { useAgentsStore, type AgentConfig } from '@/stores/useAgentsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiInformationLine, RiRobot2Line, RiRobotLine, RiSaveLine, RiSubtractLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { ModelSelector } from './ModelSelector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useAvailableTools } from '@/hooks/useAvailableTools';
export const AgentsPage: React.FC = () => {
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents } = useAgentsStore();
useConfigStore();
const { tools: availableTools } = useAvailableTools();
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
const isNewAgent = selectedAgentName && !selectedAgent;
const [name, setName] = React.useState('');
const [description, setDescription] = React.useState('');
const [mode, setMode] = React.useState<'primary' | 'subagent' | 'all'>('subagent');
const [model, setModel] = React.useState('');
const [temperature, setTemperature] = React.useState<number | undefined>(undefined);
const [topP, setTopP] = React.useState<number | undefined>(undefined);
const [prompt, setPrompt] = React.useState('');
const [tools, setTools] = React.useState<Record<string, boolean>>({});
const [editPermission, setEditPermission] = React.useState<'allow' | 'ask' | 'deny' | 'full'>('allow');
const [webfetchPermission, setWebfetchPermission] = React.useState<'allow' | 'ask' | 'deny'>('allow');
const [bashPermission, setBashPermission] = React.useState<'allow' | 'ask' | 'deny'>('ask');
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (isNewAgent) {
setName(selectedAgentName || '');
setDescription('');
setMode('subagent');
setModel('');
setTemperature(undefined);
setTopP(undefined);
setPrompt('');
setTools({});
setEditPermission('allow');
setWebfetchPermission('allow');
setBashPermission('ask');
} else if (selectedAgent) {
setName(selectedAgent.name);
setDescription(selectedAgent.description || '');
setMode(selectedAgent.mode || 'subagent');
if (selectedAgent.model?.providerID && selectedAgent.model?.modelID) {
setModel(`${selectedAgent.model.providerID}/${selectedAgent.model.modelID}`);
} else {
setModel('');
}
setTemperature(selectedAgent.temperature);
setTopP(selectedAgent.topP);
setPrompt(selectedAgent.prompt || '');
setTools(selectedAgent.tools || {});
if (selectedAgent.permission) {
const editMode = selectedAgent.permission.edit;
if (editMode === 'allow' || editMode === 'ask' || editMode === 'deny' || editMode === 'full') {
setEditPermission(editMode);
}
if (selectedAgent.permission.webfetch) {
setWebfetchPermission(selectedAgent.permission.webfetch);
}
if (typeof selectedAgent.permission.bash === 'string') {
setBashPermission(selectedAgent.permission.bash as 'allow' | 'ask' | 'deny');
}
}
}
}, [selectedAgent, isNewAgent, selectedAgentName, agents]);
const handleSave = async () => {
if (!name.trim()) {
toast.error('Agent name is required');
return;
}
setIsSaving(true);
try {
const trimmedModel = model.trim();
const config: AgentConfig = {
name: name.trim(),
description: description.trim() || undefined,
mode,
model: trimmedModel === '' ? null : trimmedModel,
temperature,
top_p: topP,
prompt: prompt.trim() || undefined,
tools: Object.keys(tools).length > 0 ? tools : undefined,
permission: {
edit: editPermission,
webfetch: webfetchPermission,
bash: bashPermission,
},
};
let success: boolean;
if (isNewAgent) {
success = await createAgent(config);
} else {
success = await updateAgent(name, config);
}
if (success) {
toast.success(isNewAgent ? 'Agent created successfully' : 'Agent updated successfully');
} else {
toast.error(isNewAgent ? 'Failed to create agent' : 'Failed to update agent');
}
} catch (error) {
console.error('Error saving agent:', error);
toast.error('An error occurred while saving');
} finally {
setIsSaving(false);
}
};
const toggleTool = (tool: string) => {
setTools((prev) => ({
...prev,
[tool]: !prev[tool],
}));
};
const toggleAllTools = (enabled: boolean) => {
const allTools: Record<string, boolean> = {};
availableTools.forEach((tool: string) => {
allTools[tool] = enabled;
});
setTools(allTools);
};
if (!selectedAgentName) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiRobot2Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">Select an agent from the sidebar</p>
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
{}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewAgent ? 'New Agent' : name}
</h1>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
<p className="typography-meta text-muted-foreground/80">
Configure agent identity and behavior mode
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Agent Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-agent"
disabled={!isNewAgent}
/>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?"
rows={3}
/>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Mode
</label>
<div className="flex gap-1 w-fit">
<ButtonSmall
variant={mode === 'primary' ? 'default' : 'outline'}
onClick={() => setMode('primary')}
className={cn('gap-2', mode === 'primary' ? undefined : 'text-foreground')}
>
<RiAiAgentLine className="h-3 w-3" />
Primary
</ButtonSmall>
<ButtonSmall
variant={mode === 'subagent' ? 'default' : 'outline'}
onClick={() => setMode('subagent')}
className={cn('gap-2', mode === 'subagent' ? undefined : 'text-foreground')}
>
<RiRobotLine className="h-3 w-3" />
Subagent
</ButtonSmall>
<ButtonSmall
variant={mode === 'all' ? 'default' : 'outline'}
onClick={() => setMode('all')}
className={cn('gap-2', mode === 'all' ? undefined : 'text-foreground')}
>
<RiAiAgentFill className="h-3 w-3" />
All
</ButtonSmall>
</div>
<p className="typography-meta text-muted-foreground">
Primary: main agent, Subagent: helper agent, All: both modes
</p>
</div>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Model Configuration</h2>
<p className="typography-meta text-muted-foreground/80">
Configure model and generation parameters
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Model
</label>
<ModelSelector
providerId={model ? model.split('/')[0] : ''}
modelId={model ? model.split('/')[1] : ''}
onChange={(providerId: string, modelId: string) => {
if (providerId && modelId) {
setModel(`${providerId}/${modelId}`);
} else {
setModel('');
}
}}
/>
</div>
<div className="flex gap-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Temperature
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Controls randomness in responses.<br />
Higher values make output more creative and unpredictable,<br />
lower values make it more focused and deterministic.
</TooltipContent>
</Tooltip>
</label>
<div className="relative w-32">
<button
type="button"
onClick={() => {
const current = temperature !== undefined ? temperature : 0.7;
const newValue = Math.max(0, current - 0.1);
setTemperature(parseFloat(newValue.toFixed(1)));
}}
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
<RiSubtractLine className="h-3.5 w-3.5" />
</button>
<Input
type="text"
inputMode="decimal"
value={temperature !== undefined ? temperature : ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
setTemperature(undefined);
return;
}
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
setTemperature(parsed);
}
}}
onBlur={(e) => {
const value = e.target.value;
if (value !== '') {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
const clamped = Math.max(0, Math.min(2, parsed));
setTemperature(parseFloat(clamped.toFixed(1)));
}
}
}}
placeholder="—"
className="text-center px-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => {
const current = temperature !== undefined ? temperature : 0.7;
const newValue = Math.min(2, current + 0.1);
setTemperature(parseFloat(newValue.toFixed(1)));
}}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Top P
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Controls diversity via nucleus sampling.<br />
Lower values focus on most likely tokens,<br />
higher values consider more possibilities.
</TooltipContent>
</Tooltip>
</label>
<div className="relative w-32">
<button
type="button"
onClick={() => {
const current = topP !== undefined ? topP : 0.9;
const newValue = Math.max(0, current - 0.1);
setTopP(parseFloat(newValue.toFixed(1)));
}}
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
<RiSubtractLine className="h-3.5 w-3.5" />
</button>
<Input
type="text"
inputMode="decimal"
value={topP !== undefined ? topP : ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
setTopP(undefined);
return;
}
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
setTopP(parsed);
}
}}
onBlur={(e) => {
const value = e.target.value;
if (value !== '') {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
const clamped = Math.max(0, Math.min(1, parsed));
setTopP(parseFloat(clamped.toFixed(1)));
}
}
}}
placeholder="—"
className="text-center px-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => {
const current = topP !== undefined ? topP : 0.9;
const newValue = Math.min(1, current + 0.1);
setTopP(parseFloat(newValue.toFixed(1)));
}}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">System Prompt</h2>
<p className="typography-meta text-muted-foreground/80">
Override the default system prompt for this agent
</p>
</div>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Custom system prompt for this agent..."
rows={8}
className="font-mono typography-meta"
/>
</div>
{}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Available Tools</h2>
<p className="typography-meta text-muted-foreground/80">
Select tools this agent can access
</p>
</div>
<div className="flex gap-1 w-fit">
<Button
variant="outline"
size="sm"
onClick={() => toggleAllTools(true)}
className="h-6 px-2 text-xs"
>
Enable All
</Button>
<Button
variant="outline"
size="sm"
onClick={() => toggleAllTools(false)}
className="h-6 px-2 text-xs"
>
Disable All
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{availableTools.map((tool) => (
<button
key={tool}
type="button"
onClick={() => toggleTool(tool)}
className={cn(
"h-6 px-2 rounded-lg border text-[13px] cursor-pointer transition-colors",
tools[tool]
? "bg-primary border-primary text-primary-foreground"
: "border-border/40 bg-sidebar/30 text-foreground hover:bg-sidebar/50"
)}
>
{tool}
</button>
))}
</div>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Permissions</h2>
<p className="typography-meta text-muted-foreground/80">
Configure permission levels for different operations
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Edit Permission
</label>
<div className="flex gap-1 w-fit">
<Button
size="sm"
variant={editPermission === 'full' ? 'default' : 'outline'}
onClick={() => setEditPermission('full')}
className="h-6 px-2 text-xs"
>
Full
</Button>
<Button
size="sm"
variant={editPermission === 'allow' ? 'default' : 'outline'}
onClick={() => setEditPermission('allow')}
className="h-6 px-2 text-xs"
>
Allow
</Button>
<Button
size="sm"
variant={editPermission === 'ask' ? 'default' : 'outline'}
onClick={() => setEditPermission('ask')}
className="h-6 px-2 text-xs"
>
Ask
</Button>
<Button
size="sm"
variant={editPermission === 'deny' ? 'default' : 'outline'}
onClick={() => setEditPermission('deny')}
className="h-6 px-2 text-xs"
>
Deny
</Button>
</div>
<div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground">
Controls file editing permissions.
</p>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<div className="space-y-1">
<p><strong>Full:</strong> Auto-approves all tool requests</p>
<p><strong>Allow:</strong> Allows file editing with standard checks</p>
<p><strong>Ask:</strong> Prompts for confirmation before editing</p>
<p><strong>Deny:</strong> Blocks all file editing operations</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Bash Permission
</label>
<div className="flex gap-1 w-fit">
<Button
size="sm"
variant={bashPermission === 'allow' ? 'default' : 'outline'}
onClick={() => setBashPermission('allow')}
className="h-6 px-2 text-xs"
>
Allow
</Button>
<Button
size="sm"
variant={bashPermission === 'ask' ? 'default' : 'outline'}
onClick={() => setBashPermission('ask')}
className="h-6 px-2 text-xs"
>
Ask
</Button>
<Button
size="sm"
variant={bashPermission === 'deny' ? 'default' : 'outline'}
onClick={() => setBashPermission('deny')}
className="h-6 px-2 text-xs"
>
Deny
</Button>
</div>
<div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground">
Permission for running bash commands
</p>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<div className="space-y-1">
<p><strong>Allow:</strong> Run bash commands without confirmation</p>
<p><strong>Ask:</strong> Prompt for confirmation before execution</p>
<p><strong>Deny:</strong> Block all bash command execution</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
WebFetch Permission
</label>
<div className="flex gap-1 w-fit">
<Button
size="sm"
variant={webfetchPermission === 'allow' ? 'default' : 'outline'}
onClick={() => setWebfetchPermission('allow')}
className="h-6 px-2 text-xs"
>
Allow
</Button>
<Button
size="sm"
variant={webfetchPermission === 'ask' ? 'default' : 'outline'}
onClick={() => setWebfetchPermission('ask')}
className="h-6 px-2 text-xs"
>
Ask
</Button>
<Button
size="sm"
variant={webfetchPermission === 'deny' ? 'default' : 'outline'}
onClick={() => setWebfetchPermission('deny')}
className="h-6 px-2 text-xs"
>
Deny
</Button>
</div>
<div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground">
Permission for fetching web content
</p>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<div className="space-y-1">
<p><strong>Allow:</strong> Fetch web content without confirmation</p>
<p><strong>Ask:</strong> Prompt for confirmation before fetching</p>
<p><strong>Deny:</strong> Block all web content access</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{}
<div className="flex justify-end border-t border-border/40 pt-4">
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving}
className="gap-2 h-6 px-2 text-xs w-fit"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</ScrollableOverlay>
);
};
@@ -0,0 +1,318 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine } from '@remixicon/react';
import { useAgentsStore } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
export const AgentsSidebar: React.FC = () => {
const [newAgentName, setNewAgentName] = React.useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
const {
selectedAgentName,
agents,
setSelectedAgent,
deleteAgent,
loadAgents,
} = useAgentsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
const handleCreateAgent = () => {
if (!newAgentName.trim()) {
toast.error('Agent name is required');
return;
}
if (agents.some((agent) => agent.name === newAgentName)) {
toast.error('An agent with this name already exists');
return;
}
setSelectedAgent(newAgentName);
setNewAgentName('');
setIsCreateDialogOpen(false);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteAgent = async (agent: Agent) => {
if (agent.builtIn) {
toast.error('Built-in agents cannot be deleted');
return;
}
if (window.confirm(`Are you sure you want to delete agent "${agent.name}"?`)) {
const success = await deleteAgent(agent.name);
if (success) {
toast.success(`Agent "${agent.name}" deleted successfully`);
} else {
toast.error('Failed to delete agent');
}
}
};
const handleDuplicateAgent = (agent: Agent) => {
const baseName = agent.name;
let copyNumber = 1;
let newName = `${baseName} Copy`;
while (agents.some((a) => a.name === newName)) {
copyNumber++;
newName = `${baseName} Copy ${copyNumber}`;
}
setSelectedAgent(newName);
setIsCreateDialogOpen(false);
if (isMobile) {
setSidebarOpen(false);
}
};
const getAgentModeIcon = (mode?: string) => {
switch (mode) {
case 'primary':
return <RiAiAgentLine className="h-3 w-3 text-primary" />;
case 'all':
return <RiAiAgentFill className="h-3 w-3 text-primary" />;
case 'subagent':
return <RiRobotLine className="h-3 w-3 text-primary" />;
default:
return null;
}
};
const builtInAgents = agents.filter((agent) => agent.builtIn);
const customAgents = agents.filter((agent) => !agent.builtIn);
return (
<div className="flex h-full flex-col 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">{agents.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>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{agents.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiRobot2Line className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No agents configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{builtInAgents.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Built-in Agents
</div>
{builtInAgents.map((agent) => (
<AgentListItem
key={agent.name}
agent={agent}
isSelected={selectedAgentName === agent.name}
onSelect={() => {
setSelectedAgent(agent.name);
if (isMobile) {
setSidebarOpen(false);
}
}}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
/>
))}
</>
)}
{customAgents.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Custom Agents
</div>
{customAgents.map((agent) => (
<AgentListItem
key={agent.name}
agent={agent}
isSelected={selectedAgentName === agent.name}
onSelect={() => {
setSelectedAgent(agent.name);
if (isMobile) {
setSidebarOpen(false);
}
}}
onDelete={() => handleDeleteAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Agent</DialogTitle>
<DialogDescription>
Enter a unique name for your new agent
</DialogDescription>
</DialogHeader>
<Input
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
placeholder="Agent name..."
className="text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateAgent();
}
}}
/>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsCreateDialogOpen(false)}
className="text-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleCreateAgent}>
Create
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface AgentListItemProps {
agent: Agent;
isSelected: boolean;
onSelect: () => void;
onDelete?: () => void;
onDuplicate: () => void;
getAgentModeIcon: (mode?: string) => React.ReactNode;
}
const AgentListItem: React.FC<AgentListItemProps> = ({
agent,
isSelected,
onSelect,
onDelete,
onDuplicate,
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>
{}
{getAgentModeIcon(agent.mode)}
</div>
{}
{agent.description && (
<div className="typography-meta text-muted-foreground truncate mt-0.5">
{agent.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>
{!agent.builtIn && onDelete && (
<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>
</div>
);
};
@@ -0,0 +1,300 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { RiArrowDownSLine, RiArrowRightSLine, RiPencilAiLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
interface ModelSelectorProps {
providerId: string;
modelId: string;
onChange: (providerId: string, modelId: string) => void;
className?: string;
}
export const ModelSelector: React.FC<ModelSelectorProps> = ({
providerId,
modelId,
onChange,
className
}) => {
const { providers, modelsMetadata } = useConfigStore();
const isMobile = useUIStore(state => state.isMobile);
const { isMobile: deviceIsMobile } = useDeviceInfo();
const isActuallyMobile = isMobile || deviceIsMobile;
const [isMobilePanelOpen, setIsMobilePanelOpen] = React.useState(false);
const [expandedMobileProviders, setExpandedMobileProviders] = React.useState<Set<string>>(new Set());
const closeMobilePanel = () => setIsMobilePanelOpen(false);
const toggleMobileProviderExpansion = (providerId: string) => {
setExpandedMobileProviders(prev => {
const newSet = new Set(prev);
if (newSet.has(providerId)) {
newSet.delete(providerId);
} else {
newSet.add(providerId);
}
return newSet;
});
};
const getModelDisplayName = (model: Record<string, unknown>) => {
const name = model?.name || model?.id || '';
const nameStr = String(name);
if (nameStr.length > 40) {
return nameStr.substring(0, 37) + '...';
}
return nameStr;
};
const getModelMetadata = (providerId: string, modelId: string) => {
const key = `${providerId}/${modelId}`;
return modelsMetadata.get(key);
};
const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => {
onChange(newProviderId, newModelId);
};
const renderMobileModelPanel = () => {
if (!isActuallyMobile) return null;
return (
<MobileOverlayPanel
open={isMobilePanelOpen}
onClose={closeMobilePanel}
title="Select Model"
>
<div className="space-y-1">
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
if (providerModels.length === 0) return null;
const isActiveProvider = provider.id === providerId;
const isExpanded = expandedMobileProviders.has(provider.id);
return (
<div key={provider.id} className="rounded-xl border border-border/40 bg-background/95">
<button
type="button"
className="flex w-full items-center justify-between gap-1.5 px-2 py-1.5 text-left"
onClick={() => toggleMobileProviderExpansion(provider.id)}
>
<div className="flex items-center gap-2">
<ProviderLogo
providerId={provider.id}
className="h-3.5 w-3.5"
/>
<span className="typography-meta font-medium text-foreground">
{provider.name}
</span>
{isActiveProvider && (
<span className="typography-micro text-primary/80">Current</span>
)}
</div>
{isExpanded ? (
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-3 w-3 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="border-t border-border/20">
{providerModels.map((modelItem: ProviderModel) => {
const isSelectedModel = provider.id === providerId && modelItem.id === modelId;
const metadata = getModelMetadata(provider.id as string, modelItem.id as string);
return (
<button
key={modelItem.id as string}
type="button"
className={cn(
'flex w-full items-center justify-between px-2 py-1.5 text-left',
'typography-meta',
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
onClick={() => {
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
closeMobilePanel();
}}
>
<div className="flex flex-col">
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
{isSelectedModel && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</button>
);
})}
</div>
)}
</div>
);
})}
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left"
onClick={() => {
handleProviderAndModelChange('', '');
closeMobilePanel();
}}
>
<span className="typography-meta text-muted-foreground">No model (optional)</span>
</button>
</div>
</MobileOverlayPanel>
);
};
return (
<>
{isActuallyMobile ? (
<button
type="button"
onClick={() => setIsMobilePanelOpen(true)}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
className
)}
>
<div className="flex items-center gap-2">
{providerId ? (
<ProviderLogo
providerId={providerId}
className="h-3.5 w-3.5"
/>
) : (
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
)}
<span className="typography-meta font-medium text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : 'Select model...'}
</span>
</div>
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
</button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className={cn(
'flex items-center gap-2 px-2 rounded-lg bg-accent/20 border border-border/20 cursor-pointer hover:bg-accent/30 h-6 w-fit',
className
)}>
{providerId ? (
<>
<ProviderLogo
providerId={providerId}
className="h-3 w-3 flex-shrink-0"
/>
<RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" />
</>
) : (
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
)}
<span className="typography-micro font-medium whitespace-nowrap">
{providerId && modelId ? `${providerId}/${modelId}` : 'Not selected'}
</span>
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[300px]">
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
if (providerModels.length === 0) {
return (
<DropdownMenuItem
key={provider.id}
disabled
className="typography-meta text-muted-foreground"
>
<ProviderLogo
providerId={provider.id}
className="h-3 w-3 flex-shrink-0 mr-2"
/>
{provider.name} (No models)
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub key={provider.id}>
<DropdownMenuSubTrigger className="typography-meta">
<ProviderLogo
providerId={provider.id}
className="h-3 w-3 flex-shrink-0 mr-2"
/>
{provider.name}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
className="space-y-1 p-1"
>
{providerModels.map((modelItem: ProviderModel) => {
const metadata = getModelMetadata(provider.id as string, modelItem.id as string);
return (
<DropdownMenuItem
key={modelItem.id as string}
className="typography-meta"
onSelect={() => {
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
}}
>
<div className="flex flex-col">
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-meta text-muted-foreground">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleProviderAndModelChange('', '')}
>
<span className="text-muted-foreground">No model (optional)</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{renderMobileModelPanel()}
</>
);
};