import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { NumberInput } from '@/components/ui/number-input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore'; import { useDirectorySync } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDeviceInfo } from '@/lib/device'; import { opencodeClient } from '@/lib/opencode/client'; import { RiCloseLine, RiInformationLine, RiRobot2Line, RiSubtractLine, RiUser3Line, RiFolderLine } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; type PermissionAction = 'allow' | 'ask' | 'deny'; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; type PermissionConfigValue = PermissionAction | Record; type PermissionRuleKey = `${string}::${string}`; const STANDARD_PERMISSION_KEYS = [ '*', 'read', 'edit', 'glob', 'grep', 'list', 'bash', 'task', 'skill', 'lsp', 'todoread', 'todowrite', 'webfetch', 'websearch', 'codesearch', 'external_directory', 'doom_loop', 'question', 'plan_enter', 'plan_exit', ] as const; const isPermissionAction = (value: unknown): value is PermissionAction => value === 'allow' || value === 'ask' || value === 'deny'; const buildRuleKey = (permission: string, pattern: string): PermissionRuleKey => `${permission}::${pattern}`; 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; } if (!isPermissionAction(rule.action)) { continue; } map.set(buildRuleKey(rule.permission, rule.pattern), { permission: rule.permission, pattern: rule.pattern, action: rule.action, }); } return Array.from(map.values()); }; const buildRuleMap = (ruleset: PermissionRule[]): Map => { const map = new Map(); for (const rule of normalizeRuleset(ruleset)) { map.set(buildRuleKey(rule.permission, rule.pattern), rule); } return map; }; const sortRules = (ruleset: PermissionRule[]): PermissionRule[] => [...ruleset].sort((a, b) => { const permissionCompare = a.permission.localeCompare(b.permission); if (permissionCompare !== 0) return permissionCompare; return a.pattern.localeCompare(b.pattern); }); const areRulesEqual = (a: PermissionRule[], b: PermissionRule[]): boolean => { const sortedA = sortRules(normalizeRuleset(a)); const sortedB = sortRules(normalizeRuleset(b)); if (sortedA.length !== sortedB.length) { return false; } return sortedA.every((rule, index) => { const other = sortedB[index]; return rule.permission === other.permission && rule.pattern === other.pattern && rule.action === other.action; }); }; const getGlobalWildcardAction = (ruleset: PermissionRule[]): PermissionAction => { const globalRule = ruleset.find((rule) => rule.permission === '*' && rule.pattern === '*'); return globalRule?.action ?? 'allow'; }; const filterRulesAgainstGlobal = (ruleset: PermissionRule[], globalAction: PermissionAction): PermissionRule[] => ( normalizeRuleset(ruleset) .filter((rule) => !(rule.permission === '*' && rule.pattern === '*')) // Keep wildcard overrides only when they differ from global. .filter((rule) => rule.pattern !== '*' || rule.action !== globalAction) ); const permissionConfigToRuleset = (value: unknown): PermissionRule[] => { if (isPermissionAction(value)) { return [{ permission: '*', pattern: '*', action: value }]; } if (!value || typeof value !== 'object' || Array.isArray(value)) { return []; } const rules: PermissionRule[] = []; for (const [permissionName, configValue] of Object.entries(value as Record)) { if (permissionName === '__originalKeys') { continue; } if (isPermissionAction(configValue)) { rules.push({ permission: permissionName, pattern: '*', action: configValue }); continue; } if (configValue && typeof configValue === 'object' && !Array.isArray(configValue)) { for (const [pattern, action] of Object.entries(configValue as Record)) { if (isPermissionAction(action)) { rules.push({ permission: permissionName, pattern, action }); } } } } return rules; }; const buildPermissionConfigWithGlobal = ( globalAction: PermissionAction, ruleset: PermissionRule[], ): AgentConfig['permission'] => { const normalized = normalizeRuleset(ruleset); const grouped: Record> = {}; for (const rule of normalized) { (grouped[rule.permission] ||= {})[rule.pattern] = rule.action; } const result: Record = { '*': globalAction, }; for (const [permissionName, patterns] of Object.entries(grouped)) { if (permissionName === '*') { continue; } if (Object.keys(patterns).length === 1 && patterns['*']) { result[permissionName] = patterns['*']; continue; } result[permissionName] = patterns; } return result as AgentConfig['permission']; }; export const AgentsPage: React.FC = () => { const { isMobile } = useDeviceInfo(); const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore(); const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null; const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent); const [draftName, setDraftName] = React.useState(''); const [draftScope, setDraftScope] = React.useState('user'); const [description, setDescription] = React.useState(''); const [mode, setMode] = React.useState<'primary' | 'subagent' | 'all'>('subagent'); const [model, setModel] = React.useState(''); const [temperature, setTemperature] = React.useState(undefined); const [topP, setTopP] = React.useState(undefined); const [prompt, setPrompt] = React.useState(''); const [globalPermission, setGlobalPermission] = React.useState('allow'); const [permissionBaseline, setPermissionBaseline] = React.useState([]); const [permissionRules, setPermissionRules] = React.useState([]); const [pendingRuleName, setPendingRuleName] = React.useState(''); const [pendingRulePattern, setPendingRulePattern] = React.useState('*'); const [showPermissionEditor, setShowPermissionEditor] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); const initialStateRef = React.useRef<{ draftName: string; draftScope: AgentScope; description: string; mode: 'primary' | 'subagent' | 'all'; model: string; temperature: number | undefined; topP: number | undefined; prompt: string; globalPermission: PermissionAction; permissionRules: PermissionRule[]; } | null>(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const [toolIds, setToolIds] = React.useState([]); const permissionsBySession = useDirectorySync((state) => state.permission); React.useEffect(() => { let cancelled = false; const fetchToolIds = async () => { const ids = await opencodeClient.listToolIds({ directory: currentDirectory }); if (cancelled) { return; } // OpenCode permissions are keyed by tool name, but some tools are grouped // under a single permission key. E.g. `edit` covers `write`, `patch`, and `multiedit`. const editCoveredToolIds = new Set(['write', 'patch', 'multiedit']); const normalized = ids .map((id) => (typeof id === 'string' ? id.trim() : '')) .filter(Boolean) .filter((id) => id !== '*') .filter((id) => id !== 'invalid') .filter((id) => !editCoveredToolIds.has(id)); setToolIds(Array.from(new Set(normalized)).sort((a, b) => a.localeCompare(b))); }; void fetchToolIds(); return () => { cancelled = true; }; }, [currentDirectory]); const knownPermissionNames = React.useMemo(() => { const names = new Set(); for (const agent of agents) { const rules = normalizeRuleset(Array.isArray(agent.permission) ? agent.permission as PermissionRule[] : []); for (const rule of rules) { if (rule.permission && rule.permission !== '*' && rule.permission !== 'invalid') { names.add(rule.permission); } } } for (const permissions of Object.values(permissionsBySession)) { for (const request of permissions) { const permissionName = request.permission?.trim(); if (permissionName && permissionName !== 'invalid') { names.add(permissionName); } } } for (const toolId of toolIds) { names.add(toolId); } return Array.from(names).sort((a, b) => a.localeCompare(b)); }, [agents, permissionsBySession, toolIds]); const baselineRuleMap = React.useMemo(() => buildRuleMap(permissionBaseline), [permissionBaseline]); const currentRuleMap = React.useMemo(() => buildRuleMap(permissionRules), [permissionRules]); const getWildcardOverride = React.useCallback((permissionName: string): PermissionAction | undefined => ( currentRuleMap.get(buildRuleKey(permissionName, '*'))?.action ), [currentRuleMap]); const getPatternRules = React.useCallback((permissionName: string): PermissionRule[] => ( permissionRules .filter((rule) => rule.permission === permissionName && rule.pattern !== '*') .sort((a, b) => a.pattern.localeCompare(b.pattern)) ), [permissionRules]); const summaryPermissionNames = React.useMemo(() => { const names = new Set(); for (const key of STANDARD_PERMISSION_KEYS) { names.add(key); } for (const key of knownPermissionNames) { names.add(key); } return Array.from(names).sort((a, b) => a.localeCompare(b)); }, [knownPermissionNames]); const getPermissionSummary = React.useCallback((permissionName: string) => { const defaultAction = permissionName === '*' ? globalPermission : (getWildcardOverride(permissionName) ?? globalPermission); const patternRules = getPatternRules(permissionName); const hasDefaultHint = false; const patternCounts = patternRules.reduce>((acc, rule) => { acc[rule.action] = (acc[rule.action] ?? 0) + 1; return acc; }, { allow: 0, ask: 0, deny: 0 }); const patternSummary = (['allow', 'ask', 'deny'] as const) .filter((action) => patternCounts[action] > 0) .map((action) => `${patternCounts[action]} ${action}`) .join(', '); return { defaultAction, patternRulesCount: patternRules.length, patternSummary, hasDefaultHint, }; }, [getPatternRules, getWildcardOverride, globalPermission]); const availablePermissionNames = React.useMemo(() => { const names = new Set(); for (const key of STANDARD_PERMISSION_KEYS) { names.add(key); } for (const key of knownPermissionNames) { names.add(key); } return Array.from(names).sort((a, b) => a.localeCompare(b)); }, [knownPermissionNames]); const upsertRule = React.useCallback((permissionName: string, pattern: string, action: PermissionAction) => { setPermissionRules((prev) => { const map = buildRuleMap(prev); map.set(buildRuleKey(permissionName, pattern), { permission: permissionName, pattern, action }); return Array.from(map.values()); }); }, []); const removeRule = React.useCallback((permissionName: string, pattern: string) => { setPermissionRules((prev) => { const map = buildRuleMap(prev); map.delete(buildRuleKey(permissionName, pattern)); return Array.from(map.values()); }); }, []); const revertRule = React.useCallback((permissionName: string, pattern: string) => { const baseline = baselineRuleMap.get(buildRuleKey(permissionName, pattern)); if (baseline) { upsertRule(permissionName, pattern, baseline.action); return; } removeRule(permissionName, pattern); }, [baselineRuleMap, removeRule, upsertRule]); const setRuleAction = React.useCallback((permissionName: string, pattern: string, action: PermissionAction) => { upsertRule(permissionName, pattern, action); }, [upsertRule]); const setGlobalPermissionAndPrune = React.useCallback((next: PermissionAction) => { setGlobalPermission(next); setPermissionRules((prev) => prev.filter((rule) => !(rule.pattern === '*' && rule.action === next))); }, []); const applyPendingRule = React.useCallback((action: PermissionAction) => { const name = pendingRuleName.trim(); if (!name) { toast.error('Permission name is required'); return; } const pattern = pendingRulePattern.trim() || '*'; if (name === '*' && pattern === '*') { setGlobalPermissionAndPrune(action); setPendingRuleName(''); setPendingRulePattern('*'); return; } if (pattern === '*' && name !== '*' && action === globalPermission) { removeRule(name, '*'); } else { upsertRule(name, pattern, action); } setPendingRuleName(''); setPendingRulePattern('*'); }, [globalPermission, pendingRuleName, pendingRulePattern, removeRule, setGlobalPermissionAndPrune, upsertRule]); const formatPermissionLabel = React.useCallback((permissionName: string): string => { if (permissionName === '*') return 'Default'; if (permissionName === 'webfetch') return 'WebFetch'; if (permissionName === 'websearch') return 'WebSearch'; if (permissionName === 'codesearch') return 'CodeSearch'; if (permissionName === 'doom_loop') return 'Doom Loop'; if (permissionName === 'external_directory') return 'External Directory'; if (permissionName === 'todowrite') return 'TodoWrite'; if (permissionName === 'todoread') return 'TodoRead'; return permissionName .split(/[_-]+/g) .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(' '); }, []); React.useEffect(() => { setPendingRuleName(''); setPendingRulePattern('*'); const applyPermissionState = (rules: PermissionRule[]) => { const normalized = normalizeRuleset(rules); const nextGlobal = getGlobalWildcardAction(normalized); const filtered = filterRulesAgainstGlobal(normalized, nextGlobal); setGlobalPermission(nextGlobal); setPermissionBaseline(filtered); setPermissionRules(filtered); return { global: nextGlobal, rules: filtered }; }; if (isNewAgent && agentDraft) { const draftNameValue = agentDraft.name || ''; const draftScopeValue = agentDraft.scope || 'user'; const descriptionValue = agentDraft.description || ''; const modeValue = agentDraft.mode || 'subagent'; const modelValue = agentDraft.model || ''; const temperatureValue = agentDraft.temperature; const topPValue = agentDraft.top_p; const promptValue = agentDraft.prompt || ''; setDraftName(draftNameValue); setDraftScope(draftScopeValue); setDescription(descriptionValue); setMode(modeValue); setModel(modelValue); setTemperature(temperatureValue); setTopP(topPValue); setPrompt(promptValue); const parsedRules = permissionConfigToRuleset(agentDraft.permission); const permissionState = applyPermissionState(parsedRules); initialStateRef.current = { draftName: draftNameValue, draftScope: draftScopeValue, description: descriptionValue, mode: modeValue, model: modelValue, temperature: temperatureValue, topP: topPValue, prompt: promptValue, globalPermission: permissionState.global, permissionRules: permissionState.rules, }; return; } if (selectedAgent && selectedAgentName === selectedAgent.name) { const descriptionValue = selectedAgent.description || ''; const modeValue = selectedAgent.mode || 'subagent'; const modelValue = selectedAgent.model?.providerID && selectedAgent.model?.modelID ? `${selectedAgent.model.providerID}/${selectedAgent.model.modelID}` : ''; const temperatureValue = selectedAgent.temperature; const topPValue = selectedAgent.topP; const promptValue = selectedAgent.prompt || ''; setDescription(descriptionValue); setMode(modeValue); setModel(modelValue); setTemperature(temperatureValue); setTopP(topPValue); setPrompt(promptValue); const permissionState = applyPermissionState( Array.isArray(selectedAgent.permission) ? selectedAgent.permission as PermissionRule[] : [], ); initialStateRef.current = { draftName: '', draftScope: 'user', description: descriptionValue, mode: modeValue, model: modelValue, temperature: temperatureValue, topP: topPValue, prompt: promptValue, globalPermission: permissionState.global, permissionRules: permissionState.rules, }; } }, [agentDraft, isNewAgent, selectedAgent, selectedAgentName]); const isDirty = React.useMemo(() => { const initial = initialStateRef.current; if (!initial) { return false; } if (isNewAgent) { if (draftName !== initial.draftName) return true; if (draftScope !== initial.draftScope) return true; } if (description !== initial.description) return true; if (mode !== initial.mode) return true; if (model !== initial.model) return true; if (temperature !== initial.temperature) return true; if (topP !== initial.topP) return true; if (prompt !== initial.prompt) return true; if (globalPermission !== initial.globalPermission) return true; if (!areRulesEqual(permissionRules, initial.permissionRules)) return true; return false; }, [description, draftName, draftScope, globalPermission, isNewAgent, mode, model, permissionRules, prompt, temperature, topP]); const handleSave = async () => { const agentName = isNewAgent ? draftName.trim().replace(/\s+/g, '-') : selectedAgentName?.trim(); if (!agentName) { toast.error('Agent name is required'); return; } // Check for duplicate name when creating new agent if (isNewAgent && agents.some((a) => a.name === agentName)) { toast.error('An agent with this name already exists'); return; } setIsSaving(true); try { const trimmedModel = model.trim(); const permissionConfig = buildPermissionConfigWithGlobal(globalPermission, permissionRules); const config: AgentConfig = { name: agentName, description: description.trim() || undefined, mode, model: trimmedModel === '' ? null : trimmedModel, temperature, top_p: topP, prompt: prompt.trim() || undefined, permission: permissionConfig, scope: isNewAgent ? draftScope : undefined, }; let success: boolean; if (isNewAgent) { success = await createAgent(config); if (success) { setAgentDraft(null); // Clear draft after successful creation } } else { success = await updateAgent(agentName, 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); const message = error instanceof Error && error.message ? error.message : 'An error occurred while saving'; toast.error(message); } finally { setIsSaving(false); } }; if (!selectedAgentName) { return (

Select an agent from the sidebar

or create a new one

); } return (
{/* Header & Actions */}

{isNewAgent ? 'New Agent' : selectedAgentName}

{isNewAgent ? 'Configure a new assistant persona' : 'Edit agent settings'}

{/* Identity & Role */}

Identity & Role

{isNewAgent && (
Agent Name
@ setDraftName(e.target.value)} placeholder="agent-name" className="h-7 w-40 px-2" />
)}
Description