import React, { useEffect, useMemo, useState } from 'react'; import { createThemedStyles, themedColor, themedSurface } from '../../theme'; import { ActivityIndicator, Pressable, Switch, Text, TextInput, View, } from 'react-native'; import { Check, ChevronDown, Eye, EyeOff, Plus, Trash2, } from 'lucide-react-native'; import { ConnectorBrandIcon, hasConnectorBrandIcon } from '../connectors/connectorBrandIcons'; import { editorShellStyles } from '../editor/editorShellStyles'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { ModelPickerModal, useModelPicker } from './modelPicker'; import { getSelectableModelOptions, resolveModelOption, type ModelOption } from './modelOptions'; import { useSuperagentAgents, useSuperagentConnectors, useSuperagentModelActions, useSuperagentSecrets, useSuperagentShellOptions, } from '../../runtime/runtimeContext'; import { styles as sharedStyles } from '../../styles'; import { useFeatureFlags, useSuperagentModelAccess } from '../../user/userContext'; import type { SuperagentAgent, SuperagentModelChoice, SuperagentSecret, SuperagentToolPermissionConfig, } from '../../types'; const TOOL_OPERATIONS = [ { body: 'Allow the agent to update app data without asking every time.', id: 'update_entities', title: 'Update data', }, { body: 'Allow the agent to delete app data without asking every time.', id: 'delete_entities', title: 'Delete data', }, ]; type PendingAction = | 'delete-agent' | 'delete-secret' | 'rename' | 'save-guards' | 'save-secret' | null; export function AgentSettingsPanel({ agent, section = 'general', }: { agent: SuperagentAgent; // Which group of controls this modal shows. General = name/model/delete; // security = tool permissions/connector guards/secrets (its own menu item). section?: 'general' | 'security'; }) { const { secrets = [], isLoadingAgentSettings: isLoading, onSaveSecret, onDeleteSecret } = useSuperagentSecrets(); const { onUpdateAgentModel, onUpdateAgentAutomationModel, onUpdateToolPermissions } = useSuperagentModelActions(); const { onDeleteAgent, onRenameAgent } = useSuperagentAgents(); const { connectedConnectors = [] } = useSuperagentConnectors(); const bi = useAgentBi(); const { onViewPlans } = useSuperagentShellOptions(); const [nameDraft, setNameDraft] = useState(agent.name ?? ''); const [guardDrafts, setGuardDrafts] = useState>({}); const [newSecretName, setNewSecretName] = useState(''); const [newSecretValue, setNewSecretValue] = useState(''); const [pendingAction, setPendingAction] = useState(null); const [deletingSecretName, setDeletingSecretName] = useState(null); const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); const [nameError, setNameError] = useState(null); const permissions = useMemo( () => normalizePermissions(agent.toolsPermissionConfig), [agent.toolsPermissionConfig], ); // Optimistic override for the permission toggles: flipped immediately on // press, cleared when fresh server state arrives, reverted on failure. const [optimisticOps, setOptimisticOps] = useState(null); const autoApprovedOps = optimisticOps ?? permissions.auto_approved_operations; const { getFlagVariant, hasFlag } = useFeatureFlags(); const { canSelectBestModel } = useSuperagentModelAccess(); const modelOptions = useMemo( () => getSelectableModelOptions(hasFlag, getFlagVariant), [hasFlag, getFlagVariant], ); const activeConnectorIds = useMemo( () => connectedConnectors .filter((connector) => connector.status !== 'expired' && connector.status !== 'disconnected') .map((connector) => connector.id), [connectedConnectors], ); const guardDraftsDirty = useMemo( () => JSON.stringify(cleanGuards(guardDrafts)) !== JSON.stringify(permissions.connector_guards), [guardDrafts, permissions.connector_guards], ); useEffect(() => { // Seed guard drafts only when switching agents — not on every // toolsPermissionConfig change — so in-progress guard edits aren't wiped by // an unrelated permission toggle before "Save rules" is pressed. setGuardDrafts(permissions.connector_guards); // eslint-disable-next-line react-hooks/exhaustive-deps }, [agent.id]); useEffect(() => { // Fresh server state (or an agent switch) supersedes the optimistic flip. setOptimisticOps(null); }, [agent.id, agent.toolsPermissionConfig]); useEffect(() => { // Re-seed when the agent switches OR its name changes upstream (e.g. a rename // from the header modal), so General can't keep a stale draft and re-save the // old name. A local in-progress edit doesn't change agent.name, so typing is // preserved until an actual upstream change arrives. setNameDraft(agent.name ?? ''); setNameError(null); }, [agent.id, agent.name]); const trimmedName = nameDraft.trim(); const nameDirty = trimmedName.length > 0 && trimmedName !== (agent.name ?? ''); const saveName = async () => { // Bail while any other action is in flight: runAction no-ops when // pendingAction is set, which would otherwise clear nameError and look like // a successful save while the rename never ran. if (!onRenameAgent || !nameDirty || pendingAction) { return; } // The host's rename handler rethrows on failure without surfacing it, so // show the error inline here (the panel has no other feedback) and keep the // draft for retry. setNameError(null); try { await runAction('rename', async () => { await onRenameAgent({ agentId: agent.id, name: trimmedName }); }); } catch (error) { setNameError(error instanceof Error && error.message ? error.message : 'Could not rename the agent. Please try again.'); } }; const runAction = async (action: Exclude, handler: () => Promise | void) => { if (pendingAction) { return; } setPendingAction(action); try { await handler(); } finally { setPendingAction(null); } }; const updatePermissions = async ( nextConfig: SuperagentToolPermissionConfig, action: Exclude, ) => { if (!onUpdateToolPermissions) { return; } try { await runAction(action, async () => { await onUpdateToolPermissions({ agentId: agent.id, config: { auto_approved_operations: nextConfig.auto_approved_operations ?? [], connector_guards: cleanGuards(nextConfig.connector_guards ?? {}), }, }); // Inside the runAction handler: only fires when it actually ran (no // pending-action no-op) and the update resolved (rethrows on failure). void bi.trackEditor('Connector Rules Save'); }); } catch { // onUpdateToolPermissions already surfaced the error; guard drafts stay // dirty so the user can retry. (Nothing optimistic to revert on this path.) } }; const toggleOperation = (operationId: string, enabled: boolean) => { if (!onUpdateToolPermissions) { return; } void bi.trackEditor('Tool Permission Toggle', { tool: operationId, enabled }); const nextOps = new Set(autoApprovedOps); if (enabled) { nextOps.add(operationId); } else { nextOps.delete(operationId); } const nextList = Array.from(nextOps); // Optimistic: flip the switch now, sync with the server in the background, // and fall back to the last known server state if the update fails. setOptimisticOps(nextList); Promise.resolve(onUpdateToolPermissions({ agentId: agent.id, config: { auto_approved_operations: nextList, connector_guards: cleanGuards(permissions.connector_guards), }, })).catch(() => { // Revert only this operation, not every optimistic flip: a sibling toggle // the user changed in parallel may have already saved successfully, and // clearing all optimistic state would visually undo it until a refresh. setOptimisticOps((current) => { const reverted = new Set(current ?? permissions.auto_approved_operations); if (enabled) { reverted.delete(operationId); } else { reverted.add(operationId); } return Array.from(reverted); }); }); }; const addSecret = async () => { const name = newSecretName.trim().toUpperCase(); // Preserve the secret value byte-for-byte — PEM keys, tokens with a trailing // newline, and whitespace-sensitive passwords must reach the backend exactly as // typed (the API client sends it raw). Only require it to be non-empty. const value = newSecretValue; if (!name || !value.trim() || !onSaveSecret) { return; } // Only clear the inputs after a successful save; if onSaveSecret rejects it // already surfaced the error, and we keep the typed values so they aren't lost. try { await runAction('save-secret', async () => { await onSaveSecret({ agentId: agent.id, name, value }); setNewSecretName(''); setNewSecretValue(''); void bi.trackEditor('Secret Add'); }); } catch { // onSaveSecret already surfaced the error; keep the form populated. } }; const deleteSecret = async (secret: SuperagentSecret) => { if (!onDeleteSecret) { return; } setDeletingSecretName(secret.name); try { // `Secret Delete` is tracked in the runtime's onDeleteSecret success branch — // here it would also fire on the confirm-cancel / API-failure paths, which // onDeleteSecret swallows without throwing. await runAction('delete-secret', () => onDeleteSecret({ agentId: agent.id, name: secret.name })); } finally { setDeletingSecretName(null); } }; return ( {section === 'general' ? ( Name { setNameDraft(value); if (nameError) { setNameError(null); } }} placeholder="Agent name" placeholderTextColor={themedColor('#71717A')} style={localStyles.textInput} value={nameDraft} /> {nameError ? {nameError} : null} {onRenameAgent ? ( [ localStyles.secondaryButton, (pendingAction !== null || !nameDirty) && localStyles.disabledButton, pressed && sharedStyles.pressed, ]} > {pendingAction === 'rename' ? ( ) : ( )} Save name ) : null} ) : null} {section === 'general' ? ( Chat model Automation model ) : null} {section === 'security' ? ( {secrets.length > 0 ? ( {secrets.map((secret) => { const isVisible = visibleSecrets.has(secret.name); return ( {secret.name} {isVisible ? secret.value : '••••••••••••'} setVisibleSecrets((current) => toggleSecretVisibility(current, secret.name))} style={({ pressed }) => [localStyles.iconButton, pressed && sharedStyles.pressed]} > {isVisible ? ( ) : ( )} deleteSecret(secret)} style={({ pressed }) => [localStyles.iconButtonDanger, pressed && sharedStyles.pressed]} > {deletingSecretName === secret.name ? ( ) : ( )} ); })} ) : ( No secrets yet. )} setNewSecretName(value.toUpperCase())} placeholder="SECRET_NAME" placeholderTextColor={themedColor('#71717A')} style={localStyles.textInput} value={newSecretName} /> [ localStyles.primaryButton, (!onSaveSecret || !newSecretName.trim() || !newSecretValue.trim()) && localStyles.disabledButton, pressed && sharedStyles.pressed, ]} > {pendingAction === 'save-secret' ? ( ) : ( )} Add ) : null} {section === 'security' ? ( {TOOL_OPERATIONS.map((operation) => { const isEnabled = autoApprovedOps.includes(operation.id); return ( {operation.title} {operation.body} toggleOperation(operation.id, enabled)} thumbColor="#F4F4F5" trackColor={{ false: themedSurface('#2A2A2A'), true: '#246B43' }} value={isEnabled} /> ); })} ) : null} {section === 'security' ? ( {activeConnectorIds.length > 0 ? ( activeConnectorIds.map((connectorId) => { const connector = connectedConnectors.find((item) => item.id === connectorId); return ( {hasConnectorBrandIcon(connectorId) ? ( ) : ( {connector?.iconFallbackLabel ?? connectorId.slice(0, 2).toUpperCase()} )} {connector?.name ?? connectorId} {connector?.accountIdentifier || 'Connected'} { setGuardDrafts((current) => ({ ...current, [connectorId]: value.slice(0, 500), })); }} placeholder="Example: ask before sending email outside my domain." placeholderTextColor={themedColor('#71717A')} style={[localStyles.textInput, localStyles.guardInput]} value={guardDrafts[connectorId] ?? ''} /> ); }) ) : ( Connect a tool to add connector-specific rules. )} {activeConnectorIds.length > 0 ? ( updatePermissions({ // Carry the latest optimistic toggle state (autoApprovedOps), not the // stale server-backed list: this PUT sends the FULL config, so using // the server value would clobber a toggle the user just flipped but // whose own request hasn't synced agent.toolsPermissionConfig yet. auto_approved_operations: autoApprovedOps, connector_guards: guardDrafts, }, 'save-guards')} style={({ pressed }) => [ localStyles.secondaryButton, (!guardDraftsDirty || !onUpdateToolPermissions) && localStyles.disabledButton, pressed && sharedStyles.pressed, ]} > {pendingAction === 'save-guards' ? ( ) : ( )} Save rules ) : null} ) : null} {section === 'general' ? ( Delete this agent Permanently remove this agent and all its data, conversations, files, and integrations. This action cannot be undone. { void bi.trackEditor('Agent Delete Click'); void runAction('delete-agent', () => onDeleteAgent({ agentId: agent.id })); } : undefined} style={({ pressed }) => [ localStyles.destructiveButton, (!onDeleteAgent || pendingAction === 'delete-agent') && localStyles.disabledButton, pressed && sharedStyles.pressed, ]} > {pendingAction === 'delete-agent' ? ( ) : ( )} Delete this agent ) : null} ); } function SettingsSection({ children, isLoading, title, tone = 'default', }: { children: React.ReactNode; isLoading?: boolean; title: string; tone?: 'default' | 'danger'; }) { const isDanger = tone === 'danger'; return ( {title} {isLoading ? ( Loading... ) : children} ); } function ModelField({ agentId, canSelectBestModel, modelOptions, onSelect, onViewPlans, selected, }: { agentId: string; canSelectBestModel: boolean; modelOptions: ModelOption[]; onSelect?: (input: { agentId: string; model: SuperagentModelChoice | string }) => Promise | void; onViewPlans?: () => void; selected: ModelOption; }) { const picker = useModelPicker({ agentId, selected, options: modelOptions, onSelect, canSelectBestModel, onViewPlans }); return ( <> [ localStyles.modelDropdown, picker.disabled && localStyles.disabledButton, pressed && sharedStyles.pressed, ]} > {selected.label} {picker.isUpdating ? ( ) : ( )} ); } function normalizePermissions(config?: SuperagentToolPermissionConfig): Required { return { auto_approved_operations: config?.auto_approved_operations ?? [], connector_guards: config?.connector_guards ?? {}, }; } function cleanGuards(guards: Record) { return Object.fromEntries( Object.entries(guards) .map(([key, value]) => [key, value.trim()]) .filter(([, value]) => value), ); } function toggleSecretVisibility(current: Set, name: string) { const next = new Set(current); if (next.has(name)) { next.delete(name); } else { next.add(name); } return next; } const localStyles = createThemedStyles({ addSecretBox: { gap: 8, marginTop: 11, }, connectorFallback: { color: '#F4F4F5', fontSize: 10, fontWeight: '900', }, connectorGuard: { marginTop: 10, }, connectorHeader: { alignItems: 'center', flexDirection: 'row', marginBottom: 8, }, connectorIcon: { alignItems: 'center', backgroundColor: '#F4F4F5', borderRadius: 10, height: 34, justifyContent: 'center', marginRight: 10, overflow: 'hidden', width: 34, }, connectorTitleWrap: { flex: 1, minWidth: 0, }, dangerSectionCard: { borderColor: '#552022', }, dangerText: { color: '#FCA5A5', }, disabledButton: { opacity: 0.55, }, errorText: { color: '#FCA5A5', fontSize: 12, fontWeight: '700', marginTop: 8, }, deleteDescription: { color: '#A1A1AA', fontSize: 13, fontWeight: '600', lineHeight: 18, marginBottom: 14, marginTop: 4, }, destructiveButton: { alignItems: 'center', alignSelf: 'flex-start', backgroundColor: '#1C0F10', borderColor: '#552022', borderRadius: 8, borderWidth: 1, flexDirection: 'row', gap: 8, minHeight: 40, paddingHorizontal: 14, paddingVertical: 9, }, destructiveButtonText: { color: '#FCA5A5', fontSize: 13, fontWeight: '800', }, fieldLabel: { color: '#F4F4F5', fontSize: 13, fontWeight: '700', marginBottom: 8, }, fieldLabelSpaced: { marginTop: 16, }, guardInput: { minHeight: 74, paddingTop: 10, textAlignVertical: 'top', }, iconButton: { alignItems: 'center', backgroundColor: '#242427', borderRadius: 10, height: 34, justifyContent: 'center', marginLeft: 7, width: 34, }, iconButtonDanger: { alignItems: 'center', backgroundColor: '#351516', borderRadius: 10, height: 34, justifyContent: 'center', marginLeft: 7, width: 34, }, loadingRow: { alignItems: 'center', flexDirection: 'row', gap: 9, paddingVertical: 12, }, modelDropdown: { alignItems: 'center', backgroundColor: '#0B0B0C', borderColor: '#2A2A2A', borderRadius: 8, borderWidth: 1, flexDirection: 'row', minHeight: 48, paddingLeft: 14, paddingRight: 12, paddingVertical: 9, }, modelDropdownIcon: { alignItems: 'center', justifyContent: 'center', marginLeft: 10, }, modelDropdownValue: { color: '#F4F4F5', flex: 1, fontSize: 15, fontWeight: '700', minWidth: 0, }, mutedText: { color: '#A1A1AA', fontSize: 12, fontWeight: '700', lineHeight: 17, }, permissionRow: { alignItems: 'center', backgroundColor: '#151515', borderColor: '#2A2A2A', borderRadius: 8, borderWidth: 1, flexDirection: 'row', marginBottom: 9, minHeight: 70, paddingHorizontal: 12, paddingVertical: 10, }, permissionText: { flex: 1, marginRight: 10, minWidth: 0, }, primaryButton: { alignItems: 'center', alignSelf: 'flex-start', backgroundColor: '#F4F4F5', borderRadius: 8, flexDirection: 'row', minHeight: 38, paddingHorizontal: 12, }, primaryButtonText: { color: '#111111', fontSize: 13, fontWeight: '900', marginLeft: 6, }, rowBody: { color: '#A1A1AA', fontSize: 12, fontWeight: '700', lineHeight: 17, marginTop: 3, }, rowTitle: { color: '#F4F4F5', fontSize: 14, fontWeight: '900', }, secondaryButton: { alignItems: 'center', alignSelf: 'flex-start', backgroundColor: '#242427', borderColor: '#353539', borderRadius: 8, borderWidth: 1, flexDirection: 'row', marginTop: 11, minHeight: 38, paddingHorizontal: 12, }, secondaryButtonText: { color: '#F4F4F5', fontSize: 13, fontWeight: '900', marginLeft: 6, }, secretList: { gap: 8, }, secretName: { color: '#F4F4F5', fontFamily: 'Courier', fontSize: 12, fontWeight: '900', }, secretRow: { alignItems: 'center', backgroundColor: '#151515', borderColor: '#2A2A2A', borderRadius: 8, borderWidth: 1, flexDirection: 'row', minHeight: 58, paddingHorizontal: 11, paddingVertical: 9, }, secretText: { flex: 1, minWidth: 0, }, secretValue: { color: '#A1A1AA', fontSize: 12, fontWeight: '700', marginTop: 3, }, sectionCard: { backgroundColor: '#0B0B0C', borderColor: '#242427', borderRadius: 8, borderWidth: 1, marginBottom: 14, padding: 16, }, sectionTitle: { color: '#F4F4F5', fontSize: 15, fontWeight: '700', marginBottom: 12, }, textInput: { backgroundColor: '#0B0B0C', borderColor: '#2A2A2A', borderRadius: 8, borderWidth: 1, color: '#F4F4F5', fontSize: 13, fontWeight: '700', minHeight: 48, paddingHorizontal: 11, }, });