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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user