docs/plans/2026-01-08-plugins-state-management-refactor.md
Refactor Plugins.tsx to remove interstitial state management between PluginsTab.tsx and the Zustand store. The current implementation maintains duplicate local state (selectedPlugins, pluginConfig, hasUserInteracted) with bi-directional sync effects. This refactor will derive state directly from the store and update the store directly on user interactions.
useRedTeamConfig.ts) - source of truthPlugins.tsx maintains selectedPlugins, pluginConfig, hasUserInteractedPluginsTab.tsx receives state via propssrc/app/src/pages/redteam/setup/components/Plugins.tsx - Parent component with local statesrc/app/src/pages/redteam/setup/components/PluginsTab.tsx - Child component receiving propssrc/app/src/pages/redteam/setup/hooks/useRedTeamConfig.ts - Zustand storeconst [selectedPlugins, setSelectedPlugins] - lines 112-118const [hasUserInteracted, setHasUserInteracted] - line 135const [pluginConfig, setPluginConfig] - lines 136-144!hasUserInteractedhasUserInteractedThe updatePlugins method (useRedTeamConfig.ts:272-311) already:
After this refactor:
Plugins.tsx derives selectedPlugins and pluginConfig from config.plugins using useMemoPluginsTab has a new setSelectedPlugins prop for efficient bulk operationsonUserInteraction prop is removed from PluginsTabPluginsTab.test.tsx passindirect-prompt-injection) are preserved when togglingCustomIntentsTab or CustomPoliciesTabPluginConfigDialog componentThe refactoring follows these principles:
useMemo to compute selectedPlugins and pluginConfig from config.pluginsupdatePluginssetSelectedPlugins for presets and bulk selectionPlugins.tsxRemove local state and sync effects, replace with derived values and direct store updates.
File: src/app/src/pages/redteam/setup/components/Plugins.tsx
Remove lines 112-118 (selectedPlugins state):
// REMOVE THIS:
const [selectedPlugins, setSelectedPlugins] = useState<Set<Plugin>>(() => {
return new Set(
config.plugins
.map((plugin) => (typeof plugin === 'string' ? plugin : plugin.id))
.filter((id) => id !== 'policy' && id !== 'intent') as Plugin[],
);
});
Remove line 135 (hasUserInteracted state):
// REMOVE THIS:
const [hasUserInteracted, setHasUserInteracted] = useState(false);
Remove lines 136-144 (pluginConfig state):
// REMOVE THIS:
const [pluginConfig, setPluginConfig] = useState<LocalPluginConfig>(() => {
const initialConfig: LocalPluginConfig = {};
config.plugins.forEach((plugin) => {
if (typeof plugin === 'object' && plugin.config) {
initialConfig[plugin.id] = plugin.config;
}
});
return initialConfig;
});
Add after the store hook calls (after line 108):
// Derive selectedPlugins from config.plugins
const selectedPlugins = useMemo(() => {
return new Set(
config.plugins
.map((plugin) => (typeof plugin === 'string' ? plugin : plugin.id))
.filter((id) => id !== 'policy' && id !== 'intent') as Plugin[],
);
}, [config.plugins]);
// Derive pluginConfig from config.plugins
const pluginConfig = useMemo(() => {
const configs: LocalPluginConfig = {};
config.plugins.forEach((plugin) => {
if (typeof plugin === 'object' && plugin.config) {
configs[plugin.id] = plugin.config;
}
});
return configs;
}, [config.plugins]);
Remove lines 152-168 (Effect 1 - config → local sync):
// REMOVE THIS ENTIRE EFFECT:
useEffect(() => {
if (!hasUserInteracted) {
const configPlugins = new Set(
config.plugins
.map((plugin) => (typeof plugin === 'string' ? plugin : plugin.id))
.filter((id) => id !== 'policy' && id !== 'intent') as Plugin[],
);
if (
configPlugins.size !== selectedPlugins.size ||
!Array.from(configPlugins).every((plugin) => selectedPlugins.has(plugin))
) {
setSelectedPlugins(configPlugins);
}
}
}, [config.plugins, hasUserInteracted, selectedPlugins]);
Remove lines 171-199 (Effect 2 - local → config sync):
// REMOVE THIS ENTIRE EFFECT:
useEffect(() => {
if (hasUserInteracted) {
const policyPlugins = config.plugins.filter((p) => typeof p === 'object' && p.id === 'policy');
const intentPlugins = config.plugins.filter((p) => typeof p === 'object' && p.id === 'intent');
const regularPlugins = Array.from(selectedPlugins).map((plugin) => {
const existingConfig = pluginConfig[plugin];
if (existingConfig && Object.keys(existingConfig).length > 0) {
return {
id: plugin,
config: existingConfig,
};
}
return plugin;
});
const allPlugins = [...regularPlugins, ...policyPlugins, ...intentPlugins];
updatePlugins(allPlugins as Array<string | { id: string; config: any }>);
}
}, [selectedPlugins, pluginConfig, hasUserInteracted, config.plugins, updatePlugins]);
handlePluginToggleReplace the current implementation (lines 201-236) with:
const handlePluginToggle = useCallback(
(plugin: Plugin) => {
// Preserve policy and intent plugins
const policyPlugins = config.plugins.filter((p) => typeof p === 'object' && p.id === 'policy');
const intentPlugins = config.plugins.filter((p) => typeof p === 'object' && p.id === 'intent');
// Get current regular plugins (excluding policy/intent)
const currentRegularPlugins = config.plugins.filter((p) => {
const id = typeof p === 'string' ? p : p.id;
return id !== 'policy' && id !== 'intent';
});
const isCurrentlySelected = selectedPlugins.has(plugin);
let newRegularPlugins: Config['plugins'];
if (isCurrentlySelected) {
// Remove the plugin
newRegularPlugins = currentRegularPlugins.filter((p) => {
const id = typeof p === 'string' ? p : p.id;
return id !== plugin;
});
} else {
// Add the plugin
addPlugin(plugin); // Add to recently used
newRegularPlugins = [...currentRegularPlugins, plugin];
}
// Combine all plugins and update store
const allPlugins = [...newRegularPlugins, ...policyPlugins, ...intentPlugins];
updatePlugins(allPlugins);
},
[config.plugins, selectedPlugins, updatePlugins, addPlugin],
);
setSelectedPlugins Handler for Bulk OperationsAdd after handlePluginToggle:
const setSelectedPlugins = useCallback(
(newSelectedPlugins: Set<Plugin>) => {
// Preserve policy and intent plugins
const policyPlugins = config.plugins.filter((p) => typeof p === 'object' && p.id === 'policy');
const intentPlugins = config.plugins.filter((p) => typeof p === 'object' && p.id === 'intent');
// Create new plugins array, preserving configs from existing plugins
const newPluginsArray: Config['plugins'] = Array.from(newSelectedPlugins).map((plugin) => {
const existing = config.plugins.find((p) => (typeof p === 'string' ? p : p.id) === plugin);
if (existing && typeof existing === 'object' && existing.config) {
return existing; // Preserve existing config
}
return plugin;
});
// Combine all plugins and update store
const allPlugins = [...newPluginsArray, ...policyPlugins, ...intentPlugins];
updatePlugins(allPlugins);
},
[config.plugins, updatePlugins],
);
updatePluginConfigReplace the current implementation (lines 238-257) with:
const updatePluginConfig = useCallback(
(plugin: string, newConfig: Partial<LocalPluginConfig[string]>) => {
// Build new plugins array with updated config
const newPlugins = config.plugins.map((p) => {
const id = typeof p === 'string' ? p : p.id;
if (id === plugin) {
const existingConfig = typeof p === 'object' ? p.config || {} : {};
return {
id: plugin,
config: { ...existingConfig, ...newConfig },
};
}
return p;
});
updatePlugins(newPlugins);
},
[config.plugins, updatePlugins],
);
Update the PluginsTab component call (around line 431):
<PluginsTab
selectedPlugins={selectedPlugins}
handlePluginToggle={handlePluginToggle}
setSelectedPlugins={setSelectedPlugins} // NEW PROP
pluginConfig={pluginConfig}
updatePluginConfig={updatePluginConfig}
recentlyUsedPlugins={recentlyUsedSnapshot}
isRemoteGenerationDisabled={isRemoteGenerationDisabled}
/>
Remove the onUserInteraction prop.
npm run tsc from src/appnpm run lintnpm run test:app -- src/pages/redteam/setup/components/PluginsTab.test.tsxImplementation Note: After completing this phase and all automated verification passes, proceed to Phase 2.
PluginsTab.tsxUpdate the PluginsTab component to use the new setSelectedPlugins prop and remove onUserInteraction.
File: src/app/src/pages/redteam/setup/components/PluginsTab.tsx
Replace lines 64-72:
export interface PluginsTabProps {
selectedPlugins: Set<Plugin>;
handlePluginToggle: (plugin: Plugin) => void;
setSelectedPlugins: (plugins: Set<Plugin>) => void; // NEW
pluginConfig: LocalPluginConfig;
updatePluginConfig: (plugin: string, newConfig: Partial<LocalPluginConfig[string]>) => void;
recentlyUsedPlugins: Plugin[];
isRemoteGenerationDisabled: boolean;
// REMOVED: onUserInteraction
}
Update lines 74-82:
export default function PluginsTab({
selectedPlugins,
handlePluginToggle,
setSelectedPlugins, // NEW
pluginConfig,
updatePluginConfig,
recentlyUsedPlugins,
isRemoteGenerationDisabled,
}: PluginsTabProps): React.ReactElement {
handlePresetSelectReplace the current implementation (around lines 367-392):
const handlePresetSelect = useCallback(
(preset: { name: string; plugins: Set<Plugin> | ReadonlySet<Plugin> }) => {
recordEvent('feature_used', {
feature: 'redteam_config_plugins_preset_selected',
preset: preset.name,
});
if (preset.name === 'Custom') {
setIsCustomMode(true);
} else {
// Use setSelectedPlugins for efficient bulk update
setSelectedPlugins(new Set(preset.plugins as Set<Plugin>));
setIsCustomMode(false);
}
},
[recordEvent, setSelectedPlugins],
);
Replace lines 485-494:
onClick={() => {
// Collect all filtered plugins and merge with existing selection
const newSelected = new Set(selectedPlugins);
filteredPlugins.forEach(({ plugin }) => {
newSelected.add(plugin);
});
setSelectedPlugins(newSelected);
}}
Replace lines 499-508:
onClick={() => {
// Remove only the filtered plugins from selection
const filteredPluginIds = new Set(filteredPlugins.map((p) => p.plugin));
const newSelected = new Set(
[...selectedPlugins].filter((p) => !filteredPluginIds.has(p)),
);
setSelectedPlugins(newSelected);
}}
Replace lines 816-820:
onClick={() => {
setSelectedPlugins(new Set());
}}
npm run tsc from src/appnpm run lintnpm run test:app -- src/pages/redteam/setup/components/PluginsTab.test.tsxImplementation Note: After completing this phase and all automated verification passes, proceed to Phase 3.
Run the full test suite and fix any test failures. Tests should mostly pass since they test end-to-end behavior (user interaction → store state), not implementation details.
cd src/app
npm run test -- src/pages/redteam/setup/components/PluginsTab.test.tsx
The tests should largely pass as-is because they:
userEvent to simulate clicks (still works)However, if any tests reference onUserInteraction in expectations or setup, they will need updates.
If needed, remove references to onUserInteraction in test mocks or assertions.
PluginsTab.test.tsx pass (44 tests passed)npm run dev:appindirect-prompt-injection, configure it, then toggle other plugins → verify config is preservedImplementation Note: ✅ All automated and manual verification complete. Refactor is complete.
The existing test suite in PluginsTab.test.tsx covers:
useMemo derivation is O(n) where n = number of pluginsupdatePlugins already has JSON comparison optimizationdocs/research/2026-01-08-redteam-plugins-state-management.mdsrc/app/src/pages/redteam/setup/components/PluginsTab.test.tsxsrc/app/src/pages/redteam/setup/hooks/useRedTeamConfig.ts