import React, { useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui'; import { isMobileDeviceViaCSS } from '@/lib/device'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import { useSettingsDirectory } from '@/hooks/useSettingsDirectory'; import { selectAgentsForDirectory, useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore'; import { useShallow } from 'zustand/react/shallow'; import { cn } from '@/lib/utils'; import type { Agent } from '@opencode-ai/sdk/v2'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector'; import { SidebarGroup } from '@/components/sections/shared/SidebarGroup'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection'; interface AgentsSidebarProps { onItemSelect?: () => void; } type PermissionAction = 'allow' | 'ask' | 'deny'; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; type PermissionConfigValue = PermissionAction | Record; 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 normalizeRuleset = (ruleset: PermissionRule[]): PermissionRule[] => { const map = new Map(); for (const rule of ruleset) { if (!rule.permission || rule.permission === 'invalid') { continue; } if (!rule.pattern) { continue; } map.set(`${rule.permission}::${rule.pattern}`, rule); } return Array.from(map.values()); }; const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] => { const parsed = normalizeRuleset(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; } const result: Record = {}; for (const [permissionName, map] of Object.entries(byPermission)) { const patterns = Object.keys(map); if (patterns.length === 1 && patterns[0] === '*') { result[permissionName] = map['*']; continue; } result[permissionName] = map; } return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined; }; export const AgentsSidebar: React.FC = ({ onItemSelect }) => { const { t } = useI18n(); const [renameDialogAgent, setRenameDialogAgent] = React.useState(null); const [renameNewName, setRenameNewName] = React.useState(''); const [confirmActionAgent, setConfirmActionAgent] = React.useState(null); const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null); const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false); const [openMenuAgent, setOpenMenuAgent] = React.useState(null); const { selectedAgentName, setSelectedAgent, setAgentDraft, createAgent, deleteAgent, loadAgents, } = useAgentsStore(useShallow((s) => ({ selectedAgentName: s.selectedAgentName, setSelectedAgent: s.setSelectedAgent, setAgentDraft: s.setAgentDraft, createAgent: s.createAgent, deleteAgent: s.deleteAgent, loadAgents: s.loadAgents, }))); // Settings browses whichever project its own selector points at; the app // stays where it is. const settingsDirectory = useSettingsDirectory(); const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory)); React.useEffect(() => { void loadAgents(settingsDirectory); }, [loadAgents, settingsDirectory]); const bgClass = 'bg-background'; 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?.(); }; const handleDeleteAgent = async (agent: Agent) => { if (isAgentBuiltIn(agent)) { toast.error(t('settings.agents.sidebar.toast.builtInCannotDelete')); return; } setConfirmActionAgent(agent); setConfirmActionType('delete'); }; const handleResetAgent = async (agent: Agent) => { if (!isAgentBuiltIn(agent)) { return; } setConfirmActionAgent(agent); setConfirmActionType('reset'); }; const closeConfirmActionDialog = () => { setConfirmActionAgent(null); setConfirmActionType(null); }; const handleConfirmAction = async () => { if (!confirmActionAgent || !confirmActionType) { return; } setIsConfirmActionPending(true); try { const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope, settingsDirectory); if (result.ok) { if (result.requiresManualRestart) { toast.warning(t('settings.agents.page.toast.savedManualRestart')); } else if (result.restartDeferred) { toast.success(t('settings.view.pendingRestart.saved')); } else if (confirmActionType === 'delete') { toast.success(t('settings.agents.sidebar.toast.agentDeleted', { name: confirmActionAgent.name })); } else { toast.success(t('settings.agents.sidebar.toast.agentReset', { name: confirmActionAgent.name })); } closeConfirmActionDialog(); } else if (confirmActionType === 'delete') { toast.error(t('settings.agents.sidebar.toast.deleteFailed')); } else { toast.error(t('settings.agents.sidebar.toast.resetFailed')); } } catch (error) { const message = error instanceof Error ? error.message : ''; const definitionMissing = /built-in|not deletable|not found/i.test(message); if (confirmActionType === 'delete') { toast.error(definitionMissing ? t('settings.agents.sidebar.toast.definitionNotFound') : t('settings.agents.sidebar.toast.deleteFailed')); } else { toast.error(t('settings.agents.sidebar.toast.resetFailed')); } } setIsConfirmActionPending(false); }; 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, variant: agent.variant, temperature: agent.temperature, top_p: agent.topP, prompt: agent.prompt, mode: agent.mode, permission: rulesetToPermissionConfig(agent.permission), disable: draftAgent.disable, }); setSelectedAgent(newName); onItemSelect?.(); }; 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(t('settings.agents.sidebar.toast.agentNameRequired')); return; } if (sanitizedName === renameDialogAgent.name) { setRenameDialogAgent(null); return; } if (agents.some((a) => a.name === sanitizedName)) { toast.error(t('settings.agents.sidebar.toast.agentExists')); 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 createResult = await createAgent({ name: sanitizedName, description: renameDialogAgent.description, model: renameModelStr, variant: renameDialogAgent.variant, temperature: renameDialogAgent.temperature, top_p: renameDialogAgent.topP, prompt: renameDialogAgent.prompt, mode: renameDialogAgent.mode, permission: rulesetToPermissionConfig(renameDialogAgent.permission), disable: renameExt.disable, scope: renameExt.scope, }, settingsDirectory); if (createResult.ok) { // Delete old agent const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope, settingsDirectory); if (deleteResult.ok) { if (createResult.requiresManualRestart || deleteResult.requiresManualRestart) { toast.warning(t('settings.agents.page.toast.savedManualRestart')); } else { toast.success(t('settings.agents.sidebar.toast.agentRenamed', { name: sanitizedName })); } setSelectedAgent(sanitizedName); } else { toast.error(t('settings.agents.sidebar.toast.removeOldAfterRenameFailed')); } } else { toast.error(t('settings.agents.sidebar.toast.renameFailed')); } 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)); // Group custom agents by subfolder const { groupedCustomAgents, ungroupedCustomAgents } = useMemo(() => { const groups: Record = {}; const ungrouped: typeof customAgents = []; for (const agent of customAgents) { const ext = agent as { group?: string }; if (ext.group) { if (!groups[ext.group]) groups[ext.group] = []; groups[ext.group].push(agent); } else { ungrouped.push(agent); } } const sortedGroups = Object.keys(groups) .sort((a, b) => a.localeCompare(b)) .map((name) => ({ name, agents: groups[name] })); return { groupedCustomAgents: sortedGroups, ungroupedCustomAgents: ungrouped }; }, [customAgents]); return (

{t('settings.agents.sidebar.title')}

{t('settings.agents.sidebar.total', { count: visibleAgents.length })}
{visibleAgents.length === 0 ? (

{t('settings.agents.sidebar.empty.title')}

{t('settings.agents.sidebar.empty.description')}

) : ( <> {builtInAgents.length > 0 && ( <>
{t('settings.agents.sidebar.section.builtIn')}
{builtInAgents.map((agent) => ( { setSelectedAgent(agent.name); onItemSelect?.(); }} onReset={() => handleResetAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)} getAgentModeIcon={getAgentModeIcon} isMenuOpen={openMenuAgent === agent.name} onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)} /> ))} )} {customAgents.length > 0 && ( <>
{t('settings.agents.sidebar.section.custom')}
{/* Grouped agents by subfolder */} {groupedCustomAgents.map(({ name: groupName, agents: groupAgents }) => ( {groupAgents.map((agent) => ( { setSelectedAgent(agent.name); onItemSelect?.(); }} onRename={() => handleOpenRenameDialog(agent)} onDelete={() => handleDeleteAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)} getAgentModeIcon={getAgentModeIcon} isMenuOpen={openMenuAgent === agent.name} onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)} /> ))} ))} {/* Ungrouped agents (flat in root agents dir) */} {ungroupedCustomAgents.map((agent) => ( { setSelectedAgent(agent.name); onItemSelect?.(); }} onRename={() => handleOpenRenameDialog(agent)} onDelete={() => handleDeleteAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)} getAgentModeIcon={getAgentModeIcon} isMenuOpen={openMenuAgent === agent.name} onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)} /> ))} )} )}
{ if (!open && !isConfirmActionPending) { closeConfirmActionDialog(); } }} > {confirmActionType === 'delete' ? t('settings.agents.sidebar.dialog.deleteTitle') : t('settings.agents.sidebar.dialog.resetTitle')} {confirmActionType === 'delete' ? t('settings.agents.sidebar.dialog.deleteDescription', { name: confirmActionAgent?.name ?? '' }) : t('settings.agents.sidebar.dialog.resetDescription', { name: confirmActionAgent?.name ?? '' })} {/* Rename Dialog */} !open && setRenameDialogAgent(null)}> {t('settings.agents.sidebar.renameDialog.title')} {t('settings.agents.sidebar.renameDialog.description', { name: renameDialogAgent?.name ?? '' })} setRenameNewName(e.target.value)} placeholder={t('settings.agents.sidebar.renameDialog.placeholder')} className="text-foreground placeholder:text-muted-foreground" onKeyDown={(e) => { if (e.key === 'Enter') { handleRenameAgent(); } }} />
); }; interface AgentListItemProps { agent: Agent; isSelected: boolean; onSelect: () => void; onDelete?: () => void; onReset?: () => void; onRename?: () => void; onDuplicate: () => void; getAgentModeIcon: (mode?: string) => React.ReactNode; isMenuOpen: boolean; onMenuOpenChange: (open: boolean) => void; } const AgentListItem: React.FC = ({ agent, isSelected, onSelect, onDelete, onReset, onRename, onDuplicate, getAgentModeIcon, isMenuOpen, onMenuOpenChange, }) => { const { t } = useI18n(); const extAgent = agent as Agent & { scope?: AgentScope }; const isMobile = isMobileDeviceViaCSS(); const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false); const renderMenuItems = (Item: React.ElementType) => ( <> {onRename && ( { e.stopPropagation(); onRename(); }}> {t('settings.common.actions.rename')} )} { e.stopPropagation(); onDuplicate(); }}> {t('settings.common.actions.duplicate')} {onReset && ( { e.stopPropagation(); onReset(); }}> {t('settings.common.actions.reset')} )} {onDelete && ( { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive"> {t('settings.common.actions.delete')} )} ); return ( { e.preventDefault(); setIsContextMenuOpen(true); } : undefined} />}>
{ if (open) setIsContextMenuOpen(false); onMenuOpenChange(open); }}> {renderMenuItems(DropdownMenuItem)}
{renderMenuItems(ContextMenuItem)}
); };