mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Merge branch 'main' into code-refactor
This commit is contained in:
@@ -5244,4 +5244,198 @@ This is a Symphony task document.
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Manual Credit Tests (symphony:manualCredit)
|
||||
// ============================================================================
|
||||
|
||||
describe('symphony:manualCredit', () => {
|
||||
const getManualCreditHandler = () => handlers.get('symphony:manualCredit');
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset state to empty
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should reject missing required fields', async () => {
|
||||
const handler = getManualCreditHandler();
|
||||
const result = await handler!({} as any, {});
|
||||
|
||||
// Handler returns { error: '...' }, wrapper adds success: true
|
||||
// So validation errors show as { success: true, error: '...' }
|
||||
expect(result.error).toContain('Missing required fields');
|
||||
expect(result.contributionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject missing repoSlug', async () => {
|
||||
const handler = getManualCreditHandler();
|
||||
const result = await handler!({} as any, {
|
||||
repoName: 'Test Repo',
|
||||
issueNumber: 123,
|
||||
prNumber: 456,
|
||||
prUrl: 'https://github.com/owner/repo/pull/456',
|
||||
});
|
||||
|
||||
expect(result.error).toContain('Missing required fields');
|
||||
expect(result.contributionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject duplicate PR credit', async () => {
|
||||
// Setup existing state with a contribution
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
active: [],
|
||||
history: [
|
||||
{
|
||||
id: 'existing_contrib',
|
||||
repoSlug: 'owner/repo',
|
||||
prNumber: 456,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
totalContributions: 1,
|
||||
totalMerged: 0,
|
||||
totalIssuesResolved: 0,
|
||||
totalDocumentsProcessed: 0,
|
||||
totalTasksCompleted: 0,
|
||||
totalTokensUsed: 0,
|
||||
totalTimeSpent: 0,
|
||||
estimatedCostDonated: 0,
|
||||
repositoriesContributed: ['owner/repo'],
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handler = getManualCreditHandler();
|
||||
const result = await handler!({} as any, {
|
||||
repoSlug: 'owner/repo',
|
||||
repoName: 'Test Repo',
|
||||
issueNumber: 123,
|
||||
issueTitle: 'Test Issue',
|
||||
prNumber: 456,
|
||||
prUrl: 'https://github.com/owner/repo/pull/456',
|
||||
});
|
||||
|
||||
expect(result.error).toContain('already credited');
|
||||
expect(result.contributionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful credit', () => {
|
||||
it('should create a completed contribution with minimal params', async () => {
|
||||
const handler = getManualCreditHandler();
|
||||
const result = await handler!({} as any, {
|
||||
repoSlug: 'owner/repo',
|
||||
repoName: 'Test Repo',
|
||||
issueNumber: 123,
|
||||
issueTitle: 'Test Issue',
|
||||
prNumber: 456,
|
||||
prUrl: 'https://github.com/owner/repo/pull/456',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.contributionId).toMatch(/^manual_123_/);
|
||||
|
||||
// Verify state was written
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenState = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenState.history).toHaveLength(1);
|
||||
expect(writtenState.history[0].repoSlug).toBe('owner/repo');
|
||||
expect(writtenState.history[0].prNumber).toBe(456);
|
||||
expect(writtenState.stats.totalContributions).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle wasMerged flag correctly', async () => {
|
||||
const handler = getManualCreditHandler();
|
||||
const result = await handler!({} as any, {
|
||||
repoSlug: 'owner/repo',
|
||||
repoName: 'Test Repo',
|
||||
issueNumber: 123,
|
||||
issueTitle: 'Test Issue',
|
||||
prNumber: 456,
|
||||
prUrl: 'https://github.com/owner/repo/pull/456',
|
||||
wasMerged: true,
|
||||
mergedAt: '2026-02-02T23:31:31Z',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenState = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenState.history[0].wasMerged).toBe(true);
|
||||
expect(writtenState.history[0].mergedAt).toBe('2026-02-02T23:31:31Z');
|
||||
expect(writtenState.stats.totalMerged).toBe(1);
|
||||
expect(writtenState.stats.totalIssuesResolved).toBe(1);
|
||||
});
|
||||
|
||||
it('should add repo to repositoriesContributed if not already present', async () => {
|
||||
const handler = getManualCreditHandler();
|
||||
await handler!({} as any, {
|
||||
repoSlug: 'new-owner/new-repo',
|
||||
repoName: 'New Repo',
|
||||
issueNumber: 1,
|
||||
issueTitle: 'Issue 1',
|
||||
prNumber: 1,
|
||||
prUrl: 'https://github.com/new-owner/new-repo/pull/1',
|
||||
});
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenState = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenState.stats.repositoriesContributed).toContain('new-owner/new-repo');
|
||||
});
|
||||
|
||||
it('should accept custom token usage', async () => {
|
||||
const handler = getManualCreditHandler();
|
||||
await handler!({} as any, {
|
||||
repoSlug: 'owner/repo',
|
||||
repoName: 'Test Repo',
|
||||
issueNumber: 123,
|
||||
issueTitle: 'Test Issue',
|
||||
prNumber: 456,
|
||||
prUrl: 'https://github.com/owner/repo/pull/456',
|
||||
tokenUsage: {
|
||||
inputTokens: 50000,
|
||||
outputTokens: 25000,
|
||||
totalCost: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenState = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenState.history[0].tokenUsage.inputTokens).toBe(50000);
|
||||
expect(writtenState.history[0].tokenUsage.outputTokens).toBe(25000);
|
||||
expect(writtenState.history[0].tokenUsage.totalCost).toBe(1.5);
|
||||
expect(writtenState.stats.totalTokensUsed).toBe(75000);
|
||||
expect(writtenState.stats.estimatedCostDonated).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should set firstContributionAt on first credit', async () => {
|
||||
const handler = getManualCreditHandler();
|
||||
await handler!({} as any, {
|
||||
repoSlug: 'owner/repo',
|
||||
repoName: 'Test Repo',
|
||||
issueNumber: 123,
|
||||
issueTitle: 'Test Issue',
|
||||
prNumber: 456,
|
||||
prUrl: 'https://github.com/owner/repo/pull/456',
|
||||
});
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenState = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenState.stats.firstContributionAt).toBeDefined();
|
||||
expect(writtenState.stats.lastContributionAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
84
src/__tests__/main/utils/needsWindowsShell.test.ts
Normal file
84
src/__tests__/main/utils/needsWindowsShell.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for needsWindowsShell function in src/main/utils/execFile.ts
|
||||
*
|
||||
* This function determines whether a command needs shell execution on Windows.
|
||||
* Separated into its own test file to avoid module mocking conflicts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { needsWindowsShell } from '../../../main/utils/execFile';
|
||||
|
||||
describe('needsWindowsShell', () => {
|
||||
describe('batch files', () => {
|
||||
it('should return true for .cmd files', () => {
|
||||
expect(needsWindowsShell('setup.cmd')).toBe(true);
|
||||
expect(needsWindowsShell('C:\\path\\to\\setup.CMD')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for .bat files', () => {
|
||||
expect(needsWindowsShell('install.bat')).toBe(true);
|
||||
expect(needsWindowsShell('C:\\path\\to\\INSTALL.BAT')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executables', () => {
|
||||
it('should return false for .exe files', () => {
|
||||
expect(needsWindowsShell('program.exe')).toBe(false);
|
||||
expect(needsWindowsShell('C:\\path\\to\\program.EXE')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for .com files', () => {
|
||||
expect(needsWindowsShell('command.com')).toBe(false);
|
||||
expect(needsWindowsShell('C:\\path\\to\\COMMAND.COM')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('known commands with .exe variants', () => {
|
||||
it('should return false for git', () => {
|
||||
expect(needsWindowsShell('git')).toBe(false);
|
||||
expect(needsWindowsShell('GIT')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for git with full path', () => {
|
||||
expect(needsWindowsShell('C:\\Program Files\\Git\\bin\\git')).toBe(false);
|
||||
expect(needsWindowsShell('/usr/bin/git')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for node', () => {
|
||||
expect(needsWindowsShell('node')).toBe(false);
|
||||
expect(needsWindowsShell('C:\\nodejs\\node')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for npm/npx/yarn/pnpm', () => {
|
||||
expect(needsWindowsShell('npm')).toBe(false);
|
||||
expect(needsWindowsShell('npx')).toBe(false);
|
||||
expect(needsWindowsShell('yarn')).toBe(false);
|
||||
expect(needsWindowsShell('pnpm')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for python/python3', () => {
|
||||
expect(needsWindowsShell('python')).toBe(false);
|
||||
expect(needsWindowsShell('python3')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for pip/pip3', () => {
|
||||
expect(needsWindowsShell('pip')).toBe(false);
|
||||
expect(needsWindowsShell('pip3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown commands without extension', () => {
|
||||
it('should return true for unknown commands (need PATHEXT resolution)', () => {
|
||||
expect(needsWindowsShell('mycustomtool')).toBe(true);
|
||||
expect(needsWindowsShell('somecommand')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commands with other extensions', () => {
|
||||
it('should return false for commands with unknown extensions', () => {
|
||||
// These have an extension, so no PATHEXT resolution needed
|
||||
expect(needsWindowsShell('script.ps1')).toBe(false);
|
||||
expect(needsWindowsShell('tool.msi')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { buildSshCommand, buildRemoteCommand } from '../../../main/utils/ssh-command-builder';
|
||||
import {
|
||||
buildSshCommand,
|
||||
buildRemoteCommand,
|
||||
buildSshCommandWithStdin,
|
||||
} from '../../../main/utils/ssh-command-builder';
|
||||
import type { SshRemoteConfig } from '../../../shared/types';
|
||||
import * as os from 'os';
|
||||
|
||||
@@ -660,4 +664,229 @@ describe('ssh-command-builder', () => {
|
||||
expect(remoteCommand).toContain('line3');
|
||||
});
|
||||
});
|
||||
|
||||
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 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 () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run', '--format', 'json'],
|
||||
});
|
||||
|
||||
expect(result.command).toBe('ssh');
|
||||
// Last arg should be /bin/bash (the remote command)
|
||||
expect(result.args[result.args.length - 1]).toBe('/bin/bash');
|
||||
});
|
||||
|
||||
it('includes PATH setup in stdin script', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
});
|
||||
|
||||
expect(result.stdinScript).toBeDefined();
|
||||
expect(result.stdinScript).toContain('export PATH=');
|
||||
expect(result.stdinScript).toContain('.local/bin');
|
||||
expect(result.stdinScript).toContain('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('includes cd command in stdin script when cwd provided', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
cwd: '/home/user/project',
|
||||
});
|
||||
|
||||
expect(result.stdinScript).toContain("cd '/home/user/project'");
|
||||
});
|
||||
|
||||
it('includes environment variables in stdin script', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
env: {
|
||||
OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow"},"tools":{"question":false}}',
|
||||
CUSTOM_VAR: 'test-value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stdinScript).toContain('export OPENCODE_CONFIG_CONTENT=');
|
||||
expect(result.stdinScript).toContain('export CUSTOM_VAR=');
|
||||
// The JSON should be in the script (escaped with single quotes)
|
||||
expect(result.stdinScript).toContain('question');
|
||||
});
|
||||
|
||||
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'");
|
||||
|
||||
// The prompt should appear after the exec line (stdin passthrough)
|
||||
expect(result.stdinScript).toContain('Write hello world to a file');
|
||||
|
||||
// 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', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
stdinInput: "What's the $PATH? Use `echo` and \"quotes\"",
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
stdinInput: 'Line 1\nLine 2\nLine 3',
|
||||
});
|
||||
|
||||
expect(result.stdinScript).toContain('Line 1');
|
||||
expect(result.stdinScript).toContain('Line 2');
|
||||
expect(result.stdinScript).toContain('Line 3');
|
||||
});
|
||||
|
||||
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 and <<EOF markers',
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
prompt: "Say 'hello'",
|
||||
});
|
||||
|
||||
const execLine = result.stdinScript
|
||||
?.split('\n')
|
||||
.find((line) => line.startsWith('exec '));
|
||||
// The prompt is escaped with single quotes - "Say 'hello'" becomes "'Say '\\''hello'\\''"
|
||||
expect(execLine).toContain("opencode 'run' 'Say '\\''hello'\\'''");
|
||||
});
|
||||
|
||||
it('uses exec to replace shell with command', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
});
|
||||
|
||||
// The script should use exec to replace the shell process
|
||||
expect(result.stdinScript).toContain('exec ');
|
||||
});
|
||||
|
||||
it('includes SSH options in args', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
});
|
||||
|
||||
expect(result.args).toContain('-o');
|
||||
expect(result.args).toContain('BatchMode=yes');
|
||||
expect(result.args).toContain('StrictHostKeyChecking=accept-new');
|
||||
});
|
||||
|
||||
it('includes private key when provided', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
});
|
||||
|
||||
expect(result.args).toContain('-i');
|
||||
expect(result.args).toContain('/Users/testuser/.ssh/id_ed25519');
|
||||
});
|
||||
|
||||
it('includes username@host destination', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
});
|
||||
|
||||
expect(result.args).toContain('testuser@dev.example.com');
|
||||
});
|
||||
|
||||
it('merges remote config env with option env', async () => {
|
||||
const configWithEnv = {
|
||||
...baseConfig,
|
||||
remoteEnv: { REMOTE_VAR: 'from-config' },
|
||||
};
|
||||
|
||||
const result = await buildSshCommandWithStdin(configWithEnv, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
env: { OPTION_VAR: 'from-option' },
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,8 +164,9 @@ describe('TabBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
// Mock scrollTo
|
||||
// Mock scrollTo and scrollIntoView
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
// Mock clipboard
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
@@ -1430,13 +1431,13 @@ describe('TabBar', () => {
|
||||
});
|
||||
|
||||
describe('scroll behavior', () => {
|
||||
it('scrolls to center active tab when activeTabId changes', async () => {
|
||||
it('scrolls active tab into view when activeTabId changes', async () => {
|
||||
// Mock requestAnimationFrame
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
const scrollToSpy = vi.fn();
|
||||
const scrollIntoViewSpy = vi.fn();
|
||||
|
||||
const tabs = [
|
||||
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
||||
@@ -1454,9 +1455,11 @@ describe('TabBar', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Mock scrollTo on the container
|
||||
const tabBarContainer = container.firstChild as HTMLElement;
|
||||
tabBarContainer.scrollTo = scrollToSpy;
|
||||
// Mock scrollIntoView on the tab elements
|
||||
const tabElements = container.querySelectorAll('[data-tab-id]');
|
||||
tabElements.forEach((el) => {
|
||||
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
||||
});
|
||||
|
||||
// Change active tab
|
||||
rerender(
|
||||
@@ -1470,19 +1473,29 @@ describe('TabBar', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// scrollTo should have been called via requestAnimationFrame
|
||||
expect(scrollToSpy).toHaveBeenCalled();
|
||||
// Re-mock scrollIntoView on tab elements after rerender
|
||||
const newTabElements = container.querySelectorAll('[data-tab-id]');
|
||||
newTabElements.forEach((el) => {
|
||||
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
||||
});
|
||||
|
||||
// scrollIntoView should have been called via requestAnimationFrame
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('scrolls to center active tab when showUnreadOnly filter is toggled off', async () => {
|
||||
it('scrolls active tab into view when showUnreadOnly filter is toggled off', async () => {
|
||||
// Mock requestAnimationFrame
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
const scrollToSpy = vi.fn();
|
||||
const scrollIntoViewSpy = vi.fn();
|
||||
|
||||
const tabs = [
|
||||
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
||||
@@ -1502,12 +1515,14 @@ describe('TabBar', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Mock scrollTo on the container
|
||||
const tabBarContainer = container.firstChild as HTMLElement;
|
||||
tabBarContainer.scrollTo = scrollToSpy;
|
||||
// Mock scrollIntoView on the tab elements
|
||||
const tabElements = container.querySelectorAll('[data-tab-id]');
|
||||
tabElements.forEach((el) => {
|
||||
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
||||
});
|
||||
|
||||
// Clear initial calls
|
||||
scrollToSpy.mockClear();
|
||||
scrollIntoViewSpy.mockClear();
|
||||
|
||||
// Toggle filter off - this should trigger scroll to active tab
|
||||
rerender(
|
||||
@@ -1522,19 +1537,29 @@ describe('TabBar', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// scrollTo should have been called when filter was toggled
|
||||
expect(scrollToSpy).toHaveBeenCalled();
|
||||
// Re-mock scrollIntoView on tab elements after rerender
|
||||
const newTabElements = container.querySelectorAll('[data-tab-id]');
|
||||
newTabElements.forEach((el) => {
|
||||
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
||||
});
|
||||
|
||||
// scrollIntoView should have been called when filter was toggled
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('scrolls to center file tab when activeFileTabId changes', async () => {
|
||||
it('scrolls file tab into view when activeFileTabId changes', async () => {
|
||||
// Mock requestAnimationFrame
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
const scrollToSpy = vi.fn();
|
||||
const scrollIntoViewSpy = vi.fn();
|
||||
|
||||
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
||||
const fileTab: FilePreviewTab = {
|
||||
@@ -1563,12 +1588,14 @@ describe('TabBar', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Mock scrollTo on the container
|
||||
const tabBarContainer = container.firstChild as HTMLElement;
|
||||
tabBarContainer.scrollTo = scrollToSpy;
|
||||
// Mock scrollIntoView on the tab elements
|
||||
const tabElements = container.querySelectorAll('[data-tab-id]');
|
||||
tabElements.forEach((el) => {
|
||||
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
||||
});
|
||||
|
||||
// Clear initial calls
|
||||
scrollToSpy.mockClear();
|
||||
scrollIntoViewSpy.mockClear();
|
||||
|
||||
// Select the file tab - this should trigger scroll to file tab
|
||||
rerender(
|
||||
@@ -1586,8 +1613,18 @@ describe('TabBar', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// scrollTo should have been called when file tab was selected
|
||||
expect(scrollToSpy).toHaveBeenCalled();
|
||||
// Re-mock scrollIntoView on tab elements after rerender
|
||||
const newTabElements = container.querySelectorAll('[data-tab-id]');
|
||||
newTabElements.forEach((el) => {
|
||||
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
||||
});
|
||||
|
||||
// scrollIntoView should have been called when file tab was selected
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
@@ -1878,6 +1915,7 @@ describe('TabBar', () => {
|
||||
querySelector: vi.fn().mockReturnValue({
|
||||
offsetLeft: 100,
|
||||
offsetWidth: 80,
|
||||
scrollIntoView: vi.fn(),
|
||||
}),
|
||||
scrollTo: vi.fn(),
|
||||
}),
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('SummaryCards', () => {
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByTestId('summary-cards')).toBeInTheDocument();
|
||||
// Multiple cards show '0' for empty data (Sessions, Queries)
|
||||
// Multiple cards show '0' for empty data (Agents, Queries)
|
||||
expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('Chart Accessibility - SummaryCards', () => {
|
||||
const groups = screen.getAllByRole('group');
|
||||
|
||||
const expectedLabels = [
|
||||
/Sessions/i,
|
||||
/Agents/i,
|
||||
/Total Queries/i,
|
||||
/Queries\/Session/i,
|
||||
/Total Time/i,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
CreateHandlerOptions,
|
||||
} from '../../utils/ipcHandler';
|
||||
import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver';
|
||||
import { buildSshCommand } from '../../utils/ssh-command-builder';
|
||||
import { buildSshCommandWithStdin } from '../../utils/ssh-command-builder';
|
||||
import { buildExpandedEnv } from '../../../shared/pathUtils';
|
||||
import type { SshRemoteConfig } from '../../../shared/types';
|
||||
import { powerManager } from '../../power-manager';
|
||||
@@ -263,7 +263,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
let useShell = false;
|
||||
let sshRemoteUsed: SshRemoteConfig | null = null;
|
||||
let customEnvVarsToPass: Record<string, string> | undefined = effectiveCustomEnvVars;
|
||||
let useHereDocForOpenCode = false;
|
||||
let sshStdinScript: string | undefined;
|
||||
|
||||
if (config.sessionCustomPath) {
|
||||
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
|
||||
@@ -319,8 +319,6 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
willUseSsh: config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled,
|
||||
});
|
||||
}
|
||||
let shouldSendPromptViaStdin = false;
|
||||
let shouldSendPromptViaStdinRaw = false;
|
||||
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled) {
|
||||
// Session-level SSH config provided - resolve and use it
|
||||
logger.info(`Using session-level SSH config`, LOG_CONTEXT, {
|
||||
@@ -336,121 +334,52 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
});
|
||||
|
||||
if (sshResult.config) {
|
||||
// SSH remote is configured - wrap the command for remote execution
|
||||
// SSH remote is configured - use stdin-based execution
|
||||
// This completely bypasses shell escaping issues by sending the script via stdin
|
||||
sshRemoteUsed = sshResult.config;
|
||||
|
||||
// ALWAYS use stdin for SSH remote execution when there's a prompt.
|
||||
// Embedding prompts in the command line causes shell escaping nightmares:
|
||||
// - Multiple layers of quote escaping (local spawn, SSH, remote zsh, bash -c)
|
||||
// - Embedded newlines in prompts break zsh parsing (e.g., "zsh:35: parse error")
|
||||
// - Special characters like quotes, $, !, etc. need complex escaping
|
||||
// Using stdin with --input-format stream-json completely bypasses all these issues.
|
||||
const hasStreamJsonInput =
|
||||
finalArgs.includes('--input-format') && finalArgs.includes('stream-json');
|
||||
const agentSupportsStreamJson = agent?.capabilities.supportsStreamJsonInput ?? false;
|
||||
let sshArgs = finalArgs;
|
||||
if (config.prompt && agentSupportsStreamJson) {
|
||||
// Agent supports stream-json - always use stdin for prompts
|
||||
if (!hasStreamJsonInput) {
|
||||
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
|
||||
}
|
||||
shouldSendPromptViaStdin = true;
|
||||
logger.info(`Using stdin for prompt in SSH remote execution`, LOG_CONTEXT, {
|
||||
sessionId: config.sessionId,
|
||||
promptLength: config.prompt?.length,
|
||||
reason: 'ssh-stdin-for-reliability',
|
||||
hasStreamJsonInput,
|
||||
});
|
||||
} else if (config.prompt && !agentSupportsStreamJson) {
|
||||
// Agent doesn't support stream-json - use alternative methods
|
||||
if (config.toolType === 'opencode') {
|
||||
// OpenCode: mark for here document processing (will be handled after remoteCommand is set)
|
||||
useHereDocForOpenCode = true;
|
||||
} else {
|
||||
// Other agents: send via stdin as raw text
|
||||
shouldSendPromptViaStdinRaw = true;
|
||||
}
|
||||
}
|
||||
// Determine the command to run on the remote host
|
||||
const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
|
||||
|
||||
// Build the SSH command with stdin script
|
||||
// The script contains PATH setup, cd, env vars, and the actual command
|
||||
// This eliminates all shell escaping issues
|
||||
//
|
||||
// Determine the command to run on the remote host:
|
||||
// 1. If user set a session-specific custom path, use that (they configured it for the remote)
|
||||
// 2. Otherwise, use the agent's binaryName (e.g., 'codex', 'claude') and let
|
||||
// the remote shell's PATH resolve it. This avoids using local paths like
|
||||
// '/opt/homebrew/bin/codex' which don't exist on the remote host.
|
||||
let remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
|
||||
|
||||
// Handle OpenCode here document for large prompts
|
||||
if (useHereDocForOpenCode && config.prompt) {
|
||||
// OpenCode: use here document to avoid command line limits
|
||||
// Escape single quotes in the prompt for bash here document
|
||||
const escapedPrompt = config.prompt.replace(/'/g, "'\\''");
|
||||
// Construct: cat << 'EOF' | opencode run --format json\nlong prompt here\nEOF
|
||||
const hereDocCommand = `cat << 'EOF' | ${remoteCommand} ${sshArgs.join(' ')}\n${escapedPrompt}\nEOF`;
|
||||
sshArgs = []; // Clear args since they're now in the here doc command
|
||||
remoteCommand = hereDocCommand; // Update to use here document
|
||||
logger.info(
|
||||
`Using here document for large OpenCode prompt to avoid command line limits`,
|
||||
LOG_CONTEXT,
|
||||
{
|
||||
sessionId: config.sessionId,
|
||||
promptLength: config.prompt?.length,
|
||||
commandLength: hereDocCommand.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
// Decide whether we'll send input via stdin to the remote command
|
||||
const useStdin = sshArgs.includes('--input-format') && sshArgs.includes('stream-json');
|
||||
|
||||
const sshCommand = await buildSshCommand(sshResult.config, {
|
||||
// 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: sshArgs,
|
||||
// Use the cwd from config - this is the project directory on the remote
|
||||
args: finalArgs,
|
||||
cwd: config.cwd,
|
||||
// Pass custom environment variables to the remote command
|
||||
env: effectiveCustomEnvVars,
|
||||
// Explicitly indicate whether stdin will be used so ssh-command-builder
|
||||
// can avoid forcing a TTY for stream-json modes.
|
||||
useStdin,
|
||||
// prompt is not passed as CLI arg - it goes via stdinInput
|
||||
stdinInput,
|
||||
});
|
||||
|
||||
commandToSpawn = sshCommand.command;
|
||||
argsToSpawn = sshCommand.args;
|
||||
sshStdinScript = sshCommand.stdinScript;
|
||||
|
||||
// For SSH, env vars are passed in the remote command string, not locally
|
||||
// For SSH, env vars are passed in the stdin script, not locally
|
||||
customEnvVarsToPass = undefined;
|
||||
|
||||
// On Windows, use PowerShell for SSH commands to avoid cmd.exe's 8191 character limit
|
||||
// PowerShell supports up to 32,767 characters, which is needed for large prompts
|
||||
if (isWindows) {
|
||||
useShell = true;
|
||||
shellToUse = 'powershell.exe';
|
||||
logger.info(
|
||||
`Using PowerShell for SSH command on Windows to support long command lines`,
|
||||
LOG_CONTEXT,
|
||||
{
|
||||
sessionId: config.sessionId,
|
||||
commandLength: sshCommand.args.join(' ').length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Detailed debug logging to diagnose SSH command execution issues
|
||||
logger.debug(`SSH command details for debugging`, LOG_CONTEXT, {
|
||||
logger.info(`SSH command built with stdin passthrough`, LOG_CONTEXT, {
|
||||
sessionId: config.sessionId,
|
||||
toolType: config.toolType,
|
||||
sshBinary: sshCommand.command,
|
||||
sshArgsCount: sshCommand.args.length,
|
||||
sshArgsArray: sshCommand.args,
|
||||
// Show the last arg which contains the wrapped remote command
|
||||
remoteCommandString: sshCommand.args[sshCommand.args.length - 1],
|
||||
// Show the agent command that will execute remotely
|
||||
agentBinary: remoteCommand,
|
||||
agentArgs: sshArgs,
|
||||
agentCwd: config.cwd,
|
||||
// Full invocation for copy-paste debugging
|
||||
fullSshInvocation: `${sshCommand.command} ${sshCommand.args
|
||||
.map((arg) => (arg.includes(' ') ? `'${arg}'` : arg))
|
||||
.join(' ')}`,
|
||||
remoteCommand,
|
||||
remoteCwd: config.cwd,
|
||||
promptLength: config.prompt?.length,
|
||||
stdinScriptLength: sshCommand.stdinScript?.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -469,38 +398,31 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
command: commandToSpawn,
|
||||
args: argsToSpawn,
|
||||
// When using SSH, use user's home directory as local cwd
|
||||
// The remote working directory is embedded in the SSH command itself
|
||||
// The remote working directory is embedded in the SSH stdin script
|
||||
// This fixes ENOENT errors when session.cwd is a remote-only path
|
||||
cwd: sshRemoteUsed ? os.homedir() : config.cwd,
|
||||
// When using SSH, disable PTY (SSH provides its own terminal handling)
|
||||
// and env vars are passed via the remote command string
|
||||
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
|
||||
// When using SSH with small prompts, the prompt was already added to sshArgs above
|
||||
// For large prompts or stream-json input, pass it to ProcessManager so it can send via stdin
|
||||
prompt:
|
||||
sshRemoteUsed && config.prompt && shouldSendPromptViaStdin
|
||||
? config.prompt
|
||||
: sshRemoteUsed
|
||||
? undefined
|
||||
: config.prompt,
|
||||
// For SSH, prompt is included in the stdin script, not passed separately
|
||||
// For local execution, pass prompt as normal
|
||||
prompt: sshRemoteUsed ? undefined : config.prompt,
|
||||
shell: shellToUse,
|
||||
runInShell: useShell,
|
||||
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
|
||||
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
|
||||
contextWindow, // Pass configured context window to process manager
|
||||
// When using SSH, env vars are passed in the remote command string, not locally
|
||||
// When using SSH, env vars are passed in the stdin script, not locally
|
||||
customEnvVars: customEnvVarsToPass,
|
||||
imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode)
|
||||
promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
|
||||
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
|
||||
// For SSH with stream-json input, send prompt via stdin instead of command line
|
||||
sendPromptViaStdin: shouldSendPromptViaStdin ? true : undefined,
|
||||
sendPromptViaStdinRaw: shouldSendPromptViaStdinRaw ? true : undefined,
|
||||
// Stats tracking: use cwd as projectPath if not explicitly provided
|
||||
projectPath: config.cwd,
|
||||
// SSH remote context (for SSH-specific error messages)
|
||||
sshRemoteId: sshRemoteUsed?.id,
|
||||
sshRemoteHost: sshRemoteUsed?.host,
|
||||
// SSH stdin script - the entire command is sent via stdin to /bin/bash on remote
|
||||
sshStdinScript,
|
||||
});
|
||||
|
||||
logger.info(`Process spawned successfully`, LOG_CONTEXT, {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { logger } from '../../utils/logger';
|
||||
import { isWebContentsAvailable } from '../../utils/safe-send';
|
||||
import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
|
||||
import { execFileNoThrow } from '../../utils/execFile';
|
||||
import { getExpandedEnv } from '../../agents/path-prober';
|
||||
import {
|
||||
SYMPHONY_REGISTRY_URL,
|
||||
REGISTRY_CACHE_TTL_MS,
|
||||
@@ -575,7 +576,7 @@ async function createBranch(
|
||||
* Check if gh CLI is authenticated.
|
||||
*/
|
||||
async function checkGhAuthentication(): Promise<{ authenticated: boolean; error?: string }> {
|
||||
const result = await execFileNoThrow('gh', ['auth', 'status']);
|
||||
const result = await execFileNoThrow('gh', ['auth', 'status'], undefined, getExpandedEnv());
|
||||
if (result.exitCode !== 0) {
|
||||
// gh auth status outputs to stderr even on success for some info
|
||||
const output = result.stderr + result.stdout;
|
||||
@@ -685,7 +686,8 @@ async function createDraftPR(
|
||||
'--body',
|
||||
body,
|
||||
],
|
||||
repoPath
|
||||
repoPath,
|
||||
getExpandedEnv()
|
||||
);
|
||||
|
||||
if (prResult.exitCode !== 0) {
|
||||
@@ -710,7 +712,12 @@ async function markPRReady(
|
||||
repoPath: string,
|
||||
prNumber: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await execFileNoThrow('gh', ['pr', 'ready', String(prNumber)], repoPath);
|
||||
const result = await execFileNoThrow(
|
||||
'gh',
|
||||
['pr', 'ready', String(prNumber)],
|
||||
repoPath,
|
||||
getExpandedEnv()
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return { success: false, error: result.stderr };
|
||||
@@ -833,7 +840,8 @@ This pull request was created using [Maestro Symphony](https://runmaestro.ai/sym
|
||||
const result = await execFileNoThrow(
|
||||
'gh',
|
||||
['pr', 'comment', String(prNumber), '--body', commentBody],
|
||||
repoPath
|
||||
repoPath,
|
||||
getExpandedEnv()
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
@@ -2579,5 +2587,161 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Manually credit a contribution (for contributions made outside Symphony workflow).
|
||||
* This allows crediting a user for work done on a PR that wasn't tracked through Symphony.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'symphony:manualCredit',
|
||||
createIpcHandler(
|
||||
handlerOpts('manualCredit'),
|
||||
async (params: {
|
||||
repoSlug: string;
|
||||
repoName: string;
|
||||
issueNumber: number;
|
||||
issueTitle: string;
|
||||
prNumber: number;
|
||||
prUrl: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
wasMerged?: boolean;
|
||||
mergedAt?: string;
|
||||
tokenUsage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
};
|
||||
timeSpent?: number;
|
||||
documentsProcessed?: number;
|
||||
tasksCompleted?: number;
|
||||
}): Promise<{ contributionId?: string; error?: string }> => {
|
||||
const {
|
||||
repoSlug,
|
||||
repoName,
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
prNumber,
|
||||
prUrl,
|
||||
startedAt,
|
||||
completedAt,
|
||||
wasMerged,
|
||||
mergedAt,
|
||||
tokenUsage,
|
||||
timeSpent,
|
||||
documentsProcessed,
|
||||
tasksCompleted,
|
||||
} = params;
|
||||
|
||||
// Validate required fields
|
||||
if (!repoSlug || !repoName || !issueNumber || !prNumber || !prUrl) {
|
||||
return { error: 'Missing required fields: repoSlug, repoName, issueNumber, prNumber, prUrl' };
|
||||
}
|
||||
|
||||
const state = await readState(app);
|
||||
|
||||
// Check if this PR is already credited
|
||||
const existingContribution = state.history.find(
|
||||
(c) => c.repoSlug === repoSlug && c.prNumber === prNumber
|
||||
);
|
||||
if (existingContribution) {
|
||||
return { error: `PR #${prNumber} is already credited (contribution: ${existingContribution.id})` };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const contributionId = `manual_${issueNumber}_${Date.now()}`;
|
||||
|
||||
const completed: CompletedContribution = {
|
||||
id: contributionId,
|
||||
repoSlug,
|
||||
repoName,
|
||||
issueNumber,
|
||||
issueTitle: issueTitle || `Issue #${issueNumber}`,
|
||||
startedAt: startedAt || now,
|
||||
completedAt: completedAt || now,
|
||||
prUrl,
|
||||
prNumber,
|
||||
tokenUsage: {
|
||||
inputTokens: tokenUsage?.inputTokens ?? 0,
|
||||
outputTokens: tokenUsage?.outputTokens ?? 0,
|
||||
totalCost: tokenUsage?.totalCost ?? 0,
|
||||
},
|
||||
timeSpent: timeSpent ?? 0,
|
||||
documentsProcessed: documentsProcessed ?? 0,
|
||||
tasksCompleted: tasksCompleted ?? 1,
|
||||
wasMerged: wasMerged ?? false,
|
||||
mergedAt: mergedAt,
|
||||
};
|
||||
|
||||
// Add to history
|
||||
state.history.push(completed);
|
||||
|
||||
// Update stats
|
||||
state.stats.totalContributions += 1;
|
||||
state.stats.totalDocumentsProcessed += completed.documentsProcessed;
|
||||
state.stats.totalTasksCompleted += completed.tasksCompleted;
|
||||
state.stats.totalTokensUsed +=
|
||||
completed.tokenUsage.inputTokens + completed.tokenUsage.outputTokens;
|
||||
state.stats.totalTimeSpent += completed.timeSpent;
|
||||
state.stats.estimatedCostDonated += completed.tokenUsage.totalCost;
|
||||
|
||||
if (!state.stats.repositoriesContributed.includes(repoSlug)) {
|
||||
state.stats.repositoriesContributed.push(repoSlug);
|
||||
}
|
||||
|
||||
if (wasMerged) {
|
||||
state.stats.totalMerged = (state.stats.totalMerged || 0) + 1;
|
||||
state.stats.totalIssuesResolved = (state.stats.totalIssuesResolved || 0) + 1;
|
||||
}
|
||||
|
||||
state.stats.lastContributionAt = completed.completedAt;
|
||||
if (!state.stats.firstContributionAt) {
|
||||
state.stats.firstContributionAt = completed.completedAt;
|
||||
}
|
||||
|
||||
// Update streak
|
||||
const getWeekNumber = (date: Date): string => {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${weekNo}`;
|
||||
};
|
||||
const currentWeek = getWeekNumber(new Date());
|
||||
const lastWeek = state.stats.lastContributionDate;
|
||||
if (lastWeek) {
|
||||
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const previousWeek = getWeekNumber(oneWeekAgo);
|
||||
if (lastWeek === previousWeek || lastWeek === currentWeek) {
|
||||
if (lastWeek !== currentWeek) {
|
||||
state.stats.currentStreak += 1;
|
||||
}
|
||||
} else {
|
||||
state.stats.currentStreak = 1;
|
||||
}
|
||||
} else {
|
||||
state.stats.currentStreak = 1;
|
||||
}
|
||||
state.stats.lastContributionDate = currentWeek;
|
||||
if (state.stats.currentStreak > state.stats.longestStreak) {
|
||||
state.stats.longestStreak = state.stats.currentStreak;
|
||||
}
|
||||
|
||||
await writeState(app, state);
|
||||
|
||||
logger.info('Manual contribution credited', LOG_CONTEXT, {
|
||||
contributionId,
|
||||
repoSlug,
|
||||
prNumber,
|
||||
prUrl,
|
||||
});
|
||||
|
||||
broadcastSymphonyUpdate(getMainWindow);
|
||||
|
||||
return { contributionId };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
logger.info('Symphony handlers registered', LOG_CONTEXT);
|
||||
}
|
||||
|
||||
@@ -320,6 +320,28 @@ export function createSymphonyApi() {
|
||||
): Promise<{ success: boolean; content?: string; error?: string }> =>
|
||||
ipcRenderer.invoke('symphony:fetchDocumentContent', { url }),
|
||||
|
||||
manualCredit: (params: {
|
||||
repoSlug: string;
|
||||
repoName: string;
|
||||
issueNumber: number;
|
||||
issueTitle: string;
|
||||
prNumber: number;
|
||||
prUrl: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
wasMerged?: boolean;
|
||||
mergedAt?: string;
|
||||
tokenUsage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
};
|
||||
timeSpent?: number;
|
||||
documentsProcessed?: number;
|
||||
tasksCompleted?: number;
|
||||
}): Promise<{ success: boolean; contributionId?: string; error?: string }> =>
|
||||
ipcRenderer.invoke('symphony:manualCredit', params),
|
||||
|
||||
// Real-time updates
|
||||
onUpdated: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
|
||||
@@ -397,8 +397,17 @@ export class ChildProcessSpawner {
|
||||
this.exitHandler.handleError(sessionId, error);
|
||||
});
|
||||
|
||||
// Handle stdin for batch mode and stream-json
|
||||
if (isStreamJsonMode && prompt) {
|
||||
// Handle stdin for SSH script, stream-json, or batch mode
|
||||
if (config.sshStdinScript) {
|
||||
// SSH stdin script mode: send the entire script to /bin/bash on remote
|
||||
// This bypasses all shell escaping issues by piping the script via stdin
|
||||
logger.debug('[ProcessManager] Sending SSH stdin script', 'ProcessManager', {
|
||||
sessionId,
|
||||
scriptLength: config.sshStdinScript.length,
|
||||
});
|
||||
childProcess.stdin?.write(config.sshStdinScript);
|
||||
childProcess.stdin?.end();
|
||||
} else if (isStreamJsonMode && prompt) {
|
||||
if (config.sendPromptViaStdinRaw) {
|
||||
// Send raw prompt via stdin
|
||||
logger.debug('[ProcessManager] Sending raw prompt via stdin', 'ProcessManager', {
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface ProcessConfig {
|
||||
sendPromptViaStdin?: boolean;
|
||||
/** If true, send the prompt via stdin as raw text instead of command line */
|
||||
sendPromptViaStdinRaw?: boolean;
|
||||
/** Script to send via stdin for SSH execution (bypasses shell escaping) */
|
||||
sshStdinScript?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,10 +25,12 @@ export interface ExecResult {
|
||||
/**
|
||||
* Determine if a command needs shell execution on Windows
|
||||
* - Batch files (.cmd, .bat) always need shell
|
||||
* - Commands without extensions need PATHEXT resolution via shell
|
||||
* - Commands without extensions normally need PATHEXT resolution via shell,
|
||||
* BUT we avoid shell for known commands that have .exe variants (git, node, etc.)
|
||||
* to prevent percent-sign escaping issues in arguments
|
||||
* - Executables (.exe, .com) can run directly
|
||||
*/
|
||||
function needsWindowsShell(command: string): boolean {
|
||||
export function needsWindowsShell(command: string): boolean {
|
||||
const lowerCommand = command.toLowerCase();
|
||||
|
||||
// Batch files always need shell
|
||||
@@ -41,7 +43,17 @@ function needsWindowsShell(command: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Commands without extension need shell for PATHEXT resolution
|
||||
// Commands without extension: skip shell for known commands that have .exe variants
|
||||
// This prevents issues like % being interpreted as environment variables on Windows
|
||||
// Extract basename to handle full paths like 'C:\Program Files\Git\bin\git'
|
||||
// Use regex to handle both Unix (/) and Windows (\) path separators
|
||||
const knownExeCommands = new Set(['git', 'node', 'npm', 'npx', 'yarn', 'pnpm', 'python', 'python3', 'pip', 'pip3']);
|
||||
const commandBaseName = lowerCommand.split(/[\\/]/).pop() || lowerCommand;
|
||||
if (knownExeCommands.has(commandBaseName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other commands without extension still need shell for PATHEXT resolution
|
||||
const hasExtension = path.extname(command).length > 0;
|
||||
return !hasExtension;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface SshCommandResult {
|
||||
command: string;
|
||||
/** Arguments for the SSH command */
|
||||
args: string[];
|
||||
/** Script to send via stdin (for stdin-based execution) */
|
||||
stdinScript?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +134,154 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string {
|
||||
return parts.join(' && ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SSH command that executes a script via stdin.
|
||||
*
|
||||
* 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. 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+prompt to send via stdin
|
||||
*
|
||||
* @example
|
||||
* const result = await buildSshCommandWithStdin(config, {
|
||||
* command: 'opencode',
|
||||
* args: ['run', '--format', 'json'],
|
||||
* cwd: '/home/user/project',
|
||||
* env: { OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow"}}' },
|
||||
* stdinInput: 'Write hello world to a file'
|
||||
* });
|
||||
* // result.command = 'ssh'
|
||||
* // result.args = ['-o', 'BatchMode=yes', ..., 'user@host', '/bin/bash']
|
||||
* // 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,
|
||||
remoteOptions: RemoteCommandOptions & { prompt?: string; stdinInput?: string }
|
||||
): Promise<SshCommandResult> {
|
||||
const args: string[] = [];
|
||||
|
||||
// Resolve the SSH binary path
|
||||
const sshPath = await resolveSshPath();
|
||||
|
||||
// For stdin-based execution, we never need TTY (stdin is the script, not user input)
|
||||
// TTY would interfere with piping the script
|
||||
|
||||
// Private key - only add if explicitly provided
|
||||
if (config.privateKeyPath && config.privateKeyPath.trim()) {
|
||||
args.push('-i', expandTilde(config.privateKeyPath));
|
||||
}
|
||||
|
||||
// Default SSH options - but RequestTTY is always 'no' for stdin mode
|
||||
for (const [key, value] of Object.entries(DEFAULT_SSH_OPTIONS)) {
|
||||
args.push('-o', `${key}=${value}`);
|
||||
}
|
||||
|
||||
// Port specification
|
||||
if (!config.useSshConfig || config.port !== 22) {
|
||||
args.push('-p', config.port.toString());
|
||||
}
|
||||
|
||||
// Build destination
|
||||
if (config.username && config.username.trim()) {
|
||||
args.push(`${config.username}@${config.host}`);
|
||||
} else {
|
||||
args.push(config.host);
|
||||
}
|
||||
|
||||
// The remote command is just /bin/bash - it will read the script from stdin
|
||||
args.push('/bin/bash');
|
||||
|
||||
// Build the script to send via stdin
|
||||
const scriptLines: string[] = [];
|
||||
|
||||
// PATH setup - same directories as before
|
||||
scriptLines.push(
|
||||
'export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$HOME/.cargo/bin:$PATH"'
|
||||
);
|
||||
|
||||
// Change directory if specified
|
||||
if (remoteOptions.cwd) {
|
||||
// In the script context, we can use simple quoting
|
||||
scriptLines.push(`cd ${shellEscape(remoteOptions.cwd)} || exit 1`);
|
||||
}
|
||||
|
||||
// Merge environment variables
|
||||
const mergedEnv: Record<string, string> = {
|
||||
...(config.remoteEnv || {}),
|
||||
...(remoteOptions.env || {}),
|
||||
};
|
||||
|
||||
// Export environment variables
|
||||
for (const [key, value] of Object.entries(mergedEnv)) {
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
scriptLines.push(`export ${key}=${shellEscape(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the command line
|
||||
// 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 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)
|
||||
// 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(' ')}`);
|
||||
|
||||
// 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,
|
||||
username: config.username || '(using SSH config/system default)',
|
||||
port: config.port,
|
||||
sshPath,
|
||||
sshArgsCount: args.length,
|
||||
scriptLineCount: scriptLines.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,
|
||||
});
|
||||
|
||||
return {
|
||||
command: sshPath,
|
||||
args,
|
||||
stdinScript,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH command and arguments for remote execution.
|
||||
*
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -784,8 +784,8 @@ export function NewInstanceModal({
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
{/* "Beta" badge for Codex and OpenCode */}
|
||||
{(agent.id === 'codex' || agent.id === 'opencode') && (
|
||||
{/* "Beta" badge for Codex, OpenCode, and Factory Droid */}
|
||||
{(agent.id === 'codex' || agent.id === 'opencode' || agent.id === 'factory-droid') && (
|
||||
<span
|
||||
className="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
|
||||
style={{
|
||||
|
||||
@@ -1599,7 +1599,7 @@ function TabBarInner({
|
||||
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
// Center the active tab in the scrollable area when activeTabId or activeFileTabId changes, or filter is toggled
|
||||
// Ensure the active tab is fully visible (including close button) when activeTabId or activeFileTabId changes, or filter is toggled
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = tabBarRef.current;
|
||||
@@ -1609,10 +1609,10 @@ function TabBarInner({
|
||||
`[data-tab-id="${targetTabId}"]`
|
||||
) as HTMLElement | null;
|
||||
if (container && tabElement) {
|
||||
// Calculate scroll position to center the tab
|
||||
const scrollLeft =
|
||||
tabElement.offsetLeft - container.clientWidth / 2 + tabElement.offsetWidth / 2;
|
||||
container.scrollTo({ left: scrollLeft, behavior: 'smooth' });
|
||||
// Use scrollIntoView with 'nearest' to ensure the full tab is visible
|
||||
// This scrolls minimally - only if the tab is partially or fully out of view
|
||||
// The 'end' option ensures the right edge (with close button) is visible
|
||||
tabElement.scrollIntoView({ inline: 'nearest', behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}, [activeTabId, activeFileTabId, showUnreadOnly]);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
Globe,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { Theme, Session } from '../../types';
|
||||
import type { StatsAggregation } from '../../hooks/useStats';
|
||||
|
||||
interface SummaryCardsProps {
|
||||
@@ -39,6 +39,8 @@ interface SummaryCardsProps {
|
||||
theme: Theme;
|
||||
/** Number of columns for responsive layout (default: 3 for 2 rows × 3 cols) */
|
||||
columns?: number;
|
||||
/** Sessions array for accurate agent count (filters terminal sessions) */
|
||||
sessions?: Session[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +136,16 @@ function formatHour(hour: number): string {
|
||||
return `${displayHour} ${suffix}`;
|
||||
}
|
||||
|
||||
export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) {
|
||||
export function SummaryCards({ data, theme, columns = 3, sessions }: SummaryCardsProps) {
|
||||
// Count agent sessions (exclude terminal-only sessions) for accurate total
|
||||
const agentCount = useMemo(() => {
|
||||
if (sessions) {
|
||||
return sessions.filter((s) => s.toolType !== 'terminal').length;
|
||||
}
|
||||
// Fallback to stats-based count if sessions not provided
|
||||
return data.totalSessions;
|
||||
}, [sessions, data.totalSessions]);
|
||||
|
||||
// Calculate derived metrics
|
||||
const { mostActiveAgent, interactiveRatio, peakHour, localVsRemote, queriesPerSession } =
|
||||
useMemo(() => {
|
||||
@@ -162,10 +173,10 @@ export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) {
|
||||
? `${Math.round((data.byLocation.local / totalByLocation) * 100)}%`
|
||||
: 'N/A';
|
||||
|
||||
// Calculate queries per session
|
||||
// Calculate queries per session using agent count for consistency
|
||||
const qps =
|
||||
data.totalSessions > 0
|
||||
? (data.totalQueries / data.totalSessions).toFixed(1)
|
||||
agentCount > 0
|
||||
? (data.totalQueries / agentCount).toFixed(1)
|
||||
: 'N/A';
|
||||
|
||||
return {
|
||||
@@ -175,13 +186,13 @@ export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) {
|
||||
localVsRemote: localPercent,
|
||||
queriesPerSession: qps,
|
||||
};
|
||||
}, [data.byAgent, data.bySource, data.byHour, data.byLocation, data.totalSessions, data.totalQueries]);
|
||||
}, [data.byAgent, data.bySource, data.byHour, data.byLocation, agentCount, data.totalQueries]);
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
label: 'Sessions',
|
||||
value: formatNumber(data.totalSessions),
|
||||
label: 'Agents',
|
||||
value: formatNumber(agentCount),
|
||||
},
|
||||
{
|
||||
icon: <MessageSquare className="w-4 h-4" />,
|
||||
|
||||
@@ -728,7 +728,7 @@ export function UsageDashboardModal({
|
||||
data-testid="section-summary-cards"
|
||||
>
|
||||
<ChartErrorBoundary theme={theme} chartName="Summary Cards">
|
||||
<SummaryCards data={data} theme={theme} columns={layout.summaryCardsCols} />
|
||||
<SummaryCards data={data} theme={theme} columns={layout.summaryCardsCols} sessions={sessions} />
|
||||
</ChartErrorBoundary>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1254,8 +1254,8 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* "Beta" badge for Codex and OpenCode */}
|
||||
{isSupported && (tile.id === 'codex' || tile.id === 'opencode') && (
|
||||
{/* "Beta" badge for Codex, OpenCode, and Factory Droid */}
|
||||
{isSupported && (tile.id === 'codex' || tile.id === 'opencode' || tile.id === 'factory-droid') && (
|
||||
<span
|
||||
className="absolute top-2 left-2 px-1.5 py-0.5 text-[9px] rounded font-bold uppercase"
|
||||
style={{
|
||||
|
||||
@@ -582,7 +582,9 @@ export async function sendWizardMessage(
|
||||
const argsForSpawn = agent ? buildArgsForAgent(agent) : [];
|
||||
|
||||
// On Windows, use sendPromptViaStdin to bypass cmd.exe ~8KB command line length limit
|
||||
const sendViaStdin = process.platform === 'win32';
|
||||
// Note: Use navigator.platform in renderer (process.platform is not available in browser context)
|
||||
const isWindows = navigator.platform.toLowerCase().includes('win');
|
||||
const sendViaStdin = isWindows;
|
||||
if (sendViaStdin && !argsForSpawn.includes('--input-format')) {
|
||||
// Add --input-format stream-json when using stdin with stream-json compatible agents
|
||||
if (session.agentType === 'claude-code' || session.agentType === 'codex') {
|
||||
@@ -592,7 +594,7 @@ export async function sendWizardMessage(
|
||||
|
||||
logger.info(`Using stdin for Windows: ${sendViaStdin}`, '[InlineWizardConversation]', {
|
||||
sessionId: session.sessionId,
|
||||
platform: process.platform,
|
||||
platform: navigator.platform,
|
||||
promptLength: fullPrompt.length,
|
||||
sendViaStdin,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user