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:
committed by
GitHub
parent
74511abfda
commit
a9952fd8da
@@ -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<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 }) => {
|
||||
const [message, setMessage] = React.useState('');
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
@@ -236,60 +219,47 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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<EditPermissionMode>(() => {
|
||||
const agentEditAction = React.useMemo<EditPermissionMode>(() => {
|
||||
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<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 effectiveEditPermission = React.useMemo<EditPermissionMode>(() => {
|
||||
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]);
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
key: 'tool_call' | 'reasoning';
|
||||
icon: IconComponent;
|
||||
@@ -301,8 +284,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
saveAgentModelVariantForSession,
|
||||
getAgentModelVariantForSession,
|
||||
analyzeAndSaveExternalSessionChoices,
|
||||
getSessionAgentEditMode,
|
||||
setSessionAgentEditMode,
|
||||
} = useSessionStore();
|
||||
|
||||
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
||||
@@ -328,6 +309,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ className }) => {
|
||||
}, [activeMobilePanel, currentProviderId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeMobilePanel !== 'agent') {
|
||||
setMobileEditOptionsOpen(false);
|
||||
}
|
||||
if (activeMobilePanel !== 'model') {
|
||||
setMobileModelQuery('');
|
||||
}
|
||||
@@ -386,7 +370,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
prevAgentMenuOpenRef.current = agentMenuOpen;
|
||||
|
||||
if (!agentMenuOpen) {
|
||||
setDesktopEditOptionsOpen(false);
|
||||
setDesktopModelQuery('');
|
||||
setModelSelectedIndex(0);
|
||||
|
||||
@@ -405,81 +388,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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<EditPermissionMode>(() => {
|
||||
const agentEditAction = React.useMemo<PermissionAction>(() => {
|
||||
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<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 { 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 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<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ className }) => {
|
||||
return <RiQuestionLine className={combinedClassName} style={iconStyle} />;
|
||||
}, [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<ModelControlsProps> = ({ className }) => {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="rounded-xl border border-border/40 bg-sidebar/30">
|
||||
<button
|
||||
type="button"
|
||||
disabled={editToggleDisabled}
|
||||
onClick={() => {
|
||||
if (!editToggleDisabled) {
|
||||
setMobileEditOptionsOpen((previous) => !previous);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'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>
|
||||
</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 className="rounded-xl bg-transparent">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span className={cn(
|
||||
'typography-meta font-medium',
|
||||
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}>
|
||||
Auto-approve edits
|
||||
</span>
|
||||
<Switch
|
||||
checked={approveEditsChecked}
|
||||
disabled={approveEditsDisabled}
|
||||
onCheckedChange={handleApproveEditsToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
@@ -2549,96 +2399,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex flex-col gap-1 px-1 py-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={editToggleDisabled}
|
||||
onClick={() => {
|
||||
if (editToggleDisabled) {
|
||||
return;
|
||||
}
|
||||
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}
|
||||
<div className="rounded-xl bg-transparent">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span className={cn(
|
||||
'typography-meta font-medium',
|
||||
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}>
|
||||
Auto-approve edits
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'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' : ''
|
||||
)}
|
||||
<Switch
|
||||
checked={approveEditsChecked}
|
||||
disabled={approveEditsDisabled}
|
||||
onCheckedChange={handleApproveEditsToggle}
|
||||
/>
|
||||
</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>
|
||||
</DropdownMenuContent>
|
||||
</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>;
|
||||
|
||||
// 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<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 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<string, PermissionConfigValue> = {};
|
||||
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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<RiSaveLine className="h-3 w-3" />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ContextStore>()(
|
||||
devtools(
|
||||
@@ -542,7 +543,20 @@ export const useContextStore = create<ContextStore>()(
|
||||
|
||||
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<ContextStore>()(
|
||||
|
||||
get().setSessionAgentEditMode(sessionId, agentName, nextMode, normalizedDefault);
|
||||
},
|
||||
|
||||
}),
|
||||
{
|
||||
name: "context-store",
|
||||
|
||||
@@ -88,8 +88,8 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user