Merge branch 'fix-opencode'

Merges OpenCode fixes and SSH stdin improvements:
- Use stdin passthrough for all SSH prompts (simplifies escaping)
- Add question:deny to OpenCode permission block for robust tool disabling
- Simplify stdin prompt delivery by appending after exec (no heredoc needed)
This commit is contained in:
Pedram Amini
2026-02-03 15:21:21 -06:00
7 changed files with 604 additions and 555 deletions

View File

@@ -668,10 +668,18 @@ describe('ssh-command-builder', () => {
describe('buildSshCommandWithStdin', () => {
/**
* Tests for the stdin-based SSH execution approach.
*
* This method completely bypasses shell escaping issues by:
* 1. SSH connects and runs /bin/bash on the remote
* 2. The entire script (PATH, cd, env, command) is sent via stdin
* 3. No command-line argument parsing/escaping occurs
* 2. The script (PATH, cd, env, exec command) is sent via stdin
* 3. The prompt is appended after the script and passed through to the exec'd command
* 4. No heredoc, no delimiter collision detection, no prompt escaping needed
*
* How it works:
* - Bash reads the script lines from stdin
* - The `exec` command replaces bash with the target process
* - The target process inherits stdin and reads the remaining content (the prompt)
* - The prompt is NEVER parsed by any shell - it flows through as raw bytes
*/
it('returns ssh command with /bin/bash as remote command', async () => {
@@ -723,31 +731,44 @@ describe('ssh-command-builder', () => {
expect(result.stdinScript).toContain('question');
});
it('includes prompt via stdin heredoc when stdinInput provided', async () => {
it('appends prompt after exec command via stdin passthrough', async () => {
const result = await buildSshCommandWithStdin(baseConfig, {
command: 'opencode',
args: ['run', '--format', 'json'],
stdinInput: 'Write hello world to a file',
});
// The exec line should NOT have heredoc - just the command
const execLine = result.stdinScript
?.split('\n')
.find((line) => line.startsWith('exec '));
expect(execLine).toBe("exec opencode 'run' '--format' 'json' <<'MAESTRO_PROMPT_EOF'");
expect(execLine).toBe("exec opencode 'run' '--format' 'json'");
// The prompt should appear after the exec line (stdin passthrough)
expect(result.stdinScript).toContain('Write hello world to a file');
expect(result.stdinScript).toContain('MAESTRO_PROMPT_EOF');
// Verify the structure: script ends with exec, then prompt follows
const parts = result.stdinScript?.split("exec opencode 'run' '--format' 'json'\n");
expect(parts?.length).toBe(2);
expect(parts?.[1]).toBe('Write hello world to a file');
});
it('handles stdin prompts with special characters without escaping issues', async () => {
it('handles stdin prompts with special characters without escaping', async () => {
const result = await buildSshCommandWithStdin(baseConfig, {
command: 'opencode',
args: ['run'],
stdinInput: "What's the $PATH? Use `echo` and \"quotes\"",
});
// The script should contain the prompt verbatim (no shell interpolation in heredoc)
// The prompt should be verbatim - no escaping needed since it's stdin passthrough
expect(result.stdinScript).toBeDefined();
expect(result.stdinScript).toContain("What's the $PATH? Use `echo` and \"quotes\"");
// Verify the prompt is AFTER the exec line (not in heredoc)
const execLine = result.stdinScript
?.split('\n')
.find((line) => line.startsWith('exec '));
expect(execLine).toBe("exec opencode 'run'");
});
it('handles multi-line stdin prompts', async () => {
@@ -762,18 +783,19 @@ describe('ssh-command-builder', () => {
expect(result.stdinScript).toContain('Line 3');
});
it('uses a unique heredoc delimiter when prompt contains the default token', async () => {
it('handles prompts containing heredoc-like tokens without special treatment', async () => {
// With stdin passthrough, we don't need delimiter collision detection
const result = await buildSshCommandWithStdin(baseConfig, {
command: 'opencode',
args: ['run'],
stdinInput: 'Line with MAESTRO_PROMPT_EOF inside',
stdinInput: 'Line with MAESTRO_PROMPT_EOF inside and <<EOF markers',
});
const execLine = result.stdinScript
?.split('\n')
.find((line) => line.startsWith('exec '));
expect(execLine).toContain("<<'MAESTRO_PROMPT_EOF_0'");
expect(result.stdinScript).toContain('Line with MAESTRO_PROMPT_EOF inside');
// The prompt should be verbatim - no special handling needed
expect(result.stdinScript).toContain('Line with MAESTRO_PROMPT_EOF inside and <<EOF markers');
// No heredoc syntax should be present
expect(result.stdinScript).not.toContain("<<'");
});
it('includes prompt as final argument when stdinInput is not provided', async () => {
@@ -845,5 +867,26 @@ describe('ssh-command-builder', () => {
expect(result.stdinScript).toContain('export REMOTE_VAR=');
expect(result.stdinScript).toContain('export OPTION_VAR=');
});
it('works with Claude Code stream-json format', async () => {
// Claude Code uses --input-format stream-json and expects JSON on stdin
const streamJsonPrompt =
'{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello"}]}}';
const result = await buildSshCommandWithStdin(baseConfig, {
command: 'claude',
args: ['--print', '--verbose', '--output-format', 'stream-json', '--input-format', 'stream-json'],
stdinInput: streamJsonPrompt,
});
// The JSON should be passed through verbatim
expect(result.stdinScript).toContain(streamJsonPrompt);
// Verify exec line doesn't have the prompt
const execLine = result.stdinScript
?.split('\n')
.find((line) => line.startsWith('exec '));
expect(execLine).not.toContain('{"type"');
});
});
});

View File

@@ -7,7 +7,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { NewGroupChatModal } from '../../../renderer/components/NewGroupChatModal';
import { EditGroupChatModal } from '../../../renderer/components/EditGroupChatModal';
import type { Theme, GroupChat, AgentConfig } from '../../../renderer/types';
@@ -167,18 +167,17 @@ describe('Group Chat Modals', () => {
/>
);
// Wait for agent detection
// Wait for agent detection and verify dropdown is rendered
await waitFor(() => {
expect(screen.getByText('Claude Code')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /select moderator/i })).toBeInTheDocument();
});
// Select the agent first (the tile is now a div with role="button")
const agentTile = screen.getByText('Claude Code').closest('[role="button"]');
expect(agentTile).not.toBeNull();
fireEvent.click(agentTile!);
// Verify Claude Code is selected in dropdown
const dropdown = screen.getByRole('combobox', { name: /select moderator/i });
expect(dropdown).toHaveValue('claude-code');
// Click the Customize button to open config panel
const customizeButton = screen.getByText('Customize');
// Click the Customize button to expand config panel
const customizeButton = screen.getByRole('button', { name: /customize/i });
fireEvent.click(customizeButton);
// Wait for config panel to appear and verify MAESTRO_SESSION_RESUMED is displayed
@@ -189,6 +188,39 @@ describe('Group Chat Modals', () => {
// Also verify the value hint is shown
expect(screen.getByText('1 (when resuming)')).toBeInTheDocument();
});
it('should show all available agents in dropdown', async () => {
// Setup multiple agents
vi.mocked(window.maestro.agents.detect).mockResolvedValue([
createMockAgent({ id: 'claude-code', name: 'Claude Code' }),
createMockAgent({ id: 'codex', name: 'Codex' }),
createMockAgent({ id: 'opencode', name: 'OpenCode' }),
createMockAgent({ id: 'factory-droid', name: 'Factory Droid' }),
]);
const onCreate = vi.fn();
const onClose = vi.fn();
render(
<NewGroupChatModal
theme={createMockTheme()}
isOpen={true}
onClose={onClose}
onCreate={onCreate}
/>
);
// Wait for dropdown to be rendered
await waitFor(() => {
expect(screen.getByRole('combobox', { name: /select moderator/i })).toBeInTheDocument();
});
// Verify all agents appear as options
expect(screen.getByRole('option', { name: /Claude Code/i })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Codex.*Beta/i })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /OpenCode.*Beta/i })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Factory Droid.*Beta/i })).toBeInTheDocument();
});
});
describe('EditGroupChatModal', () => {
@@ -207,13 +239,17 @@ describe('Group Chat Modals', () => {
/>
);
// Wait for agent detection
// Wait for dropdown to be rendered
await waitFor(() => {
expect(screen.getByText('Claude Code')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /select moderator/i })).toBeInTheDocument();
});
// Click the Customize button to open config panel
const customizeButton = screen.getByText('Customize');
// Verify Claude Code is pre-selected
const dropdown = screen.getByRole('combobox', { name: /select moderator/i });
expect(dropdown).toHaveValue('claude-code');
// Click the Customize button to expand config panel
const customizeButton = screen.getByRole('button', { name: /customize/i });
fireEvent.click(customizeButton);
// Wait for config panel to appear and verify MAESTRO_SESSION_RESUMED is displayed
@@ -224,5 +260,41 @@ describe('Group Chat Modals', () => {
// Also verify the value hint is shown
expect(screen.getByText('1 (when resuming)')).toBeInTheDocument();
});
it('should show warning when changing moderator agent', async () => {
// Setup multiple agents
vi.mocked(window.maestro.agents.detect).mockResolvedValue([
createMockAgent({ id: 'claude-code', name: 'Claude Code' }),
createMockAgent({ id: 'codex', name: 'Codex' }),
]);
const onSave = vi.fn();
const onClose = vi.fn();
const groupChat = createMockGroupChat({ moderatorAgentId: 'claude-code' });
render(
<EditGroupChatModal
theme={createMockTheme()}
isOpen={true}
groupChat={groupChat}
onClose={onClose}
onSave={onSave}
/>
);
// Wait for dropdown
await waitFor(() => {
expect(screen.getByRole('combobox', { name: /select moderator/i })).toBeInTheDocument();
});
// Change to different agent
const dropdown = screen.getByRole('combobox', { name: /select moderator/i });
fireEvent.change(dropdown, { target: { value: 'codex' } });
// Verify warning message appears
await waitFor(() => {
expect(screen.getByText(/changing the moderator agent/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -200,11 +200,14 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode run -f /path/to/image.png -- "prompt"
noPromptSeparator: true, // OpenCode doesn't need '--' before prompt - yargs handles positional args
// Default env vars: enable YOLO mode (allow all permissions including external_directory)
// Also disable the question tool - it waits for stdin input which hangs batch mode
// Disable the question tool via both methods:
// - "question": "deny" in permission block (per OpenCode GitHub issue workaround)
// - "question": false in tools block (original approach)
// The question tool waits for stdin input which hangs batch mode
// Users can override by setting customEnvVars in agent config
defaultEnvVars: {
OPENCODE_CONFIG_CONTENT:
'{"permission":{"*":"allow","external_directory":"allow"},"tools":{"question":false}}',
'{"permission":{"*":"allow","external_directory":"allow","question":"deny"},"tools":{"question":false}}',
},
// Agent-specific configuration options shown in UI
configOptions: [

View File

@@ -345,17 +345,22 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// The script contains PATH setup, cd, env vars, and the actual command
// This eliminates all shell escaping issues
//
// IMPORTANT: OpenCode prompts must be passed via stdin to avoid CLI length limits.
// Prompts can be huge and contain arbitrary characters; do NOT pass them as argv.
const shouldSendPromptViaStdin = config.toolType === 'opencode' && !!config.prompt;
const promptForArgs = shouldSendPromptViaStdin ? undefined : config.prompt;
const stdinInput = shouldSendPromptViaStdin ? config.prompt : undefined;
// IMPORTANT: ALL agent prompts are passed via stdin passthrough for SSH.
// Benefits:
// - Avoids CLI argument length limits (128KB-2MB depending on OS)
// - No shell escaping needed - prompt is never parsed by any shell
// - Works with any prompt content (quotes, newlines, special chars)
// - Simpler code - no heredoc or delimiter collision detection
//
// How it works: bash reads the script, `exec` replaces bash with the agent,
// and the agent reads the remaining stdin (the prompt) directly.
const stdinInput = config.prompt;
const sshCommand = await buildSshCommandWithStdin(sshResult.config, {
command: remoteCommand,
args: finalArgs,
cwd: config.cwd,
env: effectiveCustomEnvVars,
prompt: promptForArgs,
// prompt is not passed as CLI arg - it goes via stdinInput
stdinInput,
});
@@ -366,7 +371,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// For SSH, env vars are passed in the stdin script, not locally
customEnvVarsToPass = undefined;
logger.info(`SSH command built with stdin script`, LOG_CONTEXT, {
logger.info(`SSH command built with stdin passthrough`, LOG_CONTEXT, {
sessionId: config.sessionId,
toolType: config.toolType,
sshBinary: sshCommand.command,
@@ -374,7 +379,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
remoteCommand,
remoteCwd: config.cwd,
promptLength: config.prompt?.length,
scriptLength: sshCommand.stdinScript?.length,
stdinScriptLength: sshCommand.stdinScript?.length,
});
}
}

View File

@@ -140,17 +140,24 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string {
* This approach completely bypasses shell escaping issues by:
* 1. SSH connects and runs `/bin/bash` on the remote
* 2. The script (with PATH setup, cd, env vars, command) is sent via stdin
* 3. No shell parsing of command-line arguments occurs
* 3. The prompt (if any) is appended after the script, passed through to the exec'd command
*
* This is the preferred method for SSH remote execution as it:
* - Handles any prompt content (special chars, newlines, quotes, etc.)
* - Avoids command-line length limits
* - Works regardless of the remote user's login shell (bash, zsh, fish, etc.)
* - Eliminates the escaping nightmare of nested shell contexts
* - No heredoc or delimiter collision detection needed
*
* How stdin passthrough works:
* - Bash reads and executes the script lines
* - The `exec` command replaces bash with the target process
* - Any remaining stdin (the prompt) is inherited by the exec'd command
* - The prompt is NEVER parsed by any shell - it flows through as raw bytes
*
* @param config SSH remote configuration
* @param remoteOptions Options for the remote command
* @returns SSH command/args plus the script to send via stdin
* @returns SSH command/args plus the script+prompt to send via stdin
*
* @example
* const result = await buildSshCommandWithStdin(config, {
@@ -162,7 +169,7 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string {
* });
* // result.command = 'ssh'
* // result.args = ['-o', 'BatchMode=yes', ..., 'user@host', '/bin/bash']
* // result.stdinScript = '#!/bin/bash\nexport PATH=...\ncd /home/user/project\nOPENCODE_CONFIG_CONTENT=...\nexec opencode run --format json <<'MAESTRO_PROMPT_EOF'\nWrite hello world to a file\nMAESTRO_PROMPT_EOF\n'
* // result.stdinScript = 'export PATH=...\ncd /home/user/project\nexport OPENCODE_CONFIG_CONTENT=...\nexec opencode run --format json\nWrite hello world to a file'
*/
export async function buildSshCommandWithStdin(
config: SshRemoteConfig,
@@ -232,30 +239,27 @@ export async function buildSshCommandWithStdin(
// For the script, we use simple quoting since we're not going through shell parsing layers
const cmdParts = [remoteOptions.command, ...remoteOptions.args.map((arg) => shellEscape(arg))];
// Add prompt as final argument if provided and not sending via stdin
// Add prompt as final argument if provided and not sending via stdin passthrough
const hasStdinInput = remoteOptions.stdinInput !== undefined;
if (remoteOptions.prompt && !hasStdinInput) {
cmdParts.push(shellEscape(remoteOptions.prompt));
}
// Use exec to replace the shell with the command (cleaner process tree)
if (hasStdinInput) {
// IMPORTANT: Prompts must be passed via stdin to avoid CLI length limits.
// Build a safe heredoc delimiter that won't collide with the prompt content.
const delimiterBase = 'MAESTRO_PROMPT_EOF';
let delimiter = delimiterBase;
let counter = 0;
while (remoteOptions.stdinInput?.includes(delimiter)) {
delimiter = `${delimiterBase}_${counter++}`;
}
scriptLines.push(`exec ${cmdParts.join(' ')} <<'${delimiter}'`);
scriptLines.push(remoteOptions.stdinInput ?? '');
scriptLines.push(delimiter);
} else {
scriptLines.push(`exec ${cmdParts.join(' ')}`);
}
// When stdinInput is provided, the prompt will be appended after the script
// and passed through to the exec'd command via stdin inheritance
scriptLines.push(`exec ${cmdParts.join(' ')}`);
const stdinScript = scriptLines.join('\n') + '\n';
// Build the final stdin content: script + optional prompt passthrough
// The script ends with exec, which replaces bash with the target command
// Any content after the script (the prompt) is read by the exec'd command from stdin
let stdinScript = scriptLines.join('\n') + '\n';
if (hasStdinInput && remoteOptions.stdinInput) {
// Append the prompt after the script - it will be passed through to the exec'd command
// No escaping needed - the prompt is never parsed by any shell
stdinScript += remoteOptions.stdinInput;
}
logger.info('SSH command built with stdin script', '[ssh-command-builder]', {
host: config.host,
@@ -264,7 +268,9 @@ export async function buildSshCommandWithStdin(
sshPath,
sshArgsCount: args.length,
scriptLineCount: scriptLines.length,
scriptLength: stdinScript.length,
stdinLength: stdinScript.length,
hasStdinInput,
stdinInputLength: remoteOptions.stdinInput?.length,
// Show first part of script for debugging (truncate if long)
scriptPreview: stdinScript.length > 500 ? stdinScript.substring(0, 500) + '...' : stdinScript,
});

View File

@@ -3,19 +3,19 @@
*
* Modal for editing an existing Group Chat. Allows user to:
* - Change the name of the group chat
* - Change the moderator agent
* - Customize moderator settings (CLI args, path, ENV vars)
* - 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 { Check, X, Settings, ArrowLeft } from 'lucide-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 { AgentLogo, AGENT_TILES } from './Wizard/screens/AgentSelectionScreen';
import { AGENT_TILES } from './Wizard/screens/AgentSelectionScreen';
import { AgentConfigPanel } from './shared/AgentConfigPanel';
import { SshRemoteSelector } from './shared/SshRemoteSelector';
@@ -44,9 +44,8 @@ export function EditGroupChatModal({
const [detectedAgents, setDetectedAgents] = useState<AgentConfig[]>([]);
const [isDetecting, setIsDetecting] = useState(true);
// View mode for switching between grid and config
const [viewMode, setViewMode] = useState<'grid' | 'config'>('grid');
const [isTransitioning, setIsTransitioning] = useState(false);
// Configuration panel state - expandable below dropdown
const [isConfigExpanded, setIsConfigExpanded] = useState(false);
// Custom moderator configuration state
const [customPath, setCustomPath] = useState('');
@@ -81,8 +80,7 @@ export function EditGroupChatModal({
setCustomPath(groupChat.moderatorConfig?.customPath || '');
setCustomArgs(groupChat.moderatorConfig?.customArgs || '');
setCustomEnvVars(groupChat.moderatorConfig?.customEnvVars || {});
setViewMode('grid');
setIsTransitioning(false);
setIsConfigExpanded(false);
setAgentConfig({});
setAvailableModels([]);
setLoadingModels(false);
@@ -94,8 +92,7 @@ export function EditGroupChatModal({
setName('');
setSelectedAgent(null);
setIsDetecting(true);
setViewMode('grid');
setIsTransitioning(false);
setIsConfigExpanded(false);
setCustomPath('');
setCustomArgs('');
setCustomEnvVars({});
@@ -143,10 +140,41 @@ export function EditGroupChatModal({
// Focus name input when agents detected
useEffect(() => {
if (!isDetecting && isOpen && viewMode === 'grid') {
if (!isDetecting && isOpen) {
nameInputRef.current?.focus();
}
}, [isDetecting, isOpen, viewMode]);
}, [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 => {
@@ -194,45 +222,9 @@ export function EditGroupChatModal({
const canSave = name.trim().length > 0 && selectedAgent !== null && hasChanges();
// Open configuration panel for the selected agent
const handleOpenConfig = useCallback(async () => {
if (!selectedAgent) return;
// Load agent config
const config = await window.maestro.agents.getConfig(selectedAgent);
setAgentConfig(config || {});
agentConfigRef.current = config || {};
// Load models if agent supports it
const agent = detectedAgents.find((a) => a.id === selectedAgent);
// Note: capabilities is added by agent-detector but not in the TypeScript type
if ((agent as any)?.capabilities?.supportsModelSelection) {
setLoadingModels(true);
try {
const models = await window.maestro.agents.getModels(selectedAgent);
setAvailableModels(models);
} catch (err) {
console.error('Failed to load models:', err);
} finally {
setLoadingModels(false);
}
}
// Transition to config view
setIsTransitioning(true);
setTimeout(() => {
setViewMode('config');
setIsTransitioning(false);
}, 150);
}, [selectedAgent, detectedAgents]);
// Close configuration panel
const handleCloseConfig = useCallback(() => {
setIsTransitioning(true);
setTimeout(() => {
setViewMode('grid');
setIsTransitioning(false);
}, 150);
// Toggle configuration panel
const handleToggleConfig = useCallback(() => {
setIsConfigExpanded((prev) => !prev);
}, []);
// Refresh agent detection after config changes
@@ -266,6 +258,22 @@ export function EditGroupChatModal({
}
}, [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
@@ -281,127 +289,6 @@ export function EditGroupChatModal({
// Check if there's any customization set
const hasCustomization = customPath || customArgs || Object.keys(customEnvVars).length > 0;
// Render configuration view
if (viewMode === 'config' && selectedAgentConfig && selectedTile) {
return (
<Modal
theme={theme}
title={`Configure ${selectedTile.name}`}
priority={MODAL_PRIORITIES.EDIT_GROUP_CHAT}
onClose={onClose}
width={600}
customHeader={
<div
className="p-4 border-b flex items-center justify-between shrink-0"
style={{ borderColor: theme.colors.border }}
>
<div className="flex items-center gap-3">
<button
onClick={handleCloseConfig}
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
>
<ArrowLeft className="w-4 h-4" />
Back
</button>
<h2 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
Configure {selectedTile.name}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
aria-label="Close modal"
>
<X className="w-4 h-4" />
</button>
</div>
}
footer={
<ModalFooter
theme={theme}
onCancel={handleCloseConfig}
cancelLabel="Back"
onConfirm={handleCloseConfig}
confirmLabel="Done"
/>
}
>
<div
className={`transition-opacity duration-150 ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}
>
<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>
</Modal>
);
}
// Render grid view
return (
<Modal
theme={theme}
@@ -420,9 +307,7 @@ export function EditGroupChatModal({
/>
}
>
<div
className={`transition-opacity duration-150 ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}
>
<div>
{/* Name Input */}
<div className="mb-6">
<FormInput
@@ -436,105 +321,179 @@ export function EditGroupChatModal({
/>
</div>
{/* Agent Selection */}
<div className="mb-4">
{/* Moderator Selection - Dropdown with Customize button */}
<div className="mb-6">
<label
className="block text-sm font-medium mb-3"
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 justify-center py-8">
<div className="flex items-center gap-2 py-2">
<div
className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin"
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-center py-8 text-sm" style={{ color: theme.colors.textDim }}>
No agents available. Please install Claude Code, OpenCode, or Codex.
<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="grid grid-cols-3 gap-3">
{availableTiles.map((tile) => {
const isSelected = selectedAgent === tile.id;
<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>
return (
<div
key={tile.id}
role="button"
tabIndex={0}
onClick={() => setSelectedAgent(tile.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedAgent(tile.id);
}
}}
className="relative flex flex-col items-center p-4 pb-10 rounded-lg border-2 transition-all outline-none cursor-pointer"
style={{
backgroundColor: isSelected ? `${tile.brandColor}15` : theme.colors.bgMain,
borderColor: isSelected ? tile.brandColor : theme.colors.border,
}}
>
{isSelected && (
<div
className="absolute top-2 right-2 w-5 h-5 rounded-full flex items-center justify-center"
style={{ backgroundColor: tile.brandColor }}
>
<Check className="w-3 h-3 text-white" />
</div>
)}
<AgentLogo
agentId={tile.id}
supported={true}
detected={true}
brandColor={tile.brandColor}
theme={theme}
/>
<span
className="mt-2 text-sm font-medium"
style={{ color: theme.colors.textMain }}
>
{tile.name}
{/* 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>
{/* Customize button */}
<button
onClick={(e) => {
e.stopPropagation();
setSelectedAgent(tile.id);
// Small delay to update selection before opening config
setTimeout(() => handleOpenConfig(), 50);
}}
className="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-1 px-2 py-1 rounded text-[10px] hover:bg-white/10 transition-colors"
style={{
color:
isSelected && hasCustomization ? tile.brandColor : theme.colors.textDim,
}}
title="Customize moderator settings"
>
<Settings className="w-3 h-3" />
Customize
{isSelected && hasCustomization && (
<span
className="w-1.5 h-1.5 rounded-full ml-0.5"
style={{ backgroundColor: tile.brandColor }}
/>
)}
</button>
</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-4">
<div className="mb-6">
<SshRemoteSelector
theme={theme}
sshRemotes={sshRemotes}

View File

@@ -2,20 +2,20 @@
* NewGroupChatModal.tsx
*
* Modal for creating a new Group Chat. Allows user to:
* - Select a moderator agent from available agents
* - Customize moderator settings (CLI args, path, ENV vars)
* - Select a moderator agent from a dropdown of available agents
* - Customize moderator settings (CLI args, path, ENV vars) via expandable panel
* - Enter a name for the group chat
*
* Only shows agents that are both supported by Maestro and detected on the system.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { Check, X, Settings, ArrowLeft } from 'lucide-react';
import { X, Settings, ChevronDown, Check } from 'lucide-react';
import type { Theme, AgentConfig, ModeratorConfig } from '../types';
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { Modal, ModalFooter, FormInput } from './ui';
import { AgentLogo, AGENT_TILES } from './Wizard/screens/AgentSelectionScreen';
import { AGENT_TILES } from './Wizard/screens/AgentSelectionScreen';
import { AgentConfigPanel } from './shared/AgentConfigPanel';
import { SshRemoteSelector } from './shared/SshRemoteSelector';
@@ -37,9 +37,8 @@ export function NewGroupChatModal({
const [detectedAgents, setDetectedAgents] = useState<AgentConfig[]>([]);
const [isDetecting, setIsDetecting] = useState(true);
// View mode for switching between grid and config
const [viewMode, setViewMode] = useState<'grid' | 'config'>('grid');
const [isTransitioning, setIsTransitioning] = useState(false);
// Configuration panel state - expandable below dropdown
const [isConfigExpanded, setIsConfigExpanded] = useState(false);
// Custom moderator configuration state
const [customPath, setCustomPath] = useState('');
@@ -65,8 +64,7 @@ export function NewGroupChatModal({
setName('');
setSelectedAgent(null);
setIsDetecting(true);
setViewMode('grid');
setIsTransitioning(false);
setIsConfigExpanded(false);
setCustomPath('');
setCustomArgs('');
setCustomEnvVars({});
@@ -127,10 +125,41 @@ export function NewGroupChatModal({
// Focus name input when agents detected
useEffect(() => {
if (!isDetecting && isOpen && viewMode === 'grid') {
if (!isDetecting && isOpen) {
nameInputRef.current?.focus();
}
}, [isDetecting, isOpen, viewMode]);
}, [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 => {
@@ -155,44 +184,9 @@ export function NewGroupChatModal({
const canCreate = name.trim().length > 0 && selectedAgent !== null;
// Open configuration panel for the selected agent
const handleOpenConfig = useCallback(async () => {
if (!selectedAgent) return;
// Load agent config
const config = await window.maestro.agents.getConfig(selectedAgent);
setAgentConfig(config || {});
agentConfigRef.current = config || {};
// Load models if agent supports it
const agent = detectedAgents.find((a) => a.id === selectedAgent);
if (agent?.capabilities?.supportsModelSelection) {
setLoadingModels(true);
try {
const models = await window.maestro.agents.getModels(selectedAgent);
setAvailableModels(models);
} catch (err) {
console.error('Failed to load models:', err);
} finally {
setLoadingModels(false);
}
}
// Transition to config view
setIsTransitioning(true);
setTimeout(() => {
setViewMode('config');
setIsTransitioning(false);
}, 150);
}, [selectedAgent, detectedAgents]);
// Close configuration panel
const handleCloseConfig = useCallback(() => {
setIsTransitioning(true);
setTimeout(() => {
setViewMode('grid');
setIsTransitioning(false);
}, 150);
// Toggle configuration panel
const handleToggleConfig = useCallback(() => {
setIsConfigExpanded((prev) => !prev);
}, []);
// Refresh agent detection after config changes
@@ -226,6 +220,22 @@ export function NewGroupChatModal({
}
}, [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) return null;
// Filter AGENT_TILES to only show supported + detected agents
@@ -241,125 +251,6 @@ export function NewGroupChatModal({
// Check if there's any customization set
const hasCustomization = customPath || customArgs || Object.keys(customEnvVars).length > 0;
// Render configuration view
if (viewMode === 'config' && selectedAgentConfig && selectedTile) {
return (
<Modal
theme={theme}
title={`Configure ${selectedTile.name}`}
priority={MODAL_PRIORITIES.NEW_GROUP_CHAT}
onClose={onClose}
width={600}
customHeader={
<div
className="p-4 border-b flex items-center justify-between shrink-0"
style={{ borderColor: theme.colors.border }}
>
<div className="flex items-center gap-3">
<button
onClick={handleCloseConfig}
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
>
<ArrowLeft className="w-4 h-4" />
Back
</button>
<h2 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
Configure {selectedTile.name}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
aria-label="Close modal"
>
<X className="w-4 h-4" />
</button>
</div>
}
footer={
<ModalFooter
theme={theme}
onCancel={handleCloseConfig}
cancelLabel="Back"
onConfirm={handleCloseConfig}
confirmLabel="Done"
/>
}
>
<div
className={`transition-opacity duration-150 ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}
>
<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;
}}
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);
}
}}
availableModels={availableModels}
loadingModels={loadingModels}
onRefreshModels={handleRefreshModels}
onRefreshAgent={handleRefreshAgent}
refreshingAgent={refreshingAgent}
compact
showBuiltInEnvVars
/>
</div>
</Modal>
);
}
// Render grid view
return (
<Modal
theme={theme}
@@ -409,9 +300,7 @@ export function NewGroupChatModal({
/>
}
>
<div
className={`transition-opacity duration-150 ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}
>
<div>
{/* Description */}
<div className="mb-6 text-sm leading-relaxed" style={{ color: theme.colors.textDim }}>
A Group Chat lets you collaborate with multiple AI agents in a single conversation. The{' '}
@@ -422,98 +311,170 @@ export function NewGroupChatModal({
Claude appears to be the best performing moderator.
</div>
{/* Agent Selection */}
{/* Moderator Selection - Dropdown with Customize button */}
<div className="mb-6">
<label
className="block text-sm font-medium mb-3"
className="block text-xs font-bold opacity-70 uppercase mb-2"
style={{ color: theme.colors.textMain }}
>
Select Moderator
</label>
{isDetecting ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-2 py-2">
<div
className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin"
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-center py-8 text-sm" style={{ color: theme.colors.textDim }}>
No agents available. Please install Claude Code, OpenCode, or Codex.
<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="grid grid-cols-3 gap-3">
{availableTiles.map((tile) => {
const isSelected = selectedAgent === tile.id;
<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>
return (
<div
key={tile.id}
role="button"
tabIndex={0}
onClick={() => setSelectedAgent(tile.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedAgent(tile.id);
}
}}
className="relative flex flex-col items-center p-4 pb-10 rounded-lg border-2 transition-all outline-none cursor-pointer"
style={{
backgroundColor: isSelected ? `${tile.brandColor}15` : theme.colors.bgMain,
borderColor: isSelected ? tile.brandColor : theme.colors.border,
}}
>
{isSelected && (
<div
className="absolute top-2 right-2 w-5 h-5 rounded-full flex items-center justify-center"
style={{ backgroundColor: tile.brandColor }}
>
<Check className="w-3 h-3 text-white" />
</div>
)}
<AgentLogo
agentId={tile.id}
supported={true}
detected={true}
brandColor={tile.brandColor}
theme={theme}
/>
<span
className="mt-2 text-sm font-medium"
style={{ color: theme.colors.textMain }}
>
{tile.name}
{/* 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>
{/* Customize button */}
<button
onClick={(e) => {
e.stopPropagation();
setSelectedAgent(tile.id);
// Small delay to update selection before opening config
setTimeout(() => handleOpenConfig(), 50);
}}
className="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-1 px-2 py-1 rounded text-[10px] hover:bg-white/10 transition-colors"
style={{
color:
isSelected && hasCustomization ? tile.brandColor : theme.colors.textDim,
}}
title="Customize moderator settings"
>
<Settings className="w-3 h-3" />
Customize
{isSelected && hasCustomization && (
<span
className="w-1.5 h-1.5 rounded-full ml-0.5"
style={{ backgroundColor: tile.brandColor }}
/>
)}
</button>
</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;
}}
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);
}
}}
availableModels={availableModels}
loadingModels={loadingModels}
onRefreshModels={handleRefreshModels}
onRefreshAgent={handleRefreshAgent}
refreshingAgent={refreshingAgent}
compact
showBuiltInEnvVars
/>
</div>
)}
</div>