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
This commit is contained in:
Bohdan Triapitsyn
2026-01-26 16:09:46 +02:00
committed by GitHub
parent 74511abfda
commit a9952fd8da
8 changed files with 785 additions and 898 deletions
+24 -54
View File
@@ -29,7 +29,6 @@ import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useFileStore } from '@/stores/fileStore'; import { useFileStore } from '@/stores/fileStore';
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { isIMECompositionEvent } from '@/lib/ime'; import { isIMECompositionEvent } from '@/lib/ime';
import { import {
@@ -38,6 +37,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useContextStore } from '@/stores/contextStore';
const MAX_VISIBLE_TEXTAREA_LINES = 8; const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = []; const EMPTY_QUEUE: QueuedMessage[] = [];
@@ -96,23 +96,6 @@ const resolveWildcardPermissionAction = (ruleset: unknown, permission: string):
return undefined; return undefined;
}; };
const buildPermissionActionMap = (ruleset: unknown, permission: string): Record<string, PermissionAction | undefined> | undefined => {
const rules = asPermissionRuleset(ruleset);
if (!rules || rules.length === 0) {
return undefined;
}
const map: Record<string, PermissionAction | undefined> = {};
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<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => { export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const [message, setMessage] = React.useState(''); const [message, setMessage] = React.useState('');
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
@@ -236,60 +219,47 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [pendingInputText, consumePendingInputText]); }, [pendingInputText, consumePendingInputText]);
const currentAgent = React.useMemo(() => { const currentAgent = React.useMemo(() => {
if (!currentAgentName) { const selectedName = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || currentAgentName)
: currentAgentName;
if (!selectedName) {
return undefined; return undefined;
} }
return agents.find((agent) => agent.name === currentAgentName); return agents.find((agent) => agent.name === selectedName);
}, [agents, currentAgentName]); }, [agents, currentAgentName, currentSessionId]);
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => { const agentEditAction = React.useMemo<EditPermissionMode>(() => {
if (!currentAgent) { if (!currentAgent) {
return 'deny'; return 'deny';
} }
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
return action;
}, [currentAgent]); }, [currentAgent]);
const sessionAgentEditOverride = useSessionStore( const sessionEditMode = useContextStore(
React.useCallback((state) => { React.useCallback((state) => {
if (!currentSessionId || !currentAgentName) { if (!currentAgentName) {
return undefined; return undefined;
} }
const sessionMap = state.sessionAgentEditModes.get(currentSessionId); const sessionId = currentSessionId ?? '__global__';
return sessionMap?.get(currentAgentName); return state.getSessionAgentEditMode(sessionId, currentAgentName, 'ask');
}, [currentSessionId, currentAgentName]) }, [currentAgentName, currentSessionId])
); );
const agentWebfetchPermission = React.useMemo(() => {
if (!currentAgent) {
return undefined;
}
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
}, [currentAgent]);
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
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 selectionContextReady = Boolean(currentSessionId && currentAgentName);
const effectiveEditPermission = React.useMemo<EditPermissionMode>(() => { const effectiveEditPermission = React.useMemo<EditPermissionMode>(() => {
if (selectionContextReady && sessionAgentEditOverride && permissionUiState.modeAvailability[sessionAgentEditOverride]) { // Only show accent when edits are effectively allowed.
return sessionAgentEditOverride; if (agentEditAction === 'allow') {
return 'allow';
} }
return permissionUiState.cascadeDefaultMode; if (agentEditAction !== 'ask') {
}, [permissionUiState, selectionContextReady, sessionAgentEditOverride]); 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]); const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]);
+50 -275
View File
@@ -35,11 +35,11 @@ import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { getAgentColor } from '@/lib/agentColors'; import { getAgentColor } from '@/lib/agentColors';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { cn, fuzzyMatch } from '@/lib/utils'; import { cn, fuzzyMatch } from '@/lib/utils';
import { useContextStore } from '@/stores/contextStore'; import { useContextStore } from '@/stores/contextStore';
@@ -103,23 +103,6 @@ const resolveWildcardPermissionAction = (ruleset: unknown, permission: string):
return undefined; return undefined;
}; };
const buildPermissionActionMap = (ruleset: unknown, permission: string): Record<string, PermissionAction | undefined> | undefined => {
const rules = asPermissionRuleset(ruleset);
if (!rules || rules.length === 0) {
return undefined;
}
const map: Record<string, PermissionAction | undefined> = {};
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 { interface CapabilityDefinition {
key: 'tool_call' | 'reasoning'; key: 'tool_call' | 'reasoning';
icon: IconComponent; icon: IconComponent;
@@ -301,8 +284,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
saveAgentModelVariantForSession, saveAgentModelVariantForSession,
getAgentModelVariantForSession, getAgentModelVariantForSession,
analyzeAndSaveExternalSessionChoices, analyzeAndSaveExternalSessionChoices,
getSessionAgentEditMode,
setSessionAgentEditMode,
} = useSessionStore(); } = useSessionStore();
const contextHydrated = useContextStore((state) => state.hasHydrated); const contextHydrated = useContextStore((state) => state.hasHydrated);
@@ -328,6 +309,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const uiAgentName = currentSessionId const uiAgentName = currentSessionId
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName) ? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
: 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 { toggleFavoriteModel, isFavoriteModel, addRecentModel, isModelSelectorOpen, setModelSelectorOpen } = useUIStore();
const { favoriteModelsList, recentModelsList } = useModelLists(); const { favoriteModelsList, recentModelsList } = useModelLists();
@@ -349,12 +339,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
} }
return initial; return initial;
}); });
const [mobileEditOptionsOpen, setMobileEditOptionsOpen] = React.useState(false);
// Use global state for model selector (allows Ctrl+M shortcut) // Use global state for model selector (allows Ctrl+M shortcut)
const agentMenuOpen = isModelSelectorOpen; const agentMenuOpen = isModelSelectorOpen;
const setAgentMenuOpen = setModelSelectorOpen; const setAgentMenuOpen = setModelSelectorOpen;
const [desktopEditOptionsOpen, setDesktopEditOptionsOpen] = React.useState(false);
const desktopEditOptionsId = React.useId();
const [desktopModelQuery, setDesktopModelQuery] = React.useState(''); const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0); const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
@@ -372,9 +359,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}, [activeMobilePanel, currentProviderId]); }, [activeMobilePanel, currentProviderId]);
React.useEffect(() => { React.useEffect(() => {
if (activeMobilePanel !== 'agent') {
setMobileEditOptionsOpen(false);
}
if (activeMobilePanel !== 'model') { if (activeMobilePanel !== 'model') {
setMobileModelQuery(''); setMobileModelQuery('');
} }
@@ -386,7 +370,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
prevAgentMenuOpenRef.current = agentMenuOpen; prevAgentMenuOpenRef.current = agentMenuOpen;
if (!agentMenuOpen) { if (!agentMenuOpen) {
setDesktopEditOptionsOpen(false);
setDesktopModelQuery(''); setDesktopModelQuery('');
setModelSelectedIndex(0); setModelSelectedIndex(0);
@@ -405,81 +388,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
setModelSelectedIndex(0); setModelSelectedIndex(0);
}, [desktopModelQuery]); }, [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<EditPermissionMode>(() => { const agentEditAction = React.useMemo<PermissionAction>(() => {
if (!currentAgent) { if (!currentAgent) {
return 'deny'; return 'deny';
} }
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
return action;
}, [currentAgent]); }, [currentAgent]);
const agentWebfetchPermission = React.useMemo(() => { const selectionContextReady = Boolean(uiAgentName);
if (!currentAgent) {
return undefined;
}
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
}, [currentAgent]);
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => { const approveEditsAvailable = agentEditAction === 'ask';
if (!currentAgent) { const approveEditsChecked = approveEditsAvailable
return undefined; ? sessionEditMode === 'allow' || sessionEditMode === 'full'
} : agentEditAction === 'allow';
const map = buildPermissionActionMap(currentAgent.permission, 'bash'); const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable;
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<EditPermissionMode, string> = {
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 sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default'; const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8'; const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
@@ -487,8 +416,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; 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 controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3'; 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 renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
const combinedClassName = cn(iconClass, 'flex-shrink-0'); const combinedClassName = cn(iconClass, 'flex-shrink-0');
const modeColors = getEditModeColors(mode); const modeColors = getEditModeColors(mode);
@@ -507,15 +434,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
return <RiQuestionLine className={combinedClassName} style={iconStyle} />; return <RiQuestionLine className={combinedClassName} style={iconStyle} />;
}, [editToggleIconClass]); }, [editToggleIconClass]);
const handleEditPermissionSelect = React.useCallback((mode: EditPermissionMode) => { const handleApproveEditsToggle = React.useCallback((checked: boolean) => {
if (editToggleDisabled || !currentSessionId || !currentAgentName || isModeDisabled(mode)) { if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) {
return; return;
} }
setSessionAgentEditMode(currentSessionId, currentAgentName, mode, cascadeDefaultMode); setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask');
setAgentMenuOpen(false); }, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]);
setMobileEditOptionsOpen(false);
setDesktopEditOptionsOpen(false);
}, [cascadeDefaultMode, editToggleDisabled, currentSessionId, currentAgentName, setSessionAgentEditMode, setAgentMenuOpen, setDesktopEditOptionsOpen, isModeDisabled]);
const currentProvider = getCurrentProvider(); const currentProvider = getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : []; const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
@@ -1742,94 +1666,20 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
</button> </button>
); );
})} })}
<div className="rounded-xl border border-border/40 bg-sidebar/30"> <div className="rounded-xl bg-transparent">
<button <div className="flex items-center justify-between px-2 py-2">
type="button" <span className={cn(
disabled={editToggleDisabled} 'typography-meta font-medium',
onClick={() => { approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
if (!editToggleDisabled) { )}>
setMobileEditOptionsOpen((previous) => !previous); Auto-approve edits
}
}}
className={cn(
'flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left bg-transparent',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary rounded-t-xl',
editToggleDisabled ? 'cursor-not-allowed opacity-60' : undefined
)}
>
<div className="flex flex-col text-left">
<span
className="typography-meta font-medium text-foreground"
style={{
color: activeEditModeColors ? activeEditModeColors.text : 'var(--foreground)',
mixBlendMode: 'normal',
}}
>
{editPermissionMenuLabel}
</span> </span>
</div> <Switch
<div className="flex items-center gap-2"> checked={approveEditsChecked}
<span disabled={approveEditsDisabled}
className={cn( onCheckedChange={handleApproveEditsToggle}
'flex items-center justify-center p-1',
!activeEditModeColors && 'text-muted-foreground'
)}
style={
activeEditModeColors
? {
color: activeEditModeColors.text,
}
: undefined
}
>
{renderEditModeIcon(effectiveEditMode, editToggleIconClass)}
</span>
<RiArrowDownSLine
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
mobileEditOptionsOpen ? 'rotate-180' : ''
)}
/> />
</div> </div>
</button>
{mobileEditOptionsOpen && !editToggleDisabled && (
<div className="border-t border-border/40 bg-transparent px-2 py-1.5">
<div className="flex flex-col gap-1.5">
{editPermissionOptions.map((option) => {
const isSelected = option.mode === effectiveEditMode;
const optionColors = getEditModeColors(option.mode);
return (
<button
key={option.mode}
type="button"
disabled={option.disabled}
onClick={() => handleEditPermissionSelect(option.mode)}
className={cn(
'flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left',
option.disabled ? 'cursor-not-allowed opacity-50' : 'focus:bg-transparent hover:bg-transparent',
isSelected ? 'bg-primary/10' : undefined
)}
style={isSelected && optionColors ? { backgroundColor: optionColors.background ?? undefined } : undefined}
>
{renderEditModeIcon(option.mode, editToggleIconClass)}
<div className="flex flex-col">
<span
className="typography-meta font-medium"
style={{
color: optionColors
? optionColors.text
: 'var(--foreground)',
}}
>
{option.label}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
</div> </div>
</div> </div>
</MobileOverlayPanel> </MobileOverlayPanel>
@@ -2549,96 +2399,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex flex-col gap-1 px-1 py-0.5"> <div className="flex flex-col gap-1 px-1 py-0.5">
<button <div className="rounded-xl bg-transparent">
type="button" <div className="flex items-center justify-between px-2 py-2">
disabled={editToggleDisabled} <span className={cn(
onClick={() => { 'typography-meta font-medium',
if (editToggleDisabled) { approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
return; )}>
} Auto-approve edits
setDesktopEditOptionsOpen((previous) => !previous);
}}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl px-2 py-2 text-left',
'focus:outline-none focus-visible:ring-0',
editToggleDisabled ? 'cursor-not-allowed opacity-60' : undefined
)}
aria-expanded={desktopEditOptionsOpen}
aria-controls={desktopEditOptionsId}
>
<div className="flex flex-col text-left">
<span
className="typography-meta font-medium text-foreground"
style={{
color: activeEditModeColors
? activeEditModeColors.text
: 'var(--foreground)',
}}
>
{editPermissionMenuLabel}
</span> </span>
</div> <Switch
<div className="flex items-center gap-2"> checked={approveEditsChecked}
<span disabled={approveEditsDisabled}
className={cn( onCheckedChange={handleApproveEditsToggle}
'flex items-center justify-center p-1',
!activeEditModeColors && 'text-muted-foreground'
)}
style={
activeEditModeColors
? {
color: activeEditModeColors.text,
}
: undefined
}
>
{renderEditModeIcon(effectiveEditMode)}
</span>
<RiArrowDownSLine
className={cn(
'h-3.5 w-3.5 transition-transform text-muted-foreground',
desktopEditOptionsOpen ? 'rotate-180' : ''
)}
/> />
</div> </div>
</button>
{desktopEditOptionsOpen && !editToggleDisabled && (
<div
id={desktopEditOptionsId}
className="flex flex-col gap-1 rounded-xl border border-border/40 px-2 py-2 bg-transparent"
role="group"
aria-label="Edit permission options"
>
{editPermissionOptions.map((option) => {
const isSelected = option.mode === effectiveEditMode;
const optionColors = getEditModeColors(option.mode);
return (
<button
key={option.mode}
type="button"
disabled={option.disabled}
onClick={() => handleEditPermissionSelect(option.mode)}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left',
option.disabled ? 'cursor-not-allowed opacity-50' : 'focus:outline-none focus-visible:ring-0',
isSelected ? 'bg-primary/10' : undefined
)}
style={isSelected && optionColors ? { backgroundColor: optionColors.background ?? undefined } : undefined}
>
{renderEditModeIcon(option.mode, editToggleIconClass)}
<span
className="typography-meta font-medium"
style={{
color: optionColors ? optionColors.text : 'var(--foreground)',
}}
>
{option.label}
</span>
</button>
);
})}
</div> </div>
)}
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
File diff suppressed because it is too large Load Diff
@@ -35,14 +35,6 @@ type PermissionRule = { permission: string; pattern: string; action: PermissionA
type PermissionConfigValue = PermissionAction | Record<string, PermissionAction>; type PermissionConfigValue = PermissionAction | Record<string, PermissionAction>;
// 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[] => { const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => {
if (!Array.isArray(ruleset)) { if (!Array.isArray(ruleset)) {
return []; return [];
@@ -66,8 +58,22 @@ const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => {
return parsed; return parsed;
}; };
const normalizeRuleset = (ruleset: PermissionRule[]): PermissionRule[] => {
const map = new Map<string, PermissionRule>();
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 rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] => {
const parsed = toPermissionRuleset(ruleset); const parsed = normalizeRuleset(toPermissionRuleset(ruleset));
if (parsed.length === 0) { if (parsed.length === 0) {
return undefined; return undefined;
} }
@@ -80,49 +86,15 @@ const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] =
(byPermission[rule.permission] ||= {})[rule.pattern] = rule.action; (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<string, PermissionConfigValue> = {}; const result: Record<string, PermissionConfigValue> = {};
for (const permissionName of permissionNames) { for (const [permissionName, map] of Object.entries(byPermission)) {
const map = byPermission[permissionName];
const patterns = Object.keys(map); 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] === '*') { if (patterns.length === 1 && patterns[0] === '*') {
result[permissionName] = map['*']; result[permissionName] = map['*'];
} else { continue;
result[permissionName] = map;
} }
} else {
// Non-wildcard patterns - include as-is
result[permissionName] = map; result[permissionName] = map;
} }
}
return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined; return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined;
}; };
@@ -31,26 +31,89 @@ export const CommandsPage: React.FC = () => {
const [template, setTemplate] = React.useState(''); const [template, setTemplate] = React.useState('');
const [subtask, setSubtask] = React.useState(false); const [subtask, setSubtask] = React.useState(false);
const [isSaving, setIsSaving] = 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(() => { React.useEffect(() => {
if (isNewCommand && commandDraft) { if (isNewCommand && commandDraft) {
// Prefill from draft (for new or duplicated commands) // Prefill from draft (for new or duplicated commands)
setDraftName(commandDraft.name || ''); const draftNameValue = commandDraft.name || '';
setDraftScope(commandDraft.scope || 'user'); const draftScopeValue = commandDraft.scope || 'user';
setDescription(commandDraft.description || ''); const descriptionValue = commandDraft.description || '';
setAgent(commandDraft.agent || ''); const agentValue = commandDraft.agent || '';
setModel(commandDraft.model || ''); const modelValue = commandDraft.model || '';
setTemplate(commandDraft.template || ''); const templateValue = commandDraft.template || '';
setSubtask(commandDraft.subtask || false); 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) { } else if (selectedCommand) {
setDescription(selectedCommand.description || ''); const descriptionValue = selectedCommand.description || '';
setAgent(selectedCommand.agent || ''); const agentValue = selectedCommand.agent || '';
setModel(selectedCommand.model || ''); const modelValue = selectedCommand.model || '';
setTemplate(selectedCommand.template || ''); const templateValue = selectedCommand.template || '';
setSubtask(selectedCommand.subtask || false); 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]); }, [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 handleSave = async () => {
const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim(); const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim();
@@ -352,7 +415,7 @@ Use @filename to include file contents.`}
size="sm" size="sm"
variant="default" variant="default"
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving || !isDirty}
className="gap-2 h-6 px-2 text-xs w-fit" className="gap-2 h-6 px-2 text-xs w-fit"
> >
<RiSaveLine className="h-3 w-3" /> <RiSaveLine className="h-3 w-3" />
@@ -11,17 +11,17 @@ export const getEditModeColors = (mode?: EditPermissionMode | null): EditModeCol
if (mode === 'full') { if (mode === 'full') {
return { return {
text: 'var(--status-info)', text: 'var(--status-info)',
border: 'var(--status-info-border)', border: 'color-mix(in srgb, var(--status-info) 25%, transparent)',
background: 'var(--status-info-background)', background: 'color-mix(in srgb, var(--status-info) 4%, transparent)',
borderWidth: 1.5, borderWidth: 1.5,
}; };
} }
if (mode === 'allow') { if (mode === 'allow') {
return { return {
text: 'var(--status-success)', text: 'var(--status-info)',
border: 'var(--status-success-border)', border: 'color-mix(in srgb, var(--status-info) 25%, transparent)',
background: 'var(--status-success-background)', background: 'color-mix(in srgb, var(--status-info) 4%, transparent)',
borderWidth: 1.5, borderWidth: 1.5,
}; };
} }
+16 -1
View File
@@ -69,6 +69,7 @@ interface ContextActions {
type ContextStore = ContextState & ContextActions; type ContextStore = ContextState & ContextActions;
const EDIT_PERMISSION_SEQUENCE: EditPermissionMode[] = ['ask', 'allow', 'full']; const EDIT_PERMISSION_SEQUENCE: EditPermissionMode[] = ['ask', 'allow', 'full'];
const GLOBAL_EDIT_MODE_SESSION_ID = '__global__';
export const useContextStore = create<ContextStore>()( export const useContextStore = create<ContextStore>()(
devtools( devtools(
@@ -542,7 +543,20 @@ export const useContextStore = create<ContextStore>()(
const sessionMap = get().sessionAgentEditModes.get(sessionId); const sessionMap = get().sessionAgentEditModes.get(sessionId);
const override = sessionMap?.get(agentName); 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)) => { setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode: EditPermissionMode = getAgentDefaultEditPermission(agentName)) => {
@@ -598,6 +612,7 @@ export const useContextStore = create<ContextStore>()(
get().setSessionAgentEditMode(sessionId, agentName, nextMode, normalizedDefault); get().setSessionAgentEditMode(sessionId, agentName, nextMode, normalizedDefault);
}, },
}), }),
{ {
name: "context-store", name: "context-store",
+2 -2
View File
@@ -88,8 +88,8 @@ export const usePermissionStore = create<PermissionStore>()(
const defaultMode = getAgentDefaultEditPermission(agentName); const defaultMode = getAgentDefaultEditPermission(agentName);
const effectiveMode = contextData?.getSessionAgentEditMode?.(sessionId, agentName) ?? defaultMode; const effectiveMode = contextData?.getSessionAgentEditMode?.(sessionId, agentName) ?? defaultMode;
const shouldAutoApprove = effectiveMode === 'full' const shouldAutoApprove = (effectiveMode === 'allow' || effectiveMode === 'full')
|| (effectiveMode === 'allow' && isEditPermissionType(permissionType)); && isEditPermissionType(permissionType);
if (shouldAutoApprove) { if (shouldAutoApprove) {
get().respondToPermission(sessionId, permission.id, 'once').catch(() => { get().respondToPermission(sessionId, permission.id, 'once').catch(() => {