Files
Maestro/src/renderer/components/EditGroupChatModal.tsx
Pedram Amini 963531e4ff ## CHANGES
- Swapped moderator selection tiles for a clean agent dropdown picker 🔽
- Added expandable “Customize” panel directly inside both group chat modals ⚙️
- Lazily loads agent config/models only when customization panel expands 🚀
- Resets custom path/args/env overrides automatically when changing agents 🧹
- Displays clear “Detecting agents…” inline spinner during agent discovery 
- Labels Codex, OpenCode, and Factory Droid options as “(Beta)” 🧪
- Expands empty-state messaging to recommend installing Factory Droid too 🧩
- Simplified modal flow by removing separate config-view navigation entirely 🧭
- Improved accessibility with proper combobox/button roles in modal controls 
- Strengthened test coverage for dropdown options and moderator-change warning 🧯
2026-02-03 15:17:25 -06:00

524 lines
16 KiB
TypeScript

/**
* EditGroupChatModal.tsx
*
* Modal for editing an existing Group Chat. Allows user to:
* - Change the name of the group chat
* - Change the moderator agent via dropdown
* - Customize moderator settings (CLI args, path, ENV vars) via expandable panel
*
* Similar to NewGroupChatModal but pre-populated with existing values.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { X, Settings, ChevronDown, Check } from 'lucide-react';
import type { Theme, AgentConfig, ModeratorConfig, GroupChat } from '../types';
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { Modal, ModalFooter, FormInput } from './ui';
import { AGENT_TILES } from './Wizard/screens/AgentSelectionScreen';
import { AgentConfigPanel } from './shared/AgentConfigPanel';
import { SshRemoteSelector } from './shared/SshRemoteSelector';
interface EditGroupChatModalProps {
theme: Theme;
isOpen: boolean;
groupChat: GroupChat | null;
onClose: () => void;
onSave: (
id: string,
name: string,
moderatorAgentId: string,
moderatorConfig?: ModeratorConfig
) => void;
}
export function EditGroupChatModal({
theme,
isOpen,
groupChat,
onClose,
onSave,
}: EditGroupChatModalProps): JSX.Element | null {
const [name, setName] = useState('');
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
const [detectedAgents, setDetectedAgents] = useState<AgentConfig[]>([]);
const [isDetecting, setIsDetecting] = useState(true);
// Configuration panel state - expandable below dropdown
const [isConfigExpanded, setIsConfigExpanded] = useState(false);
// Custom moderator configuration state
const [customPath, setCustomPath] = useState('');
const [customArgs, setCustomArgs] = useState('');
const [customEnvVars, setCustomEnvVars] = useState<Record<string, string>>({});
const [agentConfig, setAgentConfig] = useState<Record<string, any>>({});
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [refreshingAgent, setRefreshingAgent] = useState(false);
// Track if user has visited/modified the config panel (agent-level settings like model)
const [configWasModified, setConfigWasModified] = useState(false);
// SSH Remote configuration state
const [sshRemotes, setSshRemotes] = useState<SshRemoteConfig[]>([]);
const [sshRemoteConfig, setSshRemoteConfig] = useState<AgentSshRemoteConfig | undefined>(
undefined
);
const nameInputRef = useRef<HTMLInputElement>(null);
// Ref to track latest agentConfig for async save operations
const agentConfigRef = useRef<Record<string, any>>({});
// Initialize state from groupChat when modal opens
useEffect(() => {
if (!isOpen || !groupChat) {
return;
}
// Pre-populate from existing group chat
setName(groupChat.name);
setSelectedAgent(groupChat.moderatorAgentId);
setCustomPath(groupChat.moderatorConfig?.customPath || '');
setCustomArgs(groupChat.moderatorConfig?.customArgs || '');
setCustomEnvVars(groupChat.moderatorConfig?.customEnvVars || {});
setIsConfigExpanded(false);
setAgentConfig({});
setAvailableModels([]);
setLoadingModels(false);
setRefreshingAgent(false);
}, [isOpen, groupChat]);
// Reset state when modal closes
const resetState = useCallback(() => {
setName('');
setSelectedAgent(null);
setIsDetecting(true);
setIsConfigExpanded(false);
setCustomPath('');
setCustomArgs('');
setCustomEnvVars({});
setAgentConfig({});
setAvailableModels([]);
setLoadingModels(false);
setRefreshingAgent(false);
setConfigWasModified(false);
setSshRemoteConfig(undefined);
}, []);
// Detect agents on mount
useEffect(() => {
if (!isOpen) {
resetState();
return;
}
async function detect() {
try {
const agents = await window.maestro.agents.detect();
const available = agents.filter((a: AgentConfig) => a.available && !a.hidden);
setDetectedAgents(available);
} catch (error) {
console.error('Failed to detect agents:', error);
} finally {
setIsDetecting(false);
}
}
async function loadSshRemotes() {
try {
const configsResult = await window.maestro.sshRemote.getConfigs();
if (configsResult.success && configsResult.configs) {
setSshRemotes(configsResult.configs);
}
} catch (error) {
console.error('Failed to load SSH remotes:', error);
}
}
detect();
loadSshRemotes();
}, [isOpen, resetState]);
// Focus name input when agents detected
useEffect(() => {
if (!isDetecting && isOpen) {
nameInputRef.current?.focus();
}
}, [isDetecting, isOpen]);
// Load agent config when expanding configuration panel
useEffect(() => {
if (isConfigExpanded && selectedAgent) {
loadAgentConfig(selectedAgent);
}
}, [isConfigExpanded, selectedAgent]);
// Load agent configuration
const loadAgentConfig = useCallback(
async (agentId: string) => {
const config = await window.maestro.agents.getConfig(agentId);
setAgentConfig(config || {});
agentConfigRef.current = config || {};
// Load models if agent supports it
const agent = detectedAgents.find((a) => a.id === agentId);
if (agent?.capabilities?.supportsModelSelection) {
setLoadingModels(true);
try {
const models = await window.maestro.agents.getModels(agentId);
setAvailableModels(models);
} catch (err) {
console.error('Failed to load models:', err);
} finally {
setLoadingModels(false);
}
}
},
[detectedAgents]
);
// Build moderator config from state
const buildModeratorConfig = useCallback((): ModeratorConfig | undefined => {
const hasConfig = customPath || customArgs || Object.keys(customEnvVars).length > 0;
if (!hasConfig) return undefined;
return {
customPath: customPath || undefined,
customArgs: customArgs || undefined,
customEnvVars: Object.keys(customEnvVars).length > 0 ? customEnvVars : undefined,
};
}, [customPath, customArgs, customEnvVars]);
const handleSave = useCallback(() => {
if (name.trim() && selectedAgent && groupChat) {
const moderatorConfig = buildModeratorConfig();
onSave(groupChat.id, name.trim(), selectedAgent, moderatorConfig);
resetState();
onClose();
}
}, [name, selectedAgent, groupChat, buildModeratorConfig, onSave, resetState, onClose]);
// Check if anything has changed
const hasChanges = useCallback((): boolean => {
if (!groupChat) return false;
const nameChanged = name.trim() !== groupChat.name;
const agentChanged = selectedAgent !== groupChat.moderatorAgentId;
const pathChanged = customPath !== (groupChat.moderatorConfig?.customPath || '');
const argsChanged = customArgs !== (groupChat.moderatorConfig?.customArgs || '');
const originalEnvVars = groupChat.moderatorConfig?.customEnvVars || {};
const envVarsChanged = JSON.stringify(customEnvVars) !== JSON.stringify(originalEnvVars);
// Also consider changes if user modified agent-level config (model, etc.)
return (
nameChanged ||
agentChanged ||
pathChanged ||
argsChanged ||
envVarsChanged ||
configWasModified
);
}, [groupChat, name, selectedAgent, customPath, customArgs, customEnvVars, configWasModified]);
const canSave = name.trim().length > 0 && selectedAgent !== null && hasChanges();
// Toggle configuration panel
const handleToggleConfig = useCallback(() => {
setIsConfigExpanded((prev) => !prev);
}, []);
// Refresh agent detection after config changes
const refreshAgentDetection = useCallback(async () => {
const agents = await window.maestro.agents.detect();
const visible = agents.filter((a: AgentConfig) => !a.hidden);
setDetectedAgents(visible.filter((a) => a.available));
}, []);
// Handle refresh for agent in config panel
const handleRefreshAgent = useCallback(async () => {
setRefreshingAgent(true);
try {
await refreshAgentDetection();
} finally {
setRefreshingAgent(false);
}
}, [refreshAgentDetection]);
// Handle model refresh
const handleRefreshModels = useCallback(async () => {
if (!selectedAgent) return;
setLoadingModels(true);
try {
const models = await window.maestro.agents.getModels(selectedAgent, true);
setAvailableModels(models);
} catch (err) {
console.error('Failed to refresh models:', err);
} finally {
setLoadingModels(false);
}
}, [selectedAgent]);
// Handle agent selection change
const handleAgentChange = useCallback(
(agentId: string) => {
setSelectedAgent(agentId);
// Reset customizations when changing agent
setCustomPath('');
setCustomArgs('');
setCustomEnvVars({});
// If config is expanded, reload config for new agent
if (isConfigExpanded) {
loadAgentConfig(agentId);
}
},
[isConfigExpanded, loadAgentConfig]
);
if (!isOpen || !groupChat) return null;
// Filter AGENT_TILES to only show supported + detected agents
const availableTiles = AGENT_TILES.filter((tile) => {
if (!tile.supported) return false;
return detectedAgents.some((a: AgentConfig) => a.id === tile.id);
});
// Get selected agent info
const selectedAgentConfig = detectedAgents.find((a) => a.id === selectedAgent);
const selectedTile = AGENT_TILES.find((t) => t.id === selectedAgent);
// Check if there's any customization set
const hasCustomization = customPath || customArgs || Object.keys(customEnvVars).length > 0;
return (
<Modal
theme={theme}
title="Edit Group Chat"
priority={MODAL_PRIORITIES.EDIT_GROUP_CHAT}
onClose={onClose}
initialFocusRef={nameInputRef}
width={600}
footer={
<ModalFooter
theme={theme}
onCancel={onClose}
onConfirm={handleSave}
confirmLabel="Save"
confirmDisabled={!canSave}
/>
}
>
<div>
{/* Name Input */}
<div className="mb-6">
<FormInput
ref={nameInputRef}
theme={theme}
label="Chat Name"
value={name}
onChange={setName}
onSubmit={canSave ? handleSave : undefined}
placeholder="e.g., Auth Feature Implementation"
/>
</div>
{/* Moderator Selection - Dropdown with Customize button */}
<div className="mb-6">
<label
className="block text-xs font-bold opacity-70 uppercase mb-2"
style={{ color: theme.colors.textMain }}
>
Moderator Agent
</label>
{isDetecting ? (
<div className="flex items-center gap-2 py-2">
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: theme.colors.accent, borderTopColor: 'transparent' }}
/>
<span className="text-sm" style={{ color: theme.colors.textDim }}>
Detecting agents...
</span>
</div>
) : availableTiles.length === 0 ? (
<div className="text-sm py-2" style={{ color: theme.colors.textDim }}>
No agents available. Please install Claude Code, OpenCode, Codex, or Factory Droid.
</div>
) : (
<div className="flex items-center gap-2">
{/* Dropdown */}
<div className="relative flex-1">
<select
value={selectedAgent || ''}
onChange={(e) => handleAgentChange(e.target.value)}
className="w-full px-3 py-2 pr-10 rounded-lg border outline-none appearance-none cursor-pointer text-sm"
style={{
backgroundColor: theme.colors.bgMain,
borderColor: theme.colors.border,
color: theme.colors.textMain,
}}
aria-label="Select moderator agent"
>
{availableTiles.map((tile) => {
const isBeta =
tile.id === 'codex' ||
tile.id === 'opencode' ||
tile.id === 'factory-droid';
return (
<option key={tile.id} value={tile.id}>
{tile.name}
{isBeta ? ' (Beta)' : ''}
</option>
);
})}
</select>
<ChevronDown
className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: theme.colors.textDim }}
/>
</div>
{/* Customize button */}
<button
onClick={handleToggleConfig}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border transition-colors hover:bg-white/5"
style={{
borderColor: isConfigExpanded ? theme.colors.accent : theme.colors.border,
color: isConfigExpanded ? theme.colors.accent : theme.colors.textDim,
backgroundColor: isConfigExpanded ? `${theme.colors.accent}10` : 'transparent',
}}
title="Customize moderator settings"
>
<Settings className="w-4 h-4" />
<span className="text-sm">Customize</span>
{hasCustomization && (
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
)}
</button>
</div>
)}
{/* Expandable Configuration Panel */}
{isConfigExpanded && selectedAgentConfig && selectedTile && (
<div
className="mt-3 p-4 rounded-lg border"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
}}
>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium" style={{ color: theme.colors.textDim }}>
{selectedTile.name} Configuration
</span>
{hasCustomization && (
<div className="flex items-center gap-1">
<Check className="w-3 h-3" style={{ color: theme.colors.success }} />
<span className="text-xs" style={{ color: theme.colors.success }}>
Customized
</span>
</div>
)}
</div>
<AgentConfigPanel
theme={theme}
agent={selectedAgentConfig}
customPath={customPath}
onCustomPathChange={setCustomPath}
onCustomPathBlur={() => {
/* Local state only */
}}
onCustomPathClear={() => setCustomPath('')}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
onCustomArgsBlur={() => {
/* Local state only */
}}
onCustomArgsClear={() => setCustomArgs('')}
customEnvVars={customEnvVars}
onEnvVarKeyChange={(oldKey, newKey, value) => {
const newVars = { ...customEnvVars };
delete newVars[oldKey];
newVars[newKey] = value;
setCustomEnvVars(newVars);
}}
onEnvVarValueChange={(key, value) => {
setCustomEnvVars({ ...customEnvVars, [key]: value });
}}
onEnvVarRemove={(key) => {
const newVars = { ...customEnvVars };
delete newVars[key];
setCustomEnvVars(newVars);
}}
onEnvVarAdd={() => {
let newKey = 'NEW_VAR';
let counter = 1;
while (customEnvVars[newKey]) {
newKey = `NEW_VAR_${counter}`;
counter++;
}
setCustomEnvVars({ ...customEnvVars, [newKey]: '' });
}}
onEnvVarsBlur={() => {
/* Local state only */
}}
agentConfig={agentConfig}
onConfigChange={(key, value) => {
const newConfig = { ...agentConfig, [key]: value };
setAgentConfig(newConfig);
agentConfigRef.current = newConfig;
setConfigWasModified(true);
}}
onConfigBlur={async () => {
if (selectedAgent) {
// Use ref to get latest config (state may be stale in async callback)
await window.maestro.agents.setConfig(selectedAgent, agentConfigRef.current);
setConfigWasModified(true);
}
}}
availableModels={availableModels}
loadingModels={loadingModels}
onRefreshModels={handleRefreshModels}
onRefreshAgent={handleRefreshAgent}
refreshingAgent={refreshingAgent}
compact
showBuiltInEnvVars
/>
</div>
)}
</div>
{/* SSH Remote Execution - Top Level */}
{sshRemotes.length > 0 && (
<div className="mb-6">
<SshRemoteSelector
theme={theme}
sshRemotes={sshRemotes}
sshRemoteConfig={sshRemoteConfig}
onSshRemoteConfigChange={setSshRemoteConfig}
/>
</div>
)}
{/* Warning about changing moderator */}
{groupChat && selectedAgent !== groupChat.moderatorAgentId && (
<div
className="text-xs p-3 rounded"
style={{
backgroundColor: `${theme.colors.warning}20`,
color: theme.colors.warning,
border: `1px solid ${theme.colors.warning}40`,
}}
>
<strong>Note:</strong> Changing the moderator agent will restart the moderator process.
Existing conversation history will be preserved.
</div>
)}
</div>
</Modal>
);
}