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 '@/components/ui'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react'; import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { cn } from '@/lib/utils'; import type { Agent } from '@opencode-ai/sdk/v2'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface AgentsSidebarProps { onItemSelect?: () => void; } type PermissionAction = 'allow' | 'ask' | 'deny'; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; type PermissionConfigValue = PermissionAction | Record; // OpenCode's built-in defaults for permissions that differ from "allow" const getOpenCodeDefaultActionForPermission = (permissionName: string): PermissionAction => { if (permissionName === 'doom_loop' || permissionName === 'external_directory') { return 'ask'; } return 'allow'; }; const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => { if (!Array.isArray(ruleset)) { return []; } const parsed: PermissionRule[] = []; for (const entry of ruleset) { if (!entry || typeof entry !== 'object') { continue; } const candidate = entry as Partial; if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { continue; } if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { continue; } parsed.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); } return parsed; }; const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] => { const parsed = toPermissionRuleset(ruleset); if (parsed.length === 0) { return undefined; } const byPermission: Record> = {}; for (const rule of parsed) { if (!rule.permission) { continue; } (byPermission[rule.permission] ||= {})[rule.pattern] = rule.action; } // Get the global default (wildcard * with pattern *) const globalDefault = byPermission['*']?.['*']; const permissionNames = Object.keys(byPermission); if ( permissionNames.length === 1 && permissionNames[0] === '*' && Object.keys(byPermission['*'] || {}).length === 1 && byPermission['*']?.['*'] ) { return byPermission['*']['*']; } const result: Record = {}; for (const permissionName of permissionNames) { const map = byPermission[permissionName]; const patterns = Object.keys(map); // For wildcard-only entries, check if they're redundant if (patterns.length === 1 && patterns[0] === '*' && permissionName !== '*') { const action = map['*']; const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); // Skip if this permission is redundant (matches effective default) if (globalDefault) { if (action === globalDefault) continue; } else { if (action === opencodeDefault) continue; } result[permissionName] = action; } else if (permissionName === '*') { // Include global default if (patterns.length === 1 && patterns[0] === '*') { result[permissionName] = map['*']; } else { result[permissionName] = map; } } else { // Non-wildcard patterns - include as-is result[permissionName] = map; } } return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined; }; export const AgentsSidebar: React.FC = ({ onItemSelect }) => { const [renameDialogAgent, setRenameDialogAgent] = React.useState(null); const [renameNewName, setRenameNewName] = React.useState(''); const { selectedAgentName, agents, setSelectedAgent, setAgentDraft, createAgent, deleteAgent, loadAgents, } = useAgentsStore(); const { setSidebarOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { if (typeof window === 'undefined') return false; return typeof window.opencodeDesktop !== 'undefined'; }); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); React.useEffect(() => { if (typeof window === 'undefined') return; setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); }, []); React.useEffect(() => { loadAgents(); }, [loadAgents]); const bgClass = isDesktopRuntime ? 'bg-transparent' : isVSCode ? 'bg-background' : 'bg-sidebar'; const handleCreateNew = () => { // Generate unique name const baseName = 'new-agent'; let newName = baseName; let counter = 1; while (agents.some((a) => a.name === newName)) { newName = `${baseName}-${counter}`; counter++; } // Set draft and open the page for editing setAgentDraft({ name: newName, scope: 'user' }); setSelectedAgent(newName); onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } }; const handleDeleteAgent = async (agent: Agent) => { if (isAgentBuiltIn(agent)) { 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 handleResetAgent = async (agent: Agent) => { if (!isAgentBuiltIn(agent)) { return; } if (window.confirm(`Are you sure you want to reset agent "${agent.name}" to its default configuration?`)) { const success = await deleteAgent(agent.name); if (success) { toast.success(`Agent "${agent.name}" reset to default`); } else { toast.error('Failed to reset 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}`; } // Set draft with prefilled values from source agent const extAgent = agent as Agent & { scope?: AgentScope }; const modelStr = agent.model?.providerID && agent.model?.modelID ? `${agent.model.providerID}/${agent.model.modelID}` : null; const draftAgent = agent as Agent & { disable?: boolean }; setAgentDraft({ name: newName, scope: extAgent.scope || 'user', description: agent.description, model: modelStr, temperature: agent.temperature, top_p: agent.topP, prompt: agent.prompt, mode: agent.mode, permission: rulesetToPermissionConfig(agent.permission), disable: draftAgent.disable, }); setSelectedAgent(newName); if (isMobile) { setSidebarOpen(false); } }; const handleOpenRenameDialog = (agent: Agent) => { setRenameNewName(agent.name); setRenameDialogAgent(agent); }; const handleRenameAgent = async () => { if (!renameDialogAgent) return; const sanitizedName = renameNewName.trim().replace(/\s+/g, '-'); if (!sanitizedName) { toast.error('Agent name is required'); return; } if (sanitizedName === renameDialogAgent.name) { setRenameDialogAgent(null); return; } if (agents.some((a) => a.name === sanitizedName)) { toast.error('An agent with this name already exists'); return; } // Create new agent with new name and all existing config const renameModelStr = renameDialogAgent.model?.providerID && renameDialogAgent.model?.modelID ? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}` : null; const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean }; const success = await createAgent({ name: sanitizedName, description: renameDialogAgent.description, model: renameModelStr, temperature: renameDialogAgent.temperature, top_p: renameDialogAgent.topP, prompt: renameDialogAgent.prompt, mode: renameDialogAgent.mode, permission: rulesetToPermissionConfig(renameDialogAgent.permission), disable: renameExt.disable, scope: renameExt.scope, }); if (success) { // Delete old agent const deleteSuccess = await deleteAgent(renameDialogAgent.name); if (deleteSuccess) { toast.success(`Agent renamed to "${sanitizedName}"`); setSelectedAgent(sanitizedName); } else { toast.error('Failed to remove old agent after rename'); } } else { toast.error('Failed to rename agent'); } setRenameDialogAgent(null); }; const getAgentModeIcon = (mode?: string) => { switch (mode) { case 'primary': return ; case 'all': return ; case 'subagent': return ; default: return null; } }; // Filter out hidden agents (internal agents like title, compaction, summary) const visibleAgents = agents.filter((agent) => !isAgentHidden(agent)); const builtInAgents = visibleAgents.filter(isAgentBuiltIn); const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent)); return (
Total {visibleAgents.length}
{visibleAgents.length === 0 ? (

No agents configured

Use the + button above to create one

) : ( <> {builtInAgents.length > 0 && ( <>
Built-in Agents
{builtInAgents.map((agent) => ( { setSelectedAgent(agent.name); onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } }} onReset={() => handleResetAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)} getAgentModeIcon={getAgentModeIcon} /> ))} )} {customAgents.length > 0 && ( <>
Custom Agents
{customAgents.map((agent) => ( { setSelectedAgent(agent.name); onItemSelect?.(); if (isMobile) { setSidebarOpen(false); } }} onRename={() => handleOpenRenameDialog(agent)} onDelete={() => handleDeleteAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)} getAgentModeIcon={getAgentModeIcon} /> ))} )} )}
{/* Rename Dialog */} !open && setRenameDialogAgent(null)}> Rename Agent Enter a new name for the agent "@{renameDialogAgent?.name}" setRenameNewName(e.target.value)} placeholder="New agent name..." className="text-foreground placeholder:text-muted-foreground" onKeyDown={(e) => { if (e.key === 'Enter') { handleRenameAgent(); } }} /> Rename
); }; interface AgentListItemProps { agent: Agent; isSelected: boolean; onSelect: () => void; onDelete?: () => void; onReset?: () => void; onRename?: () => void; onDuplicate: () => void; getAgentModeIcon: (mode?: string) => React.ReactNode; } const AgentListItem: React.FC = ({ agent, isSelected, onSelect, onDelete, onReset, onRename, onDuplicate, getAgentModeIcon, }) => { const extAgent = agent as Agent & { scope?: AgentScope }; return (
{onRename && ( { e.stopPropagation(); onRename(); }} > Rename )} { e.stopPropagation(); onDuplicate(); }} > Duplicate {onReset && ( { e.stopPropagation(); onReset(); }} > Reset )} {onDelete && ( { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive" > Delete )}
); };