diff --git a/src/__tests__/main/ipc/handlers/symphony.test.ts b/src/__tests__/main/ipc/handlers/symphony.test.ts index 18d34133..578c98d9 100644 --- a/src/__tests__/main/ipc/handlers/symphony.test.ts +++ b/src/__tests__/main/ipc/handlers/symphony.test.ts @@ -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(); + }); + }); + }); }); diff --git a/src/__tests__/main/utils/needsWindowsShell.test.ts b/src/__tests__/main/utils/needsWindowsShell.test.ts new file mode 100644 index 00000000..30764560 --- /dev/null +++ b/src/__tests__/main/utils/needsWindowsShell.test.ts @@ -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); + }); + }); +}); diff --git a/src/__tests__/main/utils/ssh-command-builder.test.ts b/src/__tests__/main/utils/ssh-command-builder.test.ts index 4e9ffddb..f900a446 100644 --- a/src/__tests__/main/utils/ssh-command-builder.test.ts +++ b/src/__tests__/main/utils/ssh-command-builder.test.ts @@ -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 < { + 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"'); + }); + }); }); diff --git a/src/__tests__/renderer/components/GroupChatModals.test.tsx b/src/__tests__/renderer/components/GroupChatModals.test.tsx index acf9f446..1fd05c3b 100644 --- a/src/__tests__/renderer/components/GroupChatModals.test.tsx +++ b/src/__tests__/renderer/components/GroupChatModals.test.tsx @@ -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( + + ); + + // 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( + + ); + + // 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(); + }); + }); }); }); diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index 94115a13..cae12cf7 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -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(), }), diff --git a/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx b/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx index 325b250b..4b944f8f 100644 --- a/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx +++ b/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx @@ -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); }); diff --git a/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx b/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx index 34239bfa..1e7480b3 100644 --- a/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx +++ b/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx @@ -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, diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 6b893ab5..eb935768 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -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: [ diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index b4c3cbc4..900e5658 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -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 | 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, { diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts index 7a64fcea..e72cb4cc 100644 --- a/src/main/ipc/handlers/symphony.ts +++ b/src/main/ipc/handlers/symphony.ts @@ -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); } diff --git a/src/main/preload/symphony.ts b/src/main/preload/symphony.ts index 65a3d853..896afda5 100644 --- a/src/main/preload/symphony.ts +++ b/src/main/preload/symphony.ts @@ -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(); diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index 5bfad408..4391366f 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -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', { diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index e05e219e..6b2ccb03 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -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; } /** diff --git a/src/main/utils/execFile.ts b/src/main/utils/execFile.ts index 092cc469..97ad4c17 100644 --- a/src/main/utils/execFile.ts +++ b/src/main/utils/execFile.ts @@ -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; } diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index 4ac58dd8..32c747fd 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -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 { + 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 = { + ...(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. * diff --git a/src/renderer/components/EditGroupChatModal.tsx b/src/renderer/components/EditGroupChatModal.tsx index 5685c6ca..f6069a3d 100644 --- a/src/renderer/components/EditGroupChatModal.tsx +++ b/src/renderer/components/EditGroupChatModal.tsx @@ -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([]); 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 ( - -
- -

- Configure {selectedTile.name} -

-
- - - } - footer={ - - } - > -
- { - /* 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 - /> -
-
- ); - } - - // Render grid view return ( } > -
+
{/* Name Input */}
- {/* Agent Selection */} -
+ {/* Moderator Selection - Dropdown with Customize button */} +
{isDetecting ? ( -
+
+ + Detecting agents... +
) : availableTiles.length === 0 ? ( -
- No agents available. Please install Claude Code, OpenCode, or Codex. +
+ No agents available. Please install Claude Code, OpenCode, Codex, or Factory Droid.
) : ( -
- {availableTiles.map((tile) => { - const isSelected = selectedAgent === tile.id; +
+ {/* Dropdown */} +
+ + +
- return ( -
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 && ( -
- -
- )} - - - {tile.name} + {/* Customize button */} + +
+ )} + + {/* Expandable Configuration Panel */} + {isConfigExpanded && selectedAgentConfig && selectedTile && ( +
+
+ + {selectedTile.name} Configuration + + {hasCustomization && ( +
+ + + Customized - - {/* Customize button */} -
- ); - })} + )} +
+ { + /* 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 + />
)}
{/* SSH Remote Execution - Top Level */} {sshRemotes.length > 0 && ( -
+
([]); 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 ( - -
- -

- Configure {selectedTile.name} -

-
- -
- } - footer={ - - } - > -
- { - /* 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 - /> -
- - ); - } - - // Render grid view return ( } > -
+
{/* Description */}
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.
- {/* Agent Selection */} + {/* Moderator Selection - Dropdown with Customize button */}
{isDetecting ? ( -
+
+ + Detecting agents... +
) : availableTiles.length === 0 ? ( -
- No agents available. Please install Claude Code, OpenCode, or Codex. +
+ No agents available. Please install Claude Code, OpenCode, Codex, or Factory Droid.
) : ( -
- {availableTiles.map((tile) => { - const isSelected = selectedAgent === tile.id; +
+ {/* Dropdown */} +
+ + +
- return ( -
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 && ( -
- -
- )} - - - {tile.name} + {/* Customize button */} + +
+ )} + + {/* Expandable Configuration Panel */} + {isConfigExpanded && selectedAgentConfig && selectedTile && ( +
+
+ + {selectedTile.name} Configuration + + {hasCustomization && ( +
+ + + Customized - - {/* Customize button */} -
- ); - })} + )} +
+ { + /* 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 + />
)}
diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index 65f39ec1..56f1bd9c 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -784,8 +784,8 @@ export function NewInstanceModal({ /> )} {agent.name} - {/* "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') && ( >(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]); diff --git a/src/renderer/components/UsageDashboard/SummaryCards.tsx b/src/renderer/components/UsageDashboard/SummaryCards.tsx index 09ff8fc6..0be91ddf 100644 --- a/src/renderer/components/UsageDashboard/SummaryCards.tsx +++ b/src/renderer/components/UsageDashboard/SummaryCards.tsx @@ -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: , - label: 'Sessions', - value: formatNumber(data.totalSessions), + label: 'Agents', + value: formatNumber(agentCount), }, { icon: , diff --git a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx index f89f6c7d..a17f170f 100644 --- a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx +++ b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx @@ -728,7 +728,7 @@ export function UsageDashboardModal({ data-testid="section-summary-cards" > - +
diff --git a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx index 8c70d674..2505d8dd 100644 --- a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx +++ b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx @@ -1254,8 +1254,8 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX. )} - {/* "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') && (