From a9952fd8da1b87ddee71b91c99483b4dd674d4f6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 26 Jan 2026 16:09:46 +0200 Subject: [PATCH] Refactor permission management across UI components and stores (#221) * refactor: normalize permission rules and improve command draft handling Add normalized rule set construction for permissions in Agents pages Track initial command draft state to detect unsaved changes Prefill draft fields when duplicating or selecting existing commands * feat: add global session-level edit mode for agent permissions --- packages/ui/src/components/chat/ChatInput.tsx | 78 +- .../ui/src/components/chat/ModelControls.tsx | 333 +---- .../components/sections/agents/AgentsPage.tsx | 1084 +++++++++-------- .../sections/agents/AgentsSidebar.tsx | 68 +- .../sections/commands/CommandsPage.tsx | 89 +- .../ui/src/lib/permissions/editModeColors.ts | 10 +- packages/ui/src/stores/contextStore.ts | 17 +- packages/ui/src/stores/permissionStore.ts | 4 +- 8 files changed, 785 insertions(+), 898 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8fa5cb50..c736886a 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -29,7 +29,6 @@ import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; import { useFileStore } from '@/stores/fileStore'; -import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { @@ -38,6 +37,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { useContextStore } from '@/stores/contextStore'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; @@ -96,23 +96,6 @@ const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): return undefined; }; -const buildPermissionActionMap = (ruleset: unknown, permission: string): Record | undefined => { - const rules = asPermissionRuleset(ruleset); - if (!rules || rules.length === 0) { - return undefined; - } - - const map: Record = {}; - for (const rule of rules) { - if (rule.permission !== permission) { - continue; - } - map[rule.pattern] = rule.action; - } - - return Object.keys(map).length > 0 ? map : undefined; -}; - export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => { const [message, setMessage] = React.useState(''); const [isDragging, setIsDragging] = React.useState(false); @@ -236,60 +219,47 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [pendingInputText, consumePendingInputText]); const currentAgent = React.useMemo(() => { - if (!currentAgentName) { + const selectedName = currentSessionId + ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || currentAgentName) + : currentAgentName; + if (!selectedName) { return undefined; } - return agents.find((agent) => agent.name === currentAgentName); - }, [agents, currentAgentName]); + return agents.find((agent) => agent.name === selectedName); + }, [agents, currentAgentName, currentSessionId]); - const agentDefaultEditMode = React.useMemo(() => { + const agentEditAction = React.useMemo(() => { if (!currentAgent) { return 'deny'; } - const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; - return action; + return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow'; }, [currentAgent]); - const sessionAgentEditOverride = useSessionStore( + const sessionEditMode = useContextStore( React.useCallback((state) => { - if (!currentSessionId || !currentAgentName) { + if (!currentAgentName) { return undefined; } - const sessionMap = state.sessionAgentEditModes.get(currentSessionId); - return sessionMap?.get(currentAgentName); - }, [currentSessionId, currentAgentName]) + const sessionId = currentSessionId ?? '__global__'; + return state.getSessionAgentEditMode(sessionId, currentAgentName, 'ask'); + }, [currentAgentName, currentSessionId]) ); - const agentWebfetchPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch'); - }, [currentAgent]); - - const agentBashPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - const map = buildPermissionActionMap(currentAgent.permission, 'bash'); - return map ? (map as BashPermissionSetting) : undefined; - }, [currentAgent]); - - const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ - agentDefaultEditMode, - webfetchPermission: agentWebfetchPermission, - bashPermission: agentBashPermission, - }), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]); - const selectionContextReady = Boolean(currentSessionId && currentAgentName); const effectiveEditPermission = React.useMemo(() => { - if (selectionContextReady && sessionAgentEditOverride && permissionUiState.modeAvailability[sessionAgentEditOverride]) { - return sessionAgentEditOverride; + // Only show accent when edits are effectively allowed. + if (agentEditAction === 'allow') { + return 'allow'; } - return permissionUiState.cascadeDefaultMode; - }, [permissionUiState, selectionContextReady, sessionAgentEditOverride]); + if (agentEditAction !== 'ask') { + return 'ask'; + } + + const sessionMode = selectionContextReady ? (sessionEditMode ?? 'ask') : 'ask'; + return (sessionMode === 'allow' || sessionMode === 'full') ? 'allow' : 'ask'; + }, [agentEditAction, selectionContextReady, sessionEditMode]); const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 3b7badbb..263fb7ab 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -35,11 +35,11 @@ import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { getAgentColor } from '@/lib/agentColors'; import { useDeviceInfo } from '@/lib/device'; -import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useContextStore } from '@/stores/contextStore'; @@ -103,23 +103,6 @@ const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): return undefined; }; -const buildPermissionActionMap = (ruleset: unknown, permission: string): Record | undefined => { - const rules = asPermissionRuleset(ruleset); - if (!rules || rules.length === 0) { - return undefined; - } - - const map: Record = {}; - for (const rule of rules) { - if (rule.permission !== permission) { - continue; - } - map[rule.pattern] = rule.action; - } - - return Object.keys(map).length > 0 ? map : undefined; -}; - interface CapabilityDefinition { key: 'tool_call' | 'reasoning'; icon: IconComponent; @@ -301,8 +284,6 @@ export const ModelControls: React.FC = ({ className }) => { saveAgentModelVariantForSession, getAgentModelVariantForSession, analyzeAndSaveExternalSessionChoices, - getSessionAgentEditMode, - setSessionAgentEditMode, } = useSessionStore(); const contextHydrated = useContextStore((state) => state.hasHydrated); @@ -328,6 +309,15 @@ export const ModelControls: React.FC = ({ className }) => { const uiAgentName = currentSessionId ? (sessionSavedAgentName || stickySessionAgentName || currentAgentName) : currentAgentName; + + const sessionIdForEditMode = currentSessionId ?? '__global__'; + const sessionEditMode = useContextStore((state) => { + if (!uiAgentName) { + return undefined; + } + return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask'); + }); + const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode); const { toggleFavoriteModel, isFavoriteModel, addRecentModel, isModelSelectorOpen, setModelSelectorOpen } = useUIStore(); const { favoriteModelsList, recentModelsList } = useModelLists(); @@ -349,12 +339,9 @@ export const ModelControls: React.FC = ({ className }) => { } return initial; }); - const [mobileEditOptionsOpen, setMobileEditOptionsOpen] = React.useState(false); // Use global state for model selector (allows Ctrl+M shortcut) const agentMenuOpen = isModelSelectorOpen; const setAgentMenuOpen = setModelSelectorOpen; - const [desktopEditOptionsOpen, setDesktopEditOptionsOpen] = React.useState(false); - const desktopEditOptionsId = React.useId(); const [desktopModelQuery, setDesktopModelQuery] = React.useState(''); const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0); const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]); @@ -372,9 +359,6 @@ export const ModelControls: React.FC = ({ className }) => { }, [activeMobilePanel, currentProviderId]); React.useEffect(() => { - if (activeMobilePanel !== 'agent') { - setMobileEditOptionsOpen(false); - } if (activeMobilePanel !== 'model') { setMobileModelQuery(''); } @@ -386,7 +370,6 @@ export const ModelControls: React.FC = ({ className }) => { prevAgentMenuOpenRef.current = agentMenuOpen; if (!agentMenuOpen) { - setDesktopEditOptionsOpen(false); setDesktopModelQuery(''); setModelSelectedIndex(0); @@ -405,81 +388,27 @@ export const ModelControls: React.FC = ({ className }) => { setModelSelectedIndex(0); }, [desktopModelQuery]); - const currentAgent = getCurrentAgent?.(); + const currentAgent = React.useMemo(() => { + if (uiAgentName) { + return agents.find((agent) => agent.name === uiAgentName); + } + return getCurrentAgent?.(); + }, [agents, getCurrentAgent, uiAgentName]); - const agentDefaultEditMode = React.useMemo(() => { + const agentEditAction = React.useMemo(() => { if (!currentAgent) { return 'deny'; } - const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; - return action; + return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow'; }, [currentAgent]); - const agentWebfetchPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch'); - }, [currentAgent]); + const selectionContextReady = Boolean(uiAgentName); - const agentBashPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - const map = buildPermissionActionMap(currentAgent.permission, 'bash'); - return map ? (map as BashPermissionSetting) : undefined; - }, [currentAgent]); - - const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ - agentDefaultEditMode, - webfetchPermission: agentWebfetchPermission, - bashPermission: agentBashPermission, - }), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]); - - const { cascadeDefaultMode, modeAvailability, autoApproveAvailable } = permissionUiState; - - const selectionContextReady = Boolean(currentSessionId && uiAgentName); - const sessionMode = selectionContextReady && currentSessionId && uiAgentName - ? getSessionAgentEditMode(currentSessionId, uiAgentName, cascadeDefaultMode) - : cascadeDefaultMode; - - const editModeShortLabels: Record = { - ask: 'Ask before edits', - allow: 'Approve edit tools', - full: 'Approve every tool', - deny: 'Editing disabled', - }; - - const isModeDisabled = React.useCallback((mode: EditPermissionMode) => { - return !modeAvailability[mode]; - }, [modeAvailability]); - - const effectiveEditMode = React.useMemo(() => { - if (!selectionContextReady) { - return cascadeDefaultMode; - } - if (isModeDisabled(sessionMode) && sessionMode !== cascadeDefaultMode) { - return cascadeDefaultMode; - } - return sessionMode; - }, [cascadeDefaultMode, isModeDisabled, selectionContextReady, sessionMode]); - - const editPermissionOptions: Array<{ mode: EditPermissionMode; label: string; disabled: boolean }> = [ - { mode: 'ask', label: editModeShortLabels.ask, disabled: isModeDisabled('ask') }, - { mode: 'allow', label: editModeShortLabels.allow, disabled: isModeDisabled('allow') }, - { mode: 'full', label: editModeShortLabels.full, disabled: isModeDisabled('full') }, - ]; - - const activeEditModeColors = React.useMemo(() => getEditModeColors(effectiveEditMode), [effectiveEditMode]); - - const editToggleDisabled = !selectionContextReady || !autoApproveAvailable; - - React.useEffect(() => { - if (editToggleDisabled) { - setMobileEditOptionsOpen(false); - setDesktopEditOptionsOpen(false); - } - }, [editToggleDisabled]); + const approveEditsAvailable = agentEditAction === 'ask'; + const approveEditsChecked = approveEditsAvailable + ? sessionEditMode === 'allow' || sessionEditMode === 'full' + : agentEditAction === 'allow'; + const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable; const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default'; const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8'; @@ -487,8 +416,6 @@ export const ModelControls: React.FC = ({ className }) => { const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta'; const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3'; - const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode]; - const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => { const combinedClassName = cn(iconClass, 'flex-shrink-0'); const modeColors = getEditModeColors(mode); @@ -507,15 +434,12 @@ export const ModelControls: React.FC = ({ className }) => { return ; }, [editToggleIconClass]); - const handleEditPermissionSelect = React.useCallback((mode: EditPermissionMode) => { - if (editToggleDisabled || !currentSessionId || !currentAgentName || isModeDisabled(mode)) { + const handleApproveEditsToggle = React.useCallback((checked: boolean) => { + if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) { return; } - setSessionAgentEditMode(currentSessionId, currentAgentName, mode, cascadeDefaultMode); - setAgentMenuOpen(false); - setMobileEditOptionsOpen(false); - setDesktopEditOptionsOpen(false); - }, [cascadeDefaultMode, editToggleDisabled, currentSessionId, currentAgentName, setSessionAgentEditMode, setAgentMenuOpen, setDesktopEditOptionsOpen, isModeDisabled]); + setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask'); + }, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]); const currentProvider = getCurrentProvider(); const models = Array.isArray(currentProvider?.models) ? currentProvider.models : []; @@ -1742,94 +1666,20 @@ export const ModelControls: React.FC = ({ className }) => { ); })} -
- - {mobileEditOptionsOpen && !editToggleDisabled && ( -
-
- {editPermissionOptions.map((option) => { - const isSelected = option.mode === effectiveEditMode; - const optionColors = getEditModeColors(option.mode); - return ( - - ); - })} -
-
- )} +
+
+ + Auto-approve edits + + +
@@ -2549,96 +2399,21 @@ export const ModelControls: React.FC = ({ className }) => { ))}
- - {desktopEditOptionsOpen && !editToggleDisabled && ( -
- {editPermissionOptions.map((option) => { - const isSelected = option.mode === effectiveEditMode; - const optionColors = getEditModeColors(option.mode); - return ( - - ); - })} -
- )} +
diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index fb6bd95d..9d72b022 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -22,15 +22,12 @@ import { } from '@/components/ui/select'; type PermissionAction = 'allow' | 'ask' | 'deny'; -type DefaultOverride = 'default' | PermissionAction; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; type PermissionConfigValue = PermissionAction | Record; - -type ParsedPermissionConfig = { - entries: Record; -}; +type PermissionRuleKey = `${string}::${string}`; const STANDARD_PERMISSION_KEYS = [ + '*', 'read', 'edit', 'glob', @@ -47,200 +44,179 @@ const STANDARD_PERMISSION_KEYS = [ '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 parsePermissionConfigValue = (value: unknown): PermissionConfigValue | undefined => { - if (isPermissionAction(value)) { - return value; - } +const buildRuleKey = (permission: string, pattern: string): PermissionRuleKey => + `${permission}::${pattern}`; - // We only manage wildcard rules in this UI. If a granular object is provided, - // read its wildcard action and ignore all other patterns. - if (value && typeof value === 'object' && !Array.isArray(value)) { - const wildcard = (value as Record)['*']; - if (isPermissionAction(wildcard)) { - return wildcard; +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 undefined; + return Array.from(map.values()); }; -const parsePermissionConfig = (value: unknown): ParsedPermissionConfig => { +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 { entries: { '*': value } }; + return [{ permission: '*', pattern: '*', action: value }]; } if (!value || typeof value !== 'object' || Array.isArray(value)) { - return { entries: {} }; - } - - const entries: Record = {}; - - for (const [key, raw] of Object.entries(value as Record)) { - const parsedValue = parsePermissionConfigValue(raw); - if (parsedValue !== undefined) { - entries[key] = parsedValue; - } - } - - return { entries }; -}; - -const splitDefaultOverrideFromEntries = (entries: Record): { - defaultOverride: DefaultOverride; - overrides: Record; -} => { - const overrides: Record = { ...entries }; - - const maybeDefault = overrides['*']; - if (isPermissionAction(maybeDefault)) { - delete overrides['*']; - return { defaultOverride: maybeDefault, overrides }; - } - - return { defaultOverride: 'default', overrides }; -}; - -const getOpenCodeDefaultActionForPermission = (permissionName: string): PermissionAction => { - if (permissionName === 'doom_loop' || permissionName === 'external_directory') { - return 'ask'; - } - return 'allow'; -}; - -const getAgentBaseDefaultActionForPermission = ( - permissionName: string, - defaultOverride: DefaultOverride, -): PermissionAction => { - if (defaultOverride === 'default') { - return getOpenCodeDefaultActionForPermission(permissionName); - } - - return defaultOverride; -}; - -const pruneRedundantPermissionOverrides = ( - entries: Record, - defaultOverride: DefaultOverride, -): Record => { - const pruned: Record = { ...entries }; - - for (const [permissionName, value] of Object.entries(entries)) { - if (permissionName === '*') { - continue; - } - - const baseDefaultAction = getAgentBaseDefaultActionForPermission(permissionName, defaultOverride); - const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); - - // For doom_loop and external_directory (OpenCode default = "ask"), - // always keep explicit config if it differs from "ask" - if (isPermissionAction(value) && value !== opencodeDefault) { - continue; - } - - if (isPermissionAction(value) && value === baseDefaultAction) { - delete pruned[permissionName]; - continue; - } - - - if (value && typeof value === 'object' && !Array.isArray(value)) { - const map = value as Record; - const patterns = Object.keys(map); - const wildcardAction = map['*']; - // Keep if wildcard differs from OpenCode default - if (wildcardAction !== undefined && wildcardAction !== opencodeDefault) { - continue; - } - if (patterns.length === 1 && patterns[0] === '*' && wildcardAction === baseDefaultAction) { - delete pruned[permissionName]; - } - } - } - - return pruned; -}; - -const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { - if (!Array.isArray(value)) { - return null; + return []; } const rules: PermissionRule[] = []; - for (const entry of value) { - if (!entry || typeof entry !== 'object') { + for (const [permissionName, configValue] of Object.entries(value as Record)) { + if (permissionName === '__originalKeys') { continue; } - const candidate = entry as Partial; - if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { + if (isPermissionAction(configValue)) { + rules.push({ permission: permissionName, pattern: '*', action: configValue }); continue; } - if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { - 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 }); + } + } } - rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); } return rules; }; -const rulesetToPermissionConfig = (ruleset: unknown): ParsedPermissionConfig => { - const rules = asPermissionRuleset(ruleset); - if (!rules || rules.length === 0) { - return { entries: {} }; +const buildPermissionConfigFromRules = (ruleset: PermissionRule[]): AgentConfig['permission'] | undefined => { + const normalized = normalizeRuleset(ruleset); + if (normalized.length === 0) { + return undefined; } - const wildcardByPermission: Record = {}; - - for (const rule of rules) { - if (!rule.permission || rule.permission === 'invalid') { - continue; - } - - // This UI only manages wildcard ("*") rules. - if (rule.pattern !== '*') { - continue; - } - - wildcardByPermission[rule.permission] = rule.action; + const grouped: Record> = {}; + for (const rule of normalized) { + (grouped[rule.permission] ||= {})[rule.pattern] = rule.action; } - // Get the global default from the ruleset (if any) - const globalDefault = wildcardByPermission['*']; - - const entries: Record = {}; - for (const [permissionName, action] of Object.entries(wildcardByPermission)) { - if (permissionName === '*') { - entries[permissionName] = action; - continue; - } - - // Determine what this permission would be without explicit config - const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); - - if (globalDefault) { - // If there's a global default, skip permissions that match it - // (they're redundant - the global default covers them) - if (action === globalDefault) { - continue; - } + const result: Record = {}; + for (const [permissionName, patterns] of Object.entries(grouped)) { + if (Object.keys(patterns).length === 1 && patterns['*']) { + result[permissionName] = patterns['*']; } else { - // No global default - skip permissions that match OpenCode's built-in default - // (they're not explicitly configured, just inherited) - if (action === opencodeDefault) { - continue; - } + result[permissionName] = patterns; } - - entries[permissionName] = action; } - return { entries }; + return result as AgentConfig['permission']; +}; + +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']; +}; + +const buildPermissionDiffConfig = ( + baselineRules: PermissionRule[], + currentRules: PermissionRule[], +): AgentConfig['permission'] | undefined => { + const baselineMap = buildRuleMap(baselineRules); + const currentMap = buildRuleMap(currentRules); + const changedRules: PermissionRule[] = []; + + for (const [key, rule] of currentMap.entries()) { + const baselineRule = baselineMap.get(key); + if (!baselineRule || baselineRule.action !== rule.action) { + changedRules.push(rule); + } + } + + return buildPermissionConfigFromRules(changedRules); }; export const AgentsPage: React.FC = () => { @@ -258,10 +234,25 @@ export const AgentsPage: React.FC = () => { const [temperature, setTemperature] = React.useState(undefined); const [topP, setTopP] = React.useState(undefined); const [prompt, setPrompt] = React.useState(''); - const [defaultOverride, setDefaultOverride] = React.useState('default'); - const [permissionEntries, setPermissionEntries] = React.useState>({}); - const [pendingOverrideName, setPendingOverrideName] = 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([]); @@ -302,10 +293,7 @@ export const AgentsPage: React.FC = () => { const names = new Set(); for (const agent of agents) { - const rules = asPermissionRuleset((agent as { permission?: unknown }).permission); - if (!rules) { - continue; - } + 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); @@ -329,87 +317,67 @@ export const AgentsPage: React.FC = () => { return Array.from(names).sort((a, b) => a.localeCompare(b)); }, [agents, permissionsBySession, toolIds]); - const getOverrideWildcardAction = React.useCallback((permissionName: string): PermissionAction | undefined => { - const configured = permissionEntries[permissionName]; - if (isPermissionAction(configured)) { - return configured; - } - if (configured && typeof configured === 'object' && !Array.isArray(configured)) { - const wildcard = (configured as Record)['*']; - if (isPermissionAction(wildcard)) { - return wildcard; - } - } - return undefined; - }, [permissionEntries]); + const baselineRuleMap = React.useMemo(() => buildRuleMap(permissionBaseline), [permissionBaseline]); + const currentRuleMap = React.useMemo(() => buildRuleMap(permissionRules), [permissionRules]); - const getCustomPatternCount = React.useCallback((permissionName: string): number => { - const configured = permissionEntries[permissionName]; - if (!configured || typeof configured !== 'object' || Array.isArray(configured)) { - return 0; - } - const patterns = Object.keys(configured).filter((pattern) => pattern !== '*'); - return patterns.length; - }, [permissionEntries]); + const getWildcardOverride = React.useCallback((permissionName: string): PermissionAction | undefined => ( + currentRuleMap.get(buildRuleKey(permissionName, '*'))?.action + ), [currentRuleMap]); - const setOverrideAction = React.useCallback((permissionName: string, action: PermissionAction) => { + const getEffectiveWildcardAction = React.useCallback((permissionName: string): PermissionAction => { if (permissionName === '*') { - return; + return globalPermission; } + return getWildcardOverride(permissionName) ?? globalPermission; + }, [getWildcardOverride, globalPermission]); - setPermissionEntries((prev) => { - const next: Record = { ...prev }; - const current = next[permissionName]; + 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 uiDefaultAction = getAgentBaseDefaultActionForPermission(permissionName, defaultOverride); - const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); + 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]); - // Only remove if action matches BOTH the UI default AND OpenCode's built-in default - // If they differ, we need to keep the explicit override to ensure correct behavior - const canRemove = action === uiDefaultAction && action === opencodeDefault; - - if (current && typeof current === 'object' && !Array.isArray(current)) { - const map: Record = { ...(current as Record) }; - map['*'] = action; - - const nonWildcardPatterns = Object.keys(map).filter((pattern) => pattern !== '*'); - if (canRemove && nonWildcardPatterns.length === 0) { - delete next[permissionName]; - return next; - } - - next[permissionName] = map; - return next; - } - - if (canRemove) { - delete next[permissionName]; - return next; - } - - next[permissionName] = action; - return next; - }); - }, [defaultOverride]); - - const removeOverride = React.useCallback((permissionName: string) => { - setPermissionEntries((prev) => { - if (!(permissionName in prev)) { - return prev; - } - const next = { ...prev }; - delete next[permissionName]; - return next; - }); + const getFallbackDefaultAction = React.useCallback((permissionName: string): PermissionAction => { + if (permissionName === 'doom_loop' || permissionName === 'external_directory') { + return 'ask'; + } + return 'allow'; }, []); - const overrides = React.useMemo(() => { - const entries = Object.entries(permissionEntries).filter(([name]) => name !== '*'); - entries.sort(([a], [b]) => a.localeCompare(b)); - return entries; - }, [permissionEntries]); + 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 availableOverrideNames = React.useMemo(() => { + const availablePermissionNames = React.useMemo(() => { const names = new Set(); for (const key of STANDARD_PERMISSION_KEYS) { @@ -419,37 +387,68 @@ export const AgentsPage: React.FC = () => { for (const key of knownPermissionNames) { names.add(key); } - - for (const key of Object.keys(permissionEntries)) { - names.delete(key); - } - return Array.from(names).sort((a, b) => a.localeCompare(b)); - }, [knownPermissionNames, permissionEntries]); + }, [knownPermissionNames]); - const applyPendingOverride = React.useCallback((action: PermissionAction) => { - const name = pendingOverrideName.trim(); + 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 baseDefaultAction = getAgentBaseDefaultActionForPermission(name, defaultOverride); - - if (action === baseDefaultAction) { - const basis = defaultOverride === 'default' - ? 'OpenCode defaults' - : `Default Permissions ("${defaultOverride}")`; - - toast.message(`"${name}" already matches ${basis} (${baseDefaultAction}).`); - setPendingOverrideName(''); + const pattern = pendingRulePattern.trim() || '*'; + if (name === '*' && pattern === '*') { + setGlobalPermissionAndPrune(action); + setPendingRuleName(''); + setPendingRulePattern('*'); return; } - - setOverrideAction(name, action); - setPendingOverrideName(''); - }, [pendingOverrideName, setOverrideAction, defaultOverride]); + 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'; @@ -466,51 +465,115 @@ export const AgentsPage: React.FC = () => { }, []); React.useEffect(() => { - setPendingOverrideName(''); + setPendingRuleName(''); + setPendingRulePattern('*'); - const applyPermissionState = (entries: Record) => { - const split = splitDefaultOverrideFromEntries(entries); - setDefaultOverride(split.defaultOverride); - setPermissionEntries(pruneRedundantPermissionOverrides(split.overrides, split.defaultOverride)); + 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) { - setDraftName(agentDraft.name || ''); - setDraftScope(agentDraft.scope || 'user'); - setDescription(agentDraft.description || ''); - setMode(agentDraft.mode || 'subagent'); - setModel(agentDraft.model || ''); - setTemperature(agentDraft.temperature); - setTopP(agentDraft.top_p); - setPrompt(agentDraft.prompt || ''); + 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 || ''; - const parsed = parsePermissionConfig(agentDraft.permission); - applyPermissionState(parsed.entries); + 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) { - setDescription(selectedAgent.description || ''); - setMode(selectedAgent.mode || 'subagent'); + 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 || ''; - if (selectedAgent.model?.providerID && selectedAgent.model?.modelID) { - setModel(`${selectedAgent.model.providerID}/${selectedAgent.model.modelID}`); - } else { - setModel(''); - } + setDescription(descriptionValue); + setMode(modeValue); - setTemperature(selectedAgent.temperature); - setTopP(selectedAgent.topP); - setPrompt(selectedAgent.prompt || ''); + setModel(modelValue); + setTemperature(temperatureValue); + setTopP(topPValue); + setPrompt(promptValue); - const parsed = rulesetToPermissionConfig(selectedAgent.permission); - applyPermissionState(parsed.entries); + 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]); - // Note: We no longer prune overrides when defaultOverride changes. - // This preserves user's explicit overrides until they save. - // Pruning only happens in handleSave to avoid losing overrides during editing. + 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(); @@ -530,6 +593,7 @@ export const AgentsPage: React.FC = () => { try { const trimmedModel = model.trim(); + const permissionConfig = buildPermissionConfigWithGlobal(globalPermission, permissionRules); const config: AgentConfig = { name: agentName, description: description.trim() || undefined, @@ -538,36 +602,7 @@ export const AgentsPage: React.FC = () => { temperature, top_p: topP, prompt: prompt.trim() || undefined, - permission: (() => { - const overrides = pruneRedundantPermissionOverrides(permissionEntries, defaultOverride); - const combined: Record = defaultOverride === 'default' - ? overrides - : { '*': defaultOverride, ...overrides }; - - // Always explicitly include doom_loop and external_directory - // These have special OpenCode defaults ("ask") that differ from the general default ("allow") - const specialPermissions = ['doom_loop', 'external_directory'] as const; - for (const perm of specialPermissions) { - if (!(perm in combined)) { - // Use explicit override if set, otherwise derive from effective default - const explicit = permissionEntries[perm]; - if (isPermissionAction(explicit)) { - combined[perm] = explicit; - } else { - combined[perm] = getAgentBaseDefaultActionForPermission(perm, defaultOverride); - } - } - } - - const keys = Object.keys(combined); - - if (keys.length === 0) { - return isNewAgent ? undefined : null; - } - - // Don't simplify to single action - always use object form when we have special permissions - return combined as unknown as AgentConfig['permission']; - })(), + permission: permissionConfig, scope: isNewAgent ? draftScope : undefined, }; @@ -919,94 +954,233 @@ export const AgentsPage: React.FC = () => { {}
-

Permissions

+
+

Permissions

+ +

- This editor only updates wildcard ("*") rules; existing pattern rules are preserved. + {showPermissionEditor + ? 'Set a global default; tools only saved when different from global.' + : 'Summary shows default actions; edit to manage granular rules.'}

-
-
-

Default Permissions

-

- Set the default behavior for all permissions for this agent. -

- -
- - - - -
- -
-

- {defaultOverride === 'default' - ? 'Uses OpenCode defaults unless overridden below.' - : 'Applies to any permission without an explicit override below.'} -

- - - - - -
-

Default: Follow OpenCode default behavior

-

Allow: Run without confirmation

-

Ask: Prompt for confirmation

-

Deny: Block the operation

+ {!showPermissionEditor ? ( +
+ {summaryPermissionNames.map((permissionName) => { + const { defaultAction, patternRulesCount, patternSummary, hasDefaultHint } = getPermissionSummary(permissionName); + const label = formatPermissionLabel(permissionName); + const summary = hasDefaultHint + ? `${defaultAction} (env blocked)` + : defaultAction; + return ( +
+
+ {label} + {permissionName}
- - -
-
- -
-

Overrides

-

- Add overrides for permissions that should behave differently for this agent. -

- -
-
- setGlobalPermissionAndPrune(value as PermissionAction)} + > + + {globalPermission} + + + Allow + Ask + Deny + + +
+
+ +
+ {summaryPermissionNames.filter((name) => name !== '*').map((permissionName) => { + const label = formatPermissionLabel(permissionName); + const { defaultAction, patternRulesCount, patternSummary } = getPermissionSummary(permissionName); + const wildcardOverride = getWildcardOverride(permissionName); + const wildcardValue: string = wildcardOverride ?? 'global'; + const patternRules = getPatternRules(permissionName); + + const wildcardOptions = (['allow', 'ask', 'deny'] as const).filter((action) => action !== globalPermission); + + return ( +
+
+
+ {label} + {permissionName} +
+
+ {patternRulesCount > 0 ? ( + Global: {defaultAction} + ) : ( + {defaultAction} + )} + {patternRulesCount > 0 ? ( + Patterns: {patternSummary} + ) : null} +
+
+ +
+
+
+ Pattern + * + {wildcardOverride ? ( + + ) : null} +
+ + +
+ + {patternRules.map((rule) => { + const ruleKey = buildRuleKey(rule.permission, rule.pattern); + const baselineRule = baselineRuleMap.get(ruleKey); + const isAdded = !baselineRule; + const isModified = Boolean(baselineRule && baselineRule.action !== rule.action); + + return ( +
+
+ Pattern + {rule.pattern} + {isAdded ? ( + New + ) : null} + {isModified ? ( + Modified + ) : null} + {isAdded ? ( + + ) : isModified ? ( + + ) : null} +
+ + +
+ ); + })} +
+
+ ); + })} +
+ +
+

Add Rule

+

+ Choose a permission key, set a pattern, and pick an action. +

+ +
+ + setPendingRulePattern(e.target.value)} + placeholder="Pattern (e.g. *)" + className="h-8 sm:w-64 font-mono" + /> +
- -
- - {overrides.length === 0 ? ( -

- {defaultOverride === 'default' - ? 'No overrides configured. Everything follows OpenCode defaults.' - : `No overrides configured. All permissions default to "${defaultOverride}" for this agent.`} -

- ) : ( -
- {overrides.map(([permissionName]) => { - const wildcardAction = getOverrideWildcardAction(permissionName); - const customPatternCount = getCustomPatternCount(permissionName); - const label = formatPermissionLabel(permissionName); - - return ( -
-
- - - -
- -
- - - -
- -
- ); - })} -
- )}
-
+ )} {}
@@ -1143,7 +1235,7 @@ export const AgentsPage: React.FC = () => { size="sm" variant="default" onClick={handleSave} - disabled={isSaving} + disabled={isSaving || !isDirty} className="gap-2 h-6 px-2 text-xs w-fit" > diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index 42c4867d..f9b8ef0d 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -35,14 +35,6 @@ type PermissionRule = { permission: string; pattern: string; action: PermissionA 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 []; @@ -66,8 +58,22 @@ const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => { 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 = toPermissionRuleset(ruleset); + const parsed = normalizeRuleset(toPermissionRuleset(ruleset)); if (parsed.length === 0) { return undefined; } @@ -80,48 +86,14 @@ const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] = (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]; + for (const [permissionName, map] of Object.entries(byPermission)) { 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; + if (patterns.length === 1 && patterns[0] === '*') { + result[permissionName] = map['*']; + continue; } + result[permissionName] = map; } return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined; diff --git a/packages/ui/src/components/sections/commands/CommandsPage.tsx b/packages/ui/src/components/sections/commands/CommandsPage.tsx index 0dfe8432..15ce46c4 100644 --- a/packages/ui/src/components/sections/commands/CommandsPage.tsx +++ b/packages/ui/src/components/sections/commands/CommandsPage.tsx @@ -31,26 +31,89 @@ export const CommandsPage: React.FC = () => { const [template, setTemplate] = React.useState(''); const [subtask, setSubtask] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); + const initialStateRef = React.useRef<{ + draftName: string; + draftScope: CommandScope; + description: string; + agent: string; + model: string; + template: string; + subtask: boolean; + } | null>(null); React.useEffect(() => { if (isNewCommand && commandDraft) { // Prefill from draft (for new or duplicated commands) - setDraftName(commandDraft.name || ''); - setDraftScope(commandDraft.scope || 'user'); - setDescription(commandDraft.description || ''); - setAgent(commandDraft.agent || ''); - setModel(commandDraft.model || ''); - setTemplate(commandDraft.template || ''); - setSubtask(commandDraft.subtask || false); + const draftNameValue = commandDraft.name || ''; + const draftScopeValue = commandDraft.scope || 'user'; + const descriptionValue = commandDraft.description || ''; + const agentValue = commandDraft.agent || ''; + const modelValue = commandDraft.model || ''; + const templateValue = commandDraft.template || ''; + const subtaskValue = commandDraft.subtask || false; + + setDraftName(draftNameValue); + setDraftScope(draftScopeValue); + setDescription(descriptionValue); + setAgent(agentValue); + setModel(modelValue); + setTemplate(templateValue); + setSubtask(subtaskValue); + + initialStateRef.current = { + draftName: draftNameValue, + draftScope: draftScopeValue, + description: descriptionValue, + agent: agentValue, + model: modelValue, + template: templateValue, + subtask: subtaskValue, + }; } else if (selectedCommand) { - setDescription(selectedCommand.description || ''); - setAgent(selectedCommand.agent || ''); - setModel(selectedCommand.model || ''); - setTemplate(selectedCommand.template || ''); - setSubtask(selectedCommand.subtask || false); + const descriptionValue = selectedCommand.description || ''; + const agentValue = selectedCommand.agent || ''; + const modelValue = selectedCommand.model || ''; + const templateValue = selectedCommand.template || ''; + const subtaskValue = selectedCommand.subtask || false; + + setDescription(descriptionValue); + setAgent(agentValue); + setModel(modelValue); + setTemplate(templateValue); + setSubtask(subtaskValue); + + initialStateRef.current = { + draftName: '', + draftScope: 'user', + description: descriptionValue, + agent: agentValue, + model: modelValue, + template: templateValue, + subtask: subtaskValue, + }; } }, [selectedCommand, isNewCommand, selectedCommandName, commands, commandDraft]); + const isDirty = React.useMemo(() => { + const initial = initialStateRef.current; + if (!initial) { + return false; + } + + if (isNewCommand) { + if (draftName !== initial.draftName) return true; + if (draftScope !== initial.draftScope) return true; + } + + if (description !== initial.description) return true; + if (agent !== initial.agent) return true; + if (model !== initial.model) return true; + if (template !== initial.template) return true; + if (subtask !== initial.subtask) return true; + + return false; + }, [agent, description, draftName, draftScope, isNewCommand, model, subtask, template]); + const handleSave = async () => { const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim(); @@ -352,7 +415,7 @@ Use @filename to include file contents.`} size="sm" variant="default" onClick={handleSave} - disabled={isSaving} + disabled={isSaving || !isDirty} className="gap-2 h-6 px-2 text-xs w-fit" > diff --git a/packages/ui/src/lib/permissions/editModeColors.ts b/packages/ui/src/lib/permissions/editModeColors.ts index 3f7bfd53..3206d971 100644 --- a/packages/ui/src/lib/permissions/editModeColors.ts +++ b/packages/ui/src/lib/permissions/editModeColors.ts @@ -11,17 +11,17 @@ export const getEditModeColors = (mode?: EditPermissionMode | null): EditModeCol if (mode === 'full') { return { text: 'var(--status-info)', - border: 'var(--status-info-border)', - background: 'var(--status-info-background)', + border: 'color-mix(in srgb, var(--status-info) 25%, transparent)', + background: 'color-mix(in srgb, var(--status-info) 4%, transparent)', borderWidth: 1.5, }; } if (mode === 'allow') { return { - text: 'var(--status-success)', - border: 'var(--status-success-border)', - background: 'var(--status-success-background)', + text: 'var(--status-info)', + border: 'color-mix(in srgb, var(--status-info) 25%, transparent)', + background: 'color-mix(in srgb, var(--status-info) 4%, transparent)', borderWidth: 1.5, }; } diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index 52dc120d..0e01f05e 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -69,6 +69,7 @@ interface ContextActions { type ContextStore = ContextState & ContextActions; const EDIT_PERMISSION_SEQUENCE: EditPermissionMode[] = ['ask', 'allow', 'full']; +const GLOBAL_EDIT_MODE_SESSION_ID = '__global__'; export const useContextStore = create()( devtools( @@ -542,7 +543,20 @@ export const useContextStore = create()( const sessionMap = get().sessionAgentEditModes.get(sessionId); const override = sessionMap?.get(agentName); - return override ?? defaultMode; + if (override !== undefined) { + return override; + } + + // Fallback: global (applies to all sessions) + if (sessionId !== GLOBAL_EDIT_MODE_SESSION_ID) { + const globalMap = get().sessionAgentEditModes.get(GLOBAL_EDIT_MODE_SESSION_ID); + const globalOverride = globalMap?.get(agentName); + if (globalOverride !== undefined) { + return globalOverride; + } + } + + return defaultMode; }, setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode: EditPermissionMode = getAgentDefaultEditPermission(agentName)) => { @@ -598,6 +612,7 @@ export const useContextStore = create()( get().setSessionAgentEditMode(sessionId, agentName, nextMode, normalizedDefault); }, + }), { name: "context-store", diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 8bad9db2..1d7a5a5b 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -88,8 +88,8 @@ export const usePermissionStore = create()( const defaultMode = getAgentDefaultEditPermission(agentName); const effectiveMode = contextData?.getSessionAgentEditMode?.(sessionId, agentName) ?? defaultMode; - const shouldAutoApprove = effectiveMode === 'full' - || (effectiveMode === 'allow' && isEditPermissionType(permissionType)); + const shouldAutoApprove = (effectiveMode === 'allow' || effectiveMode === 'full') + && isEditPermissionType(permissionType); if (shouldAutoApprove) { get().respondToPermission(sessionId, permission.id, 'once').catch(() => {