## CHANGES

- Detect agents on SSH remotes via IPC `agents:detect(sshRemoteId)` 🌐
- Show friendly “Unable to Connect” UI when remote agent detection fails ⚠️
- Re-detect agents instantly when switching SSH remote selection in modals 🔁
- Wizard now persists SSH remote choice across step navigation 🧭
- Remote directory validation now checks existence via `fs.readDir` first 📁
- Git repo checks and Auto Run docs lookup now support SSH remote IDs 🛰️
- Directory screen hides Browse button for remote sessions, adds remote hints 📝
- Agent selection UI revamped: name + location dropdown, clearer header 🧩
- Add `showThinking` option to tabs and merged sessions, default false 🧠
- Export `AGENT_DEFINITIONS` for reuse in remote detection logic 📦
This commit is contained in:
Pedram Amini
2026-01-10 21:58:17 -06:00
parent 342549188a
commit 3222239192
16 changed files with 1548 additions and 232 deletions

View File

@@ -2437,5 +2437,135 @@ describe('NewInstanceModal', () => {
expect(sourceSession.sessionSshRemoteConfig?.remoteId).toBe('remote-1');
expect(sourceSession.sessionSshRemoteConfig?.workingDirOverride).toBe('/custom/path');
});
it('should re-detect agents when SSH remote selection changes', async () => {
const detectMock = vi.mocked(window.maestro.agents.detect);
// Initial detection returns local agents
detectMock.mockResolvedValue([
createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true }),
createAgentConfig({ id: 'opencode', name: 'OpenCode', available: true }),
]);
vi.mocked(window.maestro.sshRemote.getConfigs).mockResolvedValue({
success: true,
configs: [{
id: 'remote-1',
name: 'Test Server',
host: 'test.example.com',
port: 22,
username: 'testuser',
privateKeyPath: '/path/to/key',
enabled: true,
}],
});
render(
<NewInstanceModal
isOpen={true}
onClose={onClose}
onCreate={onCreate}
theme={theme}
existingSessions={[]}
/>
);
// Wait for initial detection
await waitFor(() => {
expect(detectMock).toHaveBeenCalledWith(undefined);
});
// Record the call count after initial detection
const initialCallCount = detectMock.mock.calls.length;
// Mock remote detection (claude available, opencode not)
detectMock.mockResolvedValue([
createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true }),
createAgentConfig({ id: 'opencode', name: 'OpenCode', available: false }),
]);
// Wait for SSH selector to be available
await waitFor(() => {
expect(screen.getByText('SSH Remote Execution')).toBeInTheDocument();
});
// Select the SSH remote
const localButton = screen.getByRole('button', { name: /local execution/i });
const dropdown = localButton.closest('div')?.querySelector('select');
if (dropdown) {
fireEvent.change(dropdown, { target: { value: 'remote-1' } });
}
// Detection should be called again with the SSH remote ID
await waitFor(() => {
expect(detectMock.mock.calls.length).toBeGreaterThan(initialCallCount);
expect(detectMock).toHaveBeenCalledWith('remote-1');
});
});
it('should show connection error when SSH remote is unreachable', async () => {
// Mock detection to return agents with errors when SSH remote is used
vi.mocked(window.maestro.agents.detect).mockImplementation(async (sshRemoteId?: string) => {
if (sshRemoteId === 'unreachable-remote') {
return [
{ ...createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: false }), error: 'Connection refused' },
{ ...createAgentConfig({ id: 'opencode', name: 'OpenCode', available: false }), error: 'Connection refused' },
];
}
return [
createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true }),
createAgentConfig({ id: 'opencode', name: 'OpenCode', available: true }),
];
});
vi.mocked(window.maestro.sshRemote.getConfigs).mockResolvedValue({
success: true,
configs: [{
id: 'unreachable-remote',
name: 'Unreachable Server',
host: 'unreachable.example.com',
port: 22,
username: 'testuser',
privateKeyPath: '/path/to/key',
enabled: true,
}],
});
render(
<NewInstanceModal
isOpen={true}
onClose={onClose}
onCreate={onCreate}
theme={theme}
existingSessions={[]}
/>
);
// Wait for initial load
await waitFor(() => {
expect(screen.getByText('Available')).toBeInTheDocument();
});
// Wait for SSH selector
await waitFor(() => {
expect(screen.getByText('SSH Remote Execution')).toBeInTheDocument();
});
// Select the unreachable SSH remote
const localButton = screen.getByRole('button', { name: /local execution/i });
const dropdown = localButton.closest('div')?.querySelector('select');
if (dropdown) {
fireEvent.change(dropdown, { target: { value: 'unreachable-remote' } });
}
// Wait for connection error to appear
await waitFor(() => {
expect(screen.getByText('Unable to Connect')).toBeInTheDocument();
expect(screen.getByText('Connection refused')).toBeInTheDocument();
});
// Agent list should not be visible
expect(screen.queryByText('Claude Code')).not.toBeInTheDocument();
});
});
});

View File

@@ -269,12 +269,16 @@ const mockMaestro = {
},
fs: {
readFile: vi.fn(),
readDir: vi.fn().mockResolvedValue([]),
},
process: {
spawn: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
},
sshRemote: {
getConfigs: vi.fn().mockResolvedValue({ success: true, configs: [] }),
},
};
// Helper to render with providers
@@ -1431,4 +1435,664 @@ describe('Wizard Integration Tests', () => {
});
});
});
describe('SSH Remote Session Support', () => {
it('should pass sshRemoteId to git.isRepo when validating remote directory', async () => {
function TestWrapper() {
const { openWizard, state, setSelectedAgent, setSessionSshRemoteConfig, goToStep } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
setSelectedAgent('claude-code');
setSessionSshRemoteConfig({
enabled: true,
remoteId: 'my-ssh-remote',
workingDirOverride: '/home/user/project'
});
goToStep('directory-selection');
openWizard();
}
}, [openWizard, state.isOpen, setSelectedAgent, setSessionSshRemoteConfig, goToStep]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
await waitFor(() => {
expect(screen.getByText('Choose Project Directory')).toBeInTheDocument();
});
// Type into the directory input to trigger validation
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '/home/user/project' } });
// Allow debounced validation to run
await act(async () => {
vi.advanceTimersByTime(600);
});
// Verify git.isRepo was called with sshRemoteId
await waitFor(() => {
expect(mockMaestro.git.isRepo).toHaveBeenCalledWith(
'/home/user/project',
'my-ssh-remote'
);
});
});
it('should show SSH remote hint and hide browse button for remote sessions', async () => {
// Mock SSH remote config lookup
mockMaestro.sshRemote.getConfigs.mockResolvedValue({
success: true,
configs: [
{ id: 'my-ssh-remote', name: 'Test Server', host: 'test.example.com' },
],
});
function TestWrapper() {
const { openWizard, state, setSelectedAgent, setSessionSshRemoteConfig, goToStep } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
setSelectedAgent('claude-code');
setSessionSshRemoteConfig({
enabled: true,
remoteId: 'my-ssh-remote',
});
goToStep('directory-selection');
openWizard();
}
}, [openWizard, state.isOpen, setSelectedAgent, setSessionSshRemoteConfig, goToStep]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
await waitFor(() => {
expect(screen.getByText('Choose Project Directory')).toBeInTheDocument();
});
// Browse button should be hidden (not just disabled)
expect(screen.queryByRole('button', { name: /browse/i })).not.toBeInTheDocument();
// SSH hint should be visible with server name
await waitFor(() => {
expect(screen.getByText(/Test Server/)).toBeInTheDocument();
});
expect(screen.getByText(/path will be validated as you type/)).toBeInTheDocument();
// Placeholder should mention the remote host
const input = screen.getByLabelText(/project directory/i);
expect(input).toHaveAttribute('placeholder', expect.stringContaining('Test Server'));
});
it('should show directory not found error when remote path does not exist', async () => {
// Mock SSH remote config lookup
mockMaestro.sshRemote.getConfigs.mockResolvedValue({
success: true,
configs: [
{ id: 'my-ssh-remote', name: 'Test Server', host: 'test.example.com' },
],
});
// Mock fs.readDir to throw an error (directory doesn't exist)
mockMaestro.fs.readDir.mockRejectedValue(new Error('No such file or directory'));
function TestWrapper() {
const { openWizard, state, setSelectedAgent, setSessionSshRemoteConfig, goToStep } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
setSelectedAgent('claude-code');
setSessionSshRemoteConfig({
enabled: true,
remoteId: 'my-ssh-remote',
});
goToStep('directory-selection');
openWizard();
}
}, [openWizard, state.isOpen, setSelectedAgent, setSessionSshRemoteConfig, goToStep]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
await waitFor(() => {
expect(screen.getByText('Choose Project Directory')).toBeInTheDocument();
});
// Type a path that doesn't exist
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '/nonexistent/path' } });
// Allow debounced validation to run
await act(async () => {
vi.advanceTimersByTime(600);
});
// Should show directory not found error
await waitFor(() => {
expect(screen.getByText('Directory not found. Please check the path exists.')).toBeInTheDocument();
});
// Should NOT show "Regular Directory" status
expect(screen.queryByText('Regular Directory')).not.toBeInTheDocument();
// Reset mock for subsequent tests
mockMaestro.fs.readDir.mockResolvedValue([]);
});
it('should pass sshRemoteId to autorun.listDocs when checking for existing docs', async () => {
function TestWrapper() {
const { openWizard, state, setSelectedAgent, setSessionSshRemoteConfig, goToStep } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
setSelectedAgent('claude-code');
setSessionSshRemoteConfig({
enabled: true,
remoteId: 'my-ssh-remote',
});
goToStep('directory-selection');
openWizard();
}
}, [openWizard, state.isOpen, setSelectedAgent, setSessionSshRemoteConfig, goToStep]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
await waitFor(() => {
expect(screen.getByText('Choose Project Directory')).toBeInTheDocument();
});
// Type into the directory input to trigger validation
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '/home/user/project' } });
// Allow debounced validation to run
await act(async () => {
vi.advanceTimersByTime(600);
});
// Verify autorun.listDocs was called with sshRemoteId
await waitFor(() => {
expect(mockMaestro.autorun.listDocs).toHaveBeenCalledWith(
'/home/user/project/Auto Run Docs',
'my-ssh-remote'
);
});
});
it('should pass sshRemoteId to agents.detect when SSH remote is configured', async () => {
// Reset the mock to track calls
mockMaestro.agents.detect.mockClear();
mockMaestro.agents.detect.mockResolvedValue(mockAgents);
function TestWrapper() {
const { openWizard, state, setSessionSshRemoteConfig } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
// Set SSH remote config before opening wizard at agent selection screen
setSessionSshRemoteConfig({
enabled: true,
remoteId: 'my-ssh-remote',
});
openWizard();
}
}, [openWizard, state.isOpen, setSessionSshRemoteConfig]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
// Wait for wizard to be open at agent selection screen
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Wait for agent detection to complete
await waitFor(() => {
// Agent detection should be called with the SSH remote ID
expect(mockMaestro.agents.detect).toHaveBeenCalledWith('my-ssh-remote');
});
});
it('should re-detect agents when SSH remote is selected from dropdown', async () => {
// Reset the mock to track calls
mockMaestro.agents.detect.mockClear();
mockMaestro.agents.detect.mockResolvedValue(mockAgents);
// Mock SSH remotes available for selection
mockMaestro.sshRemote.getConfigs.mockResolvedValue({
success: true,
configs: [
{ id: 'remote-1', name: 'Remote Server 1', host: 'server1.example.com' },
{ id: 'remote-2', name: 'Remote Server 2', host: 'server2.example.com' },
],
});
function TestWrapper() {
const { openWizard, state } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
openWizard();
}
}, [openWizard, state.isOpen]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
// Wait for wizard to be open at agent selection screen
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Initial detection should be called without SSH remote ID
await waitFor(() => {
expect(mockMaestro.agents.detect).toHaveBeenCalledWith(undefined);
});
// Wait for SSH remotes dropdown to appear
await waitFor(() => {
expect(screen.getByLabelText('Agent location')).toBeInTheDocument();
});
// Select a remote from the dropdown
const dropdown = screen.getByLabelText('Agent location');
fireEvent.change(dropdown, { target: { value: 'remote-1' } });
// Detection should be called again with the SSH remote ID
await waitFor(() => {
expect(mockMaestro.agents.detect).toHaveBeenCalledWith('remote-1');
});
});
it('should detect agents without sshRemoteId when SSH remote is not configured', async () => {
// Reset the mock to track calls
mockMaestro.agents.detect.mockClear();
mockMaestro.agents.detect.mockResolvedValue(mockAgents);
function TestWrapper() {
const { openWizard, state } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
// Open wizard without SSH remote config
openWizard();
}
}, [openWizard, state.isOpen]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
// Wait for wizard to be open at agent selection screen
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Wait for agent detection to complete
await waitFor(() => {
// Agent detection should be called without SSH remote ID (undefined)
expect(mockMaestro.agents.detect).toHaveBeenCalledWith(undefined);
});
});
it('should show connection error message when SSH remote is unreachable', async () => {
// Reset the mock to track calls
mockMaestro.agents.detect.mockClear();
// Mock SSH remotes available for selection
mockMaestro.sshRemote.getConfigs.mockResolvedValue({
success: true,
configs: [
{ id: 'unreachable-remote', name: 'Unreachable Server', host: 'unreachable.example.com' },
],
});
// Mock agents.detect to return agents with connection errors
mockMaestro.agents.detect.mockImplementation((sshRemoteId?: string) => {
if (sshRemoteId === 'unreachable-remote') {
// Return all agents as unavailable with error
return Promise.resolve([
{
id: 'claude-code',
name: 'Claude Code',
available: false,
hidden: false,
error: 'Connection timed out',
},
{
id: 'codex',
name: 'Codex',
available: false,
hidden: false,
error: 'Connection timed out',
},
]);
}
return Promise.resolve(mockAgents);
});
function TestWrapper() {
const { openWizard, state } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
openWizard();
}
}, [openWizard, state.isOpen]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
// Wait for wizard to be open at agent selection screen
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Wait for SSH remotes dropdown to appear
await waitFor(() => {
expect(screen.getByLabelText('Agent location')).toBeInTheDocument();
});
// Select the unreachable remote from the dropdown
const dropdown = screen.getByLabelText('Agent location');
fireEvent.change(dropdown, { target: { value: 'unreachable-remote' } });
// Wait for connection error message to appear
await waitFor(() => {
expect(screen.getByText('Unable to Connect')).toBeInTheDocument();
});
// Error message should be displayed
expect(screen.getByText('Connection timed out')).toBeInTheDocument();
expect(screen.getByText(/Please select a different remote host/)).toBeInTheDocument();
});
it('should recover from connection error when switching back to local', async () => {
// Reset the mock to track calls
mockMaestro.agents.detect.mockClear();
// Mock SSH remotes available for selection
mockMaestro.sshRemote.getConfigs.mockResolvedValue({
success: true,
configs: [
{ id: 'unreachable-remote', name: 'Unreachable Server', host: 'unreachable.example.com' },
],
});
// Mock agents.detect to return errors for remote, success for local
mockMaestro.agents.detect.mockImplementation((sshRemoteId?: string) => {
if (sshRemoteId === 'unreachable-remote') {
return Promise.resolve([
{
id: 'claude-code',
name: 'Claude Code',
available: false,
hidden: false,
error: 'Connection refused',
},
]);
}
return Promise.resolve(mockAgents);
});
function TestWrapper() {
const { openWizard, state } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
openWizard();
}
}, [openWizard, state.isOpen]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
// Wait for wizard to be open at agent selection screen
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Wait for SSH remotes dropdown to appear
await waitFor(() => {
expect(screen.getByLabelText('Agent location')).toBeInTheDocument();
});
// Select the unreachable remote
const dropdown = screen.getByLabelText('Agent location');
fireEvent.change(dropdown, { target: { value: 'unreachable-remote' } });
// Wait for connection error
await waitFor(() => {
expect(screen.getByText('Unable to Connect')).toBeInTheDocument();
});
// Switch back to Local Machine
fireEvent.change(dropdown, { target: { value: '' } });
// Wait for error to clear and agents to appear
await waitFor(() => {
expect(screen.queryByText('Unable to Connect')).not.toBeInTheDocument();
});
// Agent tiles should be visible again
await waitFor(() => {
expect(screen.getByRole('button', { name: /claude code/i })).toBeInTheDocument();
});
});
it('should persist SSH remote selection when navigating between wizard steps', async () => {
// Reset the mock to track calls
mockMaestro.agents.detect.mockClear();
mockMaestro.agents.detect.mockResolvedValue(mockAgents);
// Mock SSH remotes available for selection
mockMaestro.sshRemote.getConfigs.mockResolvedValue({
success: true,
configs: [
{ id: 'test-remote', name: 'Test Server', host: 'test.example.com' },
],
});
function TestWrapper() {
const { openWizard, state, setSelectedAgent, setAgentName, nextStep, previousStep } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
openWizard();
}
}, [openWizard, state.isOpen]);
return (
<>
<div data-testid="current-step">{state.currentStep}</div>
<div data-testid="ssh-enabled">
{state.sessionSshRemoteConfig?.enabled ? 'yes' : 'no'}
</div>
<div data-testid="ssh-remote-id">
{state.sessionSshRemoteConfig?.remoteId || 'none'}
</div>
<button
onClick={() => {
setSelectedAgent('claude-code');
setAgentName('Test Project');
nextStep();
}}
data-testid="next-step"
>
Next Step
</button>
<button onClick={() => previousStep()} data-testid="prev-step">
Previous Step
</button>
{state.isOpen && <MaestroWizard theme={mockTheme} />}
</>
);
}
renderWithProviders(<TestWrapper />);
// Wait for wizard to be open at agent selection screen
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Wait for SSH remotes dropdown to appear
await waitFor(() => {
expect(screen.getByLabelText('Agent location')).toBeInTheDocument();
});
// Select a remote from the dropdown
const dropdown = screen.getByLabelText('Agent location');
fireEvent.change(dropdown, { target: { value: 'test-remote' } });
// Wait for SSH config to be persisted to wizard context
await waitFor(() => {
expect(screen.getByTestId('ssh-enabled')).toHaveTextContent('yes');
expect(screen.getByTestId('ssh-remote-id')).toHaveTextContent('test-remote');
});
// Wait for agent tiles to load (re-detection for SSH remote)
await waitFor(() => {
expect(screen.getByRole('button', { name: /claude code/i })).toBeInTheDocument();
});
// Navigate to the next step (directory selection)
fireEvent.click(screen.getByTestId('next-step'));
// Verify we're on step 2
await waitFor(() => {
expect(screen.getByTestId('current-step')).toHaveTextContent('directory-selection');
});
// SSH config should still be persisted
expect(screen.getByTestId('ssh-enabled')).toHaveTextContent('yes');
expect(screen.getByTestId('ssh-remote-id')).toHaveTextContent('test-remote');
// Navigate back to step 1
fireEvent.click(screen.getByTestId('prev-step'));
// Verify we're back on step 1
await waitFor(() => {
expect(screen.getByTestId('current-step')).toHaveTextContent('agent-selection');
});
// SSH config should STILL be persisted
expect(screen.getByTestId('ssh-enabled')).toHaveTextContent('yes');
expect(screen.getByTestId('ssh-remote-id')).toHaveTextContent('test-remote');
// The dropdown should still show the selected remote
await waitFor(() => {
const locationDropdown = screen.getByLabelText('Agent location');
expect(locationDropdown).toHaveValue('test-remote');
});
});
it('should NOT re-detect agents when selecting different provider tiles', async () => {
// Reset the mock to track calls
mockMaestro.agents.detect.mockClear();
// Mock multiple available agents
const multipleAgents = [
{
id: 'claude-code',
name: 'Claude Code',
available: true,
hidden: false,
capabilities: {},
},
{
id: 'codex',
name: 'Codex',
available: true,
hidden: false,
capabilities: {},
},
{
id: 'opencode',
name: 'OpenCode',
available: true,
hidden: false,
capabilities: {},
},
];
mockMaestro.agents.detect.mockResolvedValue(multipleAgents);
// No SSH remotes for this test
mockMaestro.sshRemote.getConfigs.mockResolvedValue({
success: true,
configs: [],
});
function TestWrapper() {
const { openWizard, state } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
openWizard();
}
}, [openWizard, state.isOpen]);
return state.isOpen ? <MaestroWizard theme={mockTheme} /> : null;
}
renderWithProviders(<TestWrapper />);
// Wait for wizard to be open at agent selection screen
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Wait for initial agent detection to complete
await waitFor(() => {
expect(mockMaestro.agents.detect).toHaveBeenCalledTimes(1);
});
// Wait for agent tiles to be visible
await waitFor(() => {
expect(screen.getByRole('button', { name: /claude code/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /codex/i })).toBeInTheDocument();
});
// Record the call count after initial detection
const initialCallCount = mockMaestro.agents.detect.mock.calls.length;
// Click on a different agent tile (Codex)
const codexTile = screen.getByRole('button', { name: /codex/i });
fireEvent.click(codexTile);
// Click on another agent tile (OpenCode)
const opencodeTile = screen.getByRole('button', { name: /opencode/i });
fireEvent.click(opencodeTile);
// Click back to Claude Code
const claudeTile = screen.getByRole('button', { name: /claude code/i });
fireEvent.click(claudeTile);
// Wait a bit to ensure no async detection was triggered
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
});
// Detection should NOT have been called again
expect(mockMaestro.agents.detect.mock.calls.length).toBe(initialCallCount);
});
});
});

View File

@@ -254,7 +254,7 @@ describe('Wizard Keyboard Navigation', () => {
});
// Get the container with keyboard handler
const container = screen.getByText('Choose Your Provider').closest('div[tabindex]');
const container = screen.getByText('Create a Maestro Agent').closest('div[tabindex]');
expect(container).toBeInTheDocument();
// When only one agent is available, focus goes to name field, not tiles
@@ -288,13 +288,13 @@ describe('Wizard Keyboard Navigation', () => {
expect(screen.queryByText('Detecting available agents...')).not.toBeInTheDocument();
});
const container = screen.getByText('Choose Your Provider').closest('div[tabindex]');
const container = screen.getByText('Create a Maestro Agent').closest('div[tabindex]');
// Press Tab to move to name field
fireEvent.keyDown(container!, { key: 'Tab' });
await waitFor(() => {
const nameInput = screen.getByLabelText('Name Your Agent');
const nameInput = screen.getByLabelText('Agent name');
expect(nameInput).toHaveFocus();
});
});
@@ -307,12 +307,12 @@ describe('Wizard Keyboard Navigation', () => {
});
// Focus the name input
const nameInput = screen.getByLabelText('Name Your Agent');
const nameInput = screen.getByLabelText('Agent name');
nameInput.focus();
expect(nameInput).toHaveFocus();
// Get the container with keyboard handler
const container = screen.getByText('Choose Your Provider').closest('div[tabindex]');
const container = screen.getByText('Create a Maestro Agent').closest('div[tabindex]');
// Press Shift+Tab to go back to tiles
// Note: This triggers the keyboard handler but disabled buttons can't receive focus
@@ -330,7 +330,7 @@ describe('Wizard Keyboard Navigation', () => {
expect(screen.queryByText('Detecting available agents...')).not.toBeInTheDocument();
});
const container = screen.getByText('Choose Your Provider').closest('div[tabindex]');
const container = screen.getByText('Create a Maestro Agent').closest('div[tabindex]');
const claudeTile = screen.getByRole('button', { name: /claude code/i });
// Claude Code should be auto-selected (available agent)

View File

@@ -295,11 +295,11 @@ describe('Wizard Theme Styles', () => {
// Wait for agent detection to complete
await vi.waitFor(() => {
expect(screen.getByText('Choose Your Provider')).toBeInTheDocument();
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
});
// Check that theme colors are applied to key elements
const header = screen.getByText('Choose Your Provider');
const header = screen.getByText('Create a Maestro Agent');
expect(header).toHaveStyle({ color: theme.colors.textMain });
});

View File

@@ -184,6 +184,22 @@ describe('tabHelpers', () => {
expect(result.tab.saveToHistory).toBe(true);
});
it('creates a tab with showThinking option', () => {
const session = createMockSession({ aiTabs: [] });
// Default should be false
const defaultResult = createTab(session);
expect(defaultResult.tab.showThinking).toBe(false);
// Explicit true
const trueResult = createTab(session, { showThinking: true });
expect(trueResult.tab.showThinking).toBe(true);
// Explicit false
const falseResult = createTab(session, { showThinking: false });
expect(falseResult.tab.showThinking).toBe(false);
});
it('appends tab to existing tabs', () => {
const existingTab = createMockTab({ id: 'existing-tab' });
const session = createMockSession({
@@ -1188,6 +1204,38 @@ describe('tabHelpers', () => {
expect(sessionWithoutHistory.aiTabs[0].saveToHistory).toBe(false);
});
it('creates a session with showThinking option', () => {
const { session: sessionWithThinking } = createMergedSession({
name: 'With Thinking',
projectRoot: '/project',
toolType: 'claude-code',
mergedLogs: [],
showThinking: true,
});
expect(sessionWithThinking.aiTabs[0].showThinking).toBe(true);
const { session: sessionWithoutThinking } = createMergedSession({
name: 'Without Thinking',
projectRoot: '/project',
toolType: 'claude-code',
mergedLogs: [],
showThinking: false,
});
expect(sessionWithoutThinking.aiTabs[0].showThinking).toBe(false);
// Default should be false
const { session: sessionDefault } = createMergedSession({
name: 'Default Thinking',
projectRoot: '/project',
toolType: 'claude-code',
mergedLogs: [],
});
expect(sessionDefault.aiTabs[0].showThinking).toBe(false);
});
it('creates a session with terminal toolType sets correct inputMode', () => {
const { session } = createMergedSession({
name: 'Terminal Session',

View File

@@ -50,7 +50,7 @@ export interface AgentConfig {
defaultEnvVars?: Record<string, string>; // Default environment variables for this agent (merged with user customEnvVars)
}
const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'>[] = [
export const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'>[] = [
{
id: 'terminal',
name: 'Terminal',

View File

@@ -1082,6 +1082,7 @@ function setupIpcHandlers() {
registerAgentsHandlers({
getAgentDetector: () => agentDetector,
agentConfigsStore,
settingsStore: store,
});
// Process management operations - extracted to src/main/ipc/handlers/process.ts

View File

@@ -1,10 +1,13 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
import { AgentDetector } from '../../agent-detector';
import { AgentDetector, AGENT_DEFINITIONS } from '../../agent-detector';
import { getAgentCapabilities } from '../../agent-capabilities';
import { execFileNoThrow } from '../../utils/execFile';
import { logger } from '../../utils/logger';
import { withIpcErrorLogging, requireDependency, CreateHandlerOptions } from '../../utils/ipcHandler';
import { buildSshCommand, RemoteCommandOptions } from '../../utils/ssh-command-builder';
import { SshRemoteConfig } from '../../../shared/types';
import { MaestroSettings } from './persistence';
const LOG_CONTEXT = '[AgentDetector]';
const CONFIG_LOG_CONTEXT = '[AgentConfig]';
@@ -28,6 +31,33 @@ interface AgentConfigsData {
export interface AgentsHandlerDependencies {
getAgentDetector: () => AgentDetector | null;
agentConfigsStore: Store<AgentConfigsData>;
/** The settings store (MaestroSettings) - required for SSH remote lookup */
settingsStore?: Store<MaestroSettings>;
}
/**
* Get SSH remote configuration by ID from the settings store.
* Returns undefined if not found or store not provided.
* Note: Does not check the 'enabled' flag - if user explicitly selects a remote, we should try to use it.
*/
function getSshRemoteById(
store: Store<MaestroSettings> | undefined,
sshRemoteId: string
): SshRemoteConfig | undefined {
if (!store) {
logger.warn(`${LOG_CONTEXT} Settings store not available for SSH remote lookup`, LOG_CONTEXT);
return undefined;
}
const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[];
const config = sshRemotes.find((r) => r.id === sshRemoteId);
if (!config) {
logger.warn(`${LOG_CONTEXT} SSH remote not found: ${sshRemoteId}`, LOG_CONTEXT);
return undefined;
}
return config;
}
/**
@@ -58,6 +88,89 @@ function stripAgentFunctions(agent: any) {
};
}
/**
* Detect agents on a remote SSH host.
* Uses 'which' command over SSH to check for agent binaries.
* Includes a timeout to handle unreachable hosts gracefully.
*/
async function detectAgentsRemote(sshRemote: SshRemoteConfig): Promise<any[]> {
const agents = [];
const SSH_TIMEOUT_MS = 10000; // 10 second timeout per agent check
// Track if we've had any successful connection to detect unreachable hosts
let connectionSucceeded = false;
let connectionError: string | undefined;
for (const agentDef of AGENT_DEFINITIONS) {
// Build SSH command to check for the binary using 'which'
const remoteOptions: RemoteCommandOptions = {
command: 'which',
args: [agentDef.binaryName],
};
try {
const sshCommand = await buildSshCommand(sshRemote, remoteOptions);
// Execute with timeout
const resultPromise = execFileNoThrow(sshCommand.command, sshCommand.args);
const timeoutPromise = new Promise<{ exitCode: number; stdout: string; stderr: string }>((_, reject) => {
setTimeout(() => reject(new Error(`SSH connection timed out after ${SSH_TIMEOUT_MS / 1000}s`)), SSH_TIMEOUT_MS);
});
const result = await Promise.race([resultPromise, timeoutPromise]);
// Check for SSH connection errors in stderr
if (result.stderr && (
result.stderr.includes('Connection refused') ||
result.stderr.includes('Connection timed out') ||
result.stderr.includes('No route to host') ||
result.stderr.includes('Could not resolve hostname') ||
result.stderr.includes('Permission denied')
)) {
connectionError = result.stderr.trim().split('\n')[0];
logger.warn(`SSH connection error for ${sshRemote.host}: ${connectionError}`, LOG_CONTEXT);
} else if (result.exitCode === 0 || result.exitCode === 1) {
// Exit code 0 = found, 1 = not found (both indicate successful connection)
connectionSucceeded = true;
}
const available = result.exitCode === 0 && result.stdout.trim().length > 0;
const path = available ? result.stdout.trim().split('\n')[0] : undefined;
if (available) {
logger.info(`Agent "${agentDef.name}" found on remote at: ${path}`, LOG_CONTEXT);
} else {
logger.debug(`Agent "${agentDef.name}" not found on remote`, LOG_CONTEXT);
}
agents.push({
...agentDef,
available,
path,
capabilities: getAgentCapabilities(agentDef.id),
error: connectionError,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
connectionError = errorMessage;
logger.warn(`Failed to check agent "${agentDef.name}" on remote: ${errorMessage}`, LOG_CONTEXT);
agents.push({
...agentDef,
available: false,
capabilities: getAgentCapabilities(agentDef.id),
error: `Failed to connect: ${errorMessage}`,
});
}
}
// If no connection succeeded and we have an error, log a summary
if (!connectionSucceeded && connectionError) {
logger.error(`Failed to connect to SSH remote ${sshRemote.host}: ${connectionError}`, LOG_CONTEXT);
}
return agents;
}
/**
* Register all Agent-related IPC handlers.
*
@@ -67,12 +180,35 @@ function stripAgentFunctions(agent: any) {
* - Custom paths: setCustomPath, getCustomPath, getAllCustomPaths
*/
export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
const { getAgentDetector, agentConfigsStore } = deps;
const { getAgentDetector, agentConfigsStore, settingsStore } = deps;
// Detect all available agents
// Detect all available agents (supports SSH remote detection via optional sshRemoteId)
ipcMain.handle(
'agents:detect',
withIpcErrorLogging(handlerOpts('detect'), async () => {
withIpcErrorLogging(handlerOpts('detect'), async (sshRemoteId?: string) => {
// If SSH remote ID provided, detect agents on remote host
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
// Return all agents as unavailable with error info instead of throwing
logger.warn(`SSH remote not found or disabled: ${sshRemoteId}, returning unavailable agents`, LOG_CONTEXT);
return AGENT_DEFINITIONS.map((agentDef) => stripAgentFunctions({
...agentDef,
available: false,
path: undefined,
capabilities: getAgentCapabilities(agentDef.id),
error: `SSH remote configuration not found: ${sshRemoteId}`,
}));
}
logger.info(`Detecting agents on remote host: ${sshConfig.host}`, LOG_CONTEXT);
const agents = await detectAgentsRemote(sshConfig);
logger.info(`Detected ${agents.filter((a: any) => a.available).length} agents on remote`, LOG_CONTEXT, {
agents: agents.map((a: any) => a.id),
});
return agents.map(stripAgentFunctions);
}
// Local detection
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
logger.info('Detecting available agents', LOG_CONTEXT);
const agents = await agentDetector.detectAgents();

View File

@@ -129,6 +129,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
registerAgentsHandlers({
getAgentDetector: deps.getAgentDetector,
agentConfigsStore: deps.agentConfigsStore,
settingsStore: deps.settingsStore,
});
registerProcessHandlers({
getProcessManager: deps.getProcessManager,

View File

@@ -555,8 +555,8 @@ contextBridge.exposeInMainWorld('maestro', {
// Agent API
agents: {
detect: () => ipcRenderer.invoke('agents:detect'),
refresh: (agentId?: string) => ipcRenderer.invoke('agents:refresh', agentId),
detect: (sshRemoteId?: string) => ipcRenderer.invoke('agents:detect', sshRemoteId),
refresh: (agentId?: string, sshRemoteId?: string) => ipcRenderer.invoke('agents:refresh', agentId, sshRemoteId),
get: (agentId: string) => ipcRenderer.invoke('agents:get', agentId),
getCapabilities: (agentId: string) => ipcRenderer.invoke('agents:getCapabilities', agentId),
getConfig: (agentId: string) => ipcRenderer.invoke('agents:getConfig', agentId),
@@ -1968,7 +1968,8 @@ export interface MaestroAPI {
stopServer: () => Promise<{ success: boolean }>;
};
agents: {
detect: () => Promise<AgentConfig[]>;
detect: (sshRemoteId?: string) => Promise<AgentConfig[]>;
refresh: (agentId?: string, sshRemoteId?: string) => Promise<{ agents: AgentConfig[]; debugInfo: any }>;
get: (agentId: string) => Promise<AgentConfig | null>;
getCapabilities: (agentId: string) => Promise<AgentCapabilities>;
getConfig: (agentId: string) => Promise<Record<string, any>>;

View File

@@ -1185,7 +1185,8 @@ function MaestroConsoleInner() {
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: true
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
// Fetch git info (via SSH for remote sessions)
@@ -5281,7 +5282,8 @@ You are taking over this conversation. Based on the context above, provide a bri
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: defaultSaveToHistory
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
// Get SSH remote ID for remote git operations
@@ -5453,7 +5455,8 @@ You are taking over this conversation. Based on the context above, provide a bri
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: defaultSaveToHistory
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
// Fetch git info (with SSH support)
@@ -6557,7 +6560,8 @@ You are taking over this conversation. Based on the context above, provide a bri
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: defaultSaveToHistory
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
const newSession: Session = {
@@ -6697,7 +6701,8 @@ You are taking over this conversation. Based on the context above, provide a bri
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: defaultSaveToHistory
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
// Build Auto Run folder path
@@ -8472,7 +8477,8 @@ You are taking over this conversation. Based on the context above, provide a bri
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: true
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
// Fetch git info for this subdirectory (with SSH support)
@@ -8615,7 +8621,8 @@ You are taking over this conversation. Based on the context above, provide a bri
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: defaultSaveToHistory
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
// Fetch git info for the worktree (pass SSH remote ID for remote sessions)
@@ -8749,7 +8756,8 @@ You are taking over this conversation. Based on the context above, provide a bri
stagedImages: [],
createdAt: Date.now(),
state: 'idle',
saveToHistory: defaultSaveToHistory
saveToHistory: defaultSaveToHistory,
showThinking: defaultShowThinking
};
// Fetch git info for the worktree (pass SSH remote ID for remote sessions)

View File

@@ -95,6 +95,8 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
isDirectory: boolean;
error?: string;
}>({ checking: false, valid: false, isDirectory: false });
// SSH connection error state - shown when we can't connect to the selected remote
const [sshConnectionError, setSshConnectionError] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
@@ -205,10 +207,32 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
}, [workingDir, isSshEnabled, selectedAgent, agentSshRemoteConfigs]);
// Define handlers first before they're used in effects
const loadAgents = async (source?: Session) => {
const loadAgents = async (source?: Session, sshRemoteId?: string) => {
setLoading(true);
setSshConnectionError(null);
try {
const detectedAgents = await window.maestro.agents.detect();
const detectedAgents = await window.maestro.agents.detect(sshRemoteId);
// Check if all agents have connection errors (indicates SSH connection failure)
if (sshRemoteId) {
const connectionErrors = detectedAgents
.filter((a: AgentConfig) => !a.hidden)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((a: any) => a.error)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((a: any) => a.error);
const allHaveErrors = connectionErrors.length > 0 &&
detectedAgents.filter((a: AgentConfig) => !a.hidden)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.every((a: any) => a.error || !a.available);
if (allHaveErrors && connectionErrors.length > 0) {
setSshConnectionError(connectionErrors[0]);
setLoading(false);
return;
}
}
setAgents(detectedAgents);
// Per-agent config (path, args, env vars) starts empty - each agent gets its own config
@@ -505,6 +529,46 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
}
}, [isOpen]);
// Track the current SSH remote ID for re-detection
// Uses _pending_ key when no agent is selected, which is the shared SSH config
const currentSshRemoteId = useMemo(() => {
const config = agentSshRemoteConfigs['_pending_'] || agentSshRemoteConfigs[selectedAgent];
return config?.enabled ? config.remoteId : null;
}, [agentSshRemoteConfigs, selectedAgent]);
// Track initial load to avoid re-running on first mount
const initialLoadDoneRef = useRef(false);
const lastSshRemoteIdRef = useRef<string | null | undefined>(undefined);
// Re-detect agents when SSH remote selection changes
// This allows users to see which agents are available on remote vs local
useEffect(() => {
// Skip if modal not open
if (!isOpen) {
initialLoadDoneRef.current = false;
lastSshRemoteIdRef.current = undefined;
return;
}
// Skip the initial load (handled by the isOpen effect above)
if (!initialLoadDoneRef.current) {
initialLoadDoneRef.current = true;
lastSshRemoteIdRef.current = currentSshRemoteId;
return;
}
// Only re-detect if the SSH remote ID actually changed
if (lastSshRemoteIdRef.current === currentSshRemoteId) {
return;
}
lastSshRemoteIdRef.current = currentSshRemoteId;
// Re-run agent detection with the new SSH remote ID
loadAgents(undefined, currentSshRemoteId ?? undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, currentSshRemoteId]);
if (!isOpen) return null;
return (
@@ -547,6 +611,38 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
</label>
{loading ? (
<div className="text-sm opacity-50">Loading agents...</div>
) : sshConnectionError ? (
/* SSH Connection Error State */
<div
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 text-center"
style={{
backgroundColor: `${theme.colors.error}10`,
borderColor: theme.colors.error,
}}
>
<AlertTriangle
className="w-10 h-10 mb-3"
style={{ color: theme.colors.error }}
/>
<h4
className="text-base font-semibold mb-2"
style={{ color: theme.colors.textMain }}
>
Unable to Connect
</h4>
<p
className="text-sm mb-3"
style={{ color: theme.colors.textDim }}
>
{sshConnectionError}
</p>
<p
className="text-xs"
style={{ color: theme.colors.textDim }}
>
Select a different remote host or switch to Local Execution.
</p>
</div>
) : (
<div className="space-y-1">
{sortedAgents.map((agent) => {

View File

@@ -13,13 +13,12 @@
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { Check, X, Settings, ArrowLeft } from 'lucide-react';
import { Check, X, Settings, ArrowLeft, AlertTriangle } from 'lucide-react';
import type { Theme, AgentConfig } from '../../../types';
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../../../shared/types';
import { useWizard } from '../WizardContext';
import { ScreenReaderAnnouncement } from '../ScreenReaderAnnouncement';
import { AgentConfigPanel } from '../../shared/AgentConfigPanel';
import { SshRemoteSelector } from '../../shared/SshRemoteSelector';
interface AgentSelectionScreenProps {
theme: Theme;
@@ -322,24 +321,73 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
const [refreshingAgent, setRefreshingAgent] = useState(false);
// SSH Remote configuration state
// Initialize from wizard context if already set (e.g., when SSH was configured before opening wizard)
const [sshRemotes, setSshRemotes] = useState<SshRemoteConfig[]>([]);
const [sshRemoteConfig, setSshRemoteConfig] = useState<AgentSshRemoteConfig | undefined>(undefined);
const [sshRemoteConfig, setSshRemoteConfig] = useState<AgentSshRemoteConfig | undefined>(
state.sessionSshRemoteConfig?.enabled
? { enabled: true, remoteId: state.sessionSshRemoteConfig.remoteId ?? null, workingDirOverride: state.sessionSshRemoteConfig.workingDirOverride }
: undefined
);
// Sync local sshRemoteConfig state with wizard context when navigating back to this screen
// This ensures the dropdown reflects the saved SSH config when returning from later steps
useEffect(() => {
if (state.sessionSshRemoteConfig?.enabled && state.sessionSshRemoteConfig?.remoteId) {
setSshRemoteConfig({
enabled: true,
remoteId: state.sessionSshRemoteConfig.remoteId,
workingDirOverride: state.sessionSshRemoteConfig.workingDirOverride,
});
} else if (state.sessionSshRemoteConfig?.enabled === false) {
setSshRemoteConfig(undefined);
}
}, [state.sessionSshRemoteConfig?.enabled, state.sessionSshRemoteConfig?.remoteId, state.sessionSshRemoteConfig?.workingDirOverride]);
// SSH connection error state - shown when we can't connect to the selected remote
const [sshConnectionError, setSshConnectionError] = useState<string | null>(null);
// Refs
const containerRef = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const tileRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Detect available agents on mount
// Detect available agents on mount and when SSH remote config changes
// Note: We use a ref to track selectedAgent to avoid re-running detection when user clicks tiles
const selectedAgentRef = useRef(state.selectedAgent);
selectedAgentRef.current = state.selectedAgent;
useEffect(() => {
let mounted = true;
async function detectAgents() {
// Set detecting state when re-detecting due to SSH remote change
setIsDetecting(true);
// Clear any previous connection error
setSshConnectionError(null);
try {
const agents = await window.maestro.agents.detect();
// Pass SSH remote ID if configured for remote agent detection
const sshRemoteId = sshRemoteConfig?.enabled ? sshRemoteConfig.remoteId : undefined;
const agents = await window.maestro.agents.detect(sshRemoteId ?? undefined);
if (mounted) {
// Filter out hidden agents (like terminal)
const visibleAgents = agents.filter((a: AgentConfig) => !a.hidden);
// Check if all agents have connection errors (indicates SSH connection failure)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const connectionErrors = visibleAgents.filter((a: any) => a.error).map((a: any) => a.error);
const allHaveErrors = sshRemoteConfig?.enabled && connectionErrors.length > 0 && visibleAgents.every((a: any) => a.error || !a.available);
if (allHaveErrors && connectionErrors.length > 0) {
// Extract the first meaningful error message
const errorMsg = connectionErrors[0];
setSshConnectionError(errorMsg);
setAnnouncement(`Unable to connect to remote host: ${errorMsg}`);
setAnnouncementKey((prev) => prev + 1);
setIsDetecting(false);
return;
}
setDetectedAgents(visibleAgents);
setAvailableAgents(visibleAgents);
@@ -347,25 +395,29 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
const availableCount = visibleAgents.filter((a: AgentConfig) => a.available).length;
const totalCount = visibleAgents.length;
// Build announcement with SSH remote context
const remoteContext = sshRemoteConfig?.enabled ? ' on remote host' : '';
// Auto-select Claude Code if it's available and nothing is selected
if (!state.selectedAgent) {
// Use ref to get current value without adding to dependencies
if (!selectedAgentRef.current) {
const claudeCode = visibleAgents.find((a: AgentConfig) => a.id === 'claude-code' && a.available);
if (claudeCode) {
setSelectedAgent('claude-code');
// Announce detection complete with auto-selection
setAnnouncement(
`Agent detection complete. ${availableCount} of ${totalCount} agents available. Claude Code automatically selected.`
`Agent detection complete${remoteContext}. ${availableCount} of ${totalCount} agents available. Claude Code automatically selected.`
);
} else {
// Announce detection complete without auto-selection
setAnnouncement(
`Agent detection complete. ${availableCount} of ${totalCount} agents available.`
`Agent detection complete${remoteContext}. ${availableCount} of ${totalCount} agents available.`
);
}
} else {
// Announce detection complete (agent already selected from restore)
setAnnouncement(
`Agent detection complete. ${availableCount} of ${totalCount} agents available.`
`Agent detection complete${remoteContext}. ${availableCount} of ${totalCount} agents available.`
);
}
setAnnouncementKey((prev) => prev + 1);
@@ -375,6 +427,9 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
} catch (error) {
console.error('Failed to detect agents:', error);
if (mounted) {
if (sshRemoteConfig?.enabled) {
setSshConnectionError(error instanceof Error ? error.message : 'Unknown connection error');
}
setAnnouncement('Failed to detect available agents. Please try again.');
setAnnouncementKey((prev) => prev + 1);
setIsDetecting(false);
@@ -384,7 +439,15 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
detectAgents();
// Load SSH remote configurations
return () => { mounted = false; };
// Only re-run detection when SSH remote config changes, not when selected agent changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setAvailableAgents, setSelectedAgent, sshRemoteConfig?.enabled, sshRemoteConfig?.remoteId]);
// Load SSH remote configurations on mount
useEffect(() => {
let mounted = true;
async function loadSshRemotes() {
try {
const configsResult = await window.maestro.sshRemote.getConfigs();
@@ -398,7 +461,7 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
loadSshRemotes();
return () => { mounted = false; };
}, [setAvailableAgents, setSelectedAgent, state.selectedAgent]);
}, []);
// Focus on mount - currently focus name field since only Claude is supported
// TODO: When multiple agents are supported, focus the tiles instead
@@ -817,18 +880,6 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
showBuiltInEnvVars
/>
{/* SSH Remote Execution - at config view level */}
{sshRemotes.length > 0 && (
<div className="mt-3">
<SshRemoteSelector
theme={theme}
sshRemotes={sshRemotes}
sshRemoteConfig={sshRemoteConfig}
onSshRemoteConfigChange={setSshRemoteConfig}
compact
/>
</div>
)}
</div>
</div>
@@ -868,64 +919,173 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
politeness="polite"
/>
{/* Section 1: Header */}
<div className="text-center">
{/* Section 1: Header + Name/Location Row */}
<div className="flex flex-col items-center gap-4">
<h3
className="text-2xl font-semibold mb-2"
className="text-2xl font-semibold"
style={{ color: theme.colors.textMain }}
>
Choose Your Provider
Create a Maestro Agent
</h3>
{/* Name + Location Row */}
<div className="flex items-center gap-3">
<input
ref={nameInputRef}
id="project-name"
type="text"
value={state.agentName}
onChange={(e) => setAgentName(e.target.value)}
onFocus={() => setIsNameFieldFocused(true)}
onBlur={() => setIsNameFieldFocused(false)}
placeholder="Name your agent..."
className="w-64 px-4 py-2 rounded-lg border outline-none transition-all"
style={{
backgroundColor: theme.colors.bgMain,
borderColor: isNameFieldFocused ? theme.colors.accent : theme.colors.border,
color: theme.colors.textMain,
boxShadow: isNameFieldFocused ? `0 0 0 2px ${theme.colors.accent}40` : 'none',
}}
aria-label="Agent name"
/>
{/* SSH Remote Location Dropdown - only shown if remotes are configured */}
{sshRemotes.length > 0 && (
<div className="flex items-center gap-2">
<span
className="text-sm"
style={{ color: theme.colors.textDim }}
>
on
</span>
<select
value={sshRemoteConfig?.enabled ? sshRemoteConfig.remoteId || '' : ''}
onChange={(e) => {
const remoteId = e.target.value;
if (remoteId === '') {
// Local machine selected
setSshRemoteConfig(undefined);
// Also update wizard context immediately
setWizardSessionSshRemoteConfig({ enabled: false, remoteId: null });
} else {
// Remote selected
setSshRemoteConfig({
enabled: true,
remoteId,
});
// Also update wizard context immediately
setWizardSessionSshRemoteConfig({
enabled: true,
remoteId,
});
}
}}
className="px-3 py-2 rounded-lg border outline-none transition-all cursor-pointer"
style={{
backgroundColor: theme.colors.bgMain,
borderColor: theme.colors.border,
color: theme.colors.textMain,
minWidth: '160px',
}}
aria-label="Agent location"
>
<option value="">Local Machine</option>
{sshRemotes.map((remote) => (
<option key={remote.id} value={remote.id}>
{remote.name || remote.host}
</option>
))}
</select>
</div>
)}
</div>
<p
className="text-sm"
style={{ color: theme.colors.textDim }}
>
Select the provider that will power your agent. Use arrow keys to navigate, Enter to select.
Select the provider that will power your agent.
</p>
</div>
{/* Section 2: Agent Grid */}
<div className="flex justify-center">
<div className="grid grid-cols-3 gap-4 max-w-3xl">
{AGENT_TILES.map((tile, index) => {
const isDetected = isAgentAvailable(tile.id);
const isSupported = tile.supported;
const canSelect = isSupported && isDetected;
const isSelected = state.selectedAgent === tile.id;
const isFocused = focusedTileIndex === index && !isNameFieldFocused;
{/* Section 2: Agent Grid or Connection Error */}
{sshConnectionError ? (
/* SSH Connection Error State */
<div className="flex justify-center">
<div
className="flex flex-col items-center justify-center p-8 rounded-xl border-2 max-w-lg text-center"
style={{
backgroundColor: `${theme.colors.error}10`,
borderColor: theme.colors.error,
}}
>
<AlertTriangle
className="w-12 h-12 mb-4"
style={{ color: theme.colors.error }}
/>
<h4
className="text-lg font-semibold mb-2"
style={{ color: theme.colors.textMain }}
>
Unable to Connect
</h4>
<p
className="text-sm mb-4"
style={{ color: theme.colors.textDim }}
>
{sshConnectionError}
</p>
<p
className="text-sm"
style={{ color: theme.colors.textDim }}
>
Please select a different remote host or switch to Local Machine.
</p>
</div>
</div>
) : (
/* Agent Grid */
<div className="flex justify-center">
<div className="grid grid-cols-3 gap-4 max-w-3xl">
{AGENT_TILES.map((tile, index) => {
const isDetected = isAgentAvailable(tile.id);
const isSupported = tile.supported;
const canSelect = isSupported && isDetected;
const isSelected = state.selectedAgent === tile.id;
const isFocused = focusedTileIndex === index && !isNameFieldFocused;
return (
<button
key={tile.id}
ref={(el) => { tileRefs.current[index] = el; }}
onClick={() => handleTileClick(tile, index)}
onFocus={() => {
setFocusedTileIndex(index);
setIsNameFieldFocused(false);
}}
disabled={!canSelect}
className={`
relative flex flex-col items-center justify-center pt-6 px-6 pb-10 rounded-xl
border-2 transition-all duration-200 outline-none min-w-[160px]
${canSelect ? 'cursor-pointer' : 'cursor-not-allowed'}
`}
style={{
backgroundColor: isSelected
? `${tile.brandColor || theme.colors.accent}15`
: theme.colors.bgSidebar,
borderColor: isSelected
? tile.brandColor || theme.colors.accent
: isFocused && canSelect
? theme.colors.accent
: theme.colors.border,
opacity: isSupported ? 1 : 0.5,
boxShadow: isSelected
? `0 0 0 3px ${tile.brandColor || theme.colors.accent}30`
: isFocused && canSelect
? `0 0 0 2px ${theme.colors.accent}40`
: 'none',
}}
aria-label={`${tile.name}${canSelect ? '' : isSupported ? ' (not installed)' : ' (coming soon)'}`}
return (
<button
key={tile.id}
ref={(el) => { tileRefs.current[index] = el; }}
onClick={() => handleTileClick(tile, index)}
onFocus={() => {
setFocusedTileIndex(index);
setIsNameFieldFocused(false);
}}
disabled={!canSelect}
className={`
relative flex flex-col items-center justify-center pt-6 px-6 pb-10 rounded-xl
border-2 transition-all duration-200 outline-none min-w-[160px]
${canSelect ? 'cursor-pointer' : 'cursor-not-allowed'}
`}
style={{
backgroundColor: isSelected
? `${tile.brandColor || theme.colors.accent}15`
: theme.colors.bgSidebar,
borderColor: isSelected
? tile.brandColor || theme.colors.accent
: isFocused && canSelect
? theme.colors.accent
: theme.colors.border,
opacity: isSupported ? 1 : 0.5,
boxShadow: isSelected
? `0 0 0 3px ${tile.brandColor || theme.colors.accent}30`
: isFocused && canSelect
? `0 0 0 2px ${theme.colors.accent}40`
: 'none',
}}
aria-label={`${tile.name}${canSelect ? '' : isSupported ? ' (not installed)' : ' (coming soon)'}`}
aria-pressed={isSelected}
>
{/* Selection indicator */}
@@ -1034,95 +1194,70 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
Customize
</div>
)}
</button>
);
})}
</button>
);
})}
</div>
</div>
</div>
)}
{/* Section 3: Name Your Agent - Prominent */}
<div className="flex flex-col items-center">
<label
htmlFor="project-name"
className="text-2xl font-semibold mb-4"
style={{ color: theme.colors.textMain }}
{/* Section 3: Continue Button + Keyboard hints */}
<div className="flex flex-col items-center gap-4">
<button
onClick={handleContinue}
disabled={!canProceedToNext()}
className="px-8 py-2.5 rounded-lg font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 whitespace-nowrap"
style={{
backgroundColor: canProceedToNext() ? theme.colors.accent : theme.colors.border,
color: canProceedToNext() ? theme.colors.accentForeground : theme.colors.textDim,
cursor: canProceedToNext() ? 'pointer' : 'not-allowed',
opacity: canProceedToNext() ? 1 : 0.6,
['--tw-ring-color' as any]: theme.colors.accent,
['--tw-ring-offset-color' as any]: theme.colors.bgMain,
}}
>
Name Your Agent
</label>
<div className="flex items-center gap-4">
<input
ref={nameInputRef}
id="project-name"
type="text"
value={state.agentName}
onChange={(e) => setAgentName(e.target.value)}
onFocus={() => setIsNameFieldFocused(true)}
onBlur={() => setIsNameFieldFocused(false)}
placeholder=""
className="w-72 px-4 py-2.5 rounded-lg border outline-none transition-all text-center"
style={{
backgroundColor: theme.colors.bgMain,
borderColor: isNameFieldFocused ? theme.colors.accent : theme.colors.border,
color: theme.colors.textMain,
boxShadow: isNameFieldFocused ? `0 0 0 2px ${theme.colors.accent}40` : 'none',
}}
/>
<button
onClick={handleContinue}
disabled={!canProceedToNext()}
className="px-8 py-2.5 rounded-lg font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 whitespace-nowrap"
style={{
backgroundColor: canProceedToNext() ? theme.colors.accent : theme.colors.border,
color: canProceedToNext() ? theme.colors.accentForeground : theme.colors.textDim,
cursor: canProceedToNext() ? 'pointer' : 'not-allowed',
opacity: canProceedToNext() ? 1 : 0.6,
['--tw-ring-color' as any]: theme.colors.accent,
['--tw-ring-offset-color' as any]: theme.colors.bgMain,
}}
>
Continue
</button>
</div>
</div>
{/* Section 4: Keyboard hints (footer) */}
<div className="flex justify-center gap-6">
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<kbd
className="px-1.5 py-0.5 rounded text-xs"
style={{ backgroundColor: theme.colors.border }}
>
</kbd>
Navigate
</span>
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<kbd
className="px-1.5 py-0.5 rounded text-xs"
style={{ backgroundColor: theme.colors.border }}
>
Tab
</kbd>
Name field
</span>
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<kbd
className="px-1.5 py-0.5 rounded text-xs"
style={{ backgroundColor: theme.colors.border }}
>
Enter
</kbd>
Continue
</span>
</button>
{/* Keyboard hints */}
<div className="flex justify-center gap-6">
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<kbd
className="px-1.5 py-0.5 rounded text-xs"
style={{ backgroundColor: theme.colors.border }}
>
</kbd>
Navigate
</span>
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<kbd
className="px-1.5 py-0.5 rounded text-xs"
style={{ backgroundColor: theme.colors.border }}
>
Tab
</kbd>
Fields
</span>
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<kbd
className="px-1.5 py-0.5 rounded text-xs"
style={{ backgroundColor: theme.colors.border }}
>
Enter
</kbd>
Continue
</span>
</div>
</div>
</div>
);

View File

@@ -14,6 +14,7 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type { Theme, AgentConfig } from '../../../types';
import type { SshRemoteConfig } from '../../../../shared/types';
import { useWizard } from '../WizardContext';
import { ScreenReaderAnnouncement } from '../ScreenReaderAnnouncement';
import { ExistingDocsModal } from '../ExistingDocsModal';
@@ -45,6 +46,7 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
const [isDetecting, setIsDetecting] = useState(true);
const [agentConfig, setAgentConfig] = useState<AgentConfig | null>(null);
const [showExistingDocsModal, setShowExistingDocsModal] = useState(false);
const [sshRemoteHost, setSshRemoteHost] = useState<string | null>(null);
// Screen reader announcement state
const [announcement, setAnnouncement] = useState('');
@@ -121,13 +123,52 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
setIsDetecting(false);
}, []);
/**
* Load SSH remote host name when remote is configured
*/
useEffect(() => {
if (!state.sessionSshRemoteConfig?.enabled || !state.sessionSshRemoteConfig?.remoteId) {
setSshRemoteHost(null);
return;
}
async function loadSshRemoteHost() {
try {
const configsResult = await window.maestro.sshRemote.getConfigs();
if (configsResult.success && configsResult.configs) {
const remote = configsResult.configs.find(
(r: SshRemoteConfig) => r.id === state.sessionSshRemoteConfig?.remoteId
);
if (remote) {
setSshRemoteHost(remote.name || remote.host);
}
}
} catch (error) {
console.error('Failed to load SSH remote config:', error);
}
}
loadSshRemoteHost();
}, [state.sessionSshRemoteConfig?.enabled, state.sessionSshRemoteConfig?.remoteId]);
/**
* Get the SSH remote ID from wizard state (if configured)
*/
const getSshRemoteId = useCallback((): string | undefined => {
if (state.sessionSshRemoteConfig?.enabled && state.sessionSshRemoteConfig?.remoteId) {
return state.sessionSshRemoteConfig.remoteId;
}
return undefined;
}, [state.sessionSshRemoteConfig]);
/**
* Check if Auto Run Docs folder exists in the given path
*/
const checkForExistingDocs = useCallback(async (dirPath: string): Promise<{ exists: boolean; count: number }> => {
try {
const autoRunPath = `${dirPath}/${AUTO_RUN_FOLDER_NAME}`;
const result = await window.maestro.autorun.listDocs(autoRunPath);
const sshRemoteId = getSshRemoteId();
const result = await window.maestro.autorun.listDocs(autoRunPath, sshRemoteId);
if (result.success && result.files && result.files.length > 0) {
return { exists: true, count: result.files.length };
}
@@ -136,7 +177,7 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
// Folder doesn't exist or error reading it
return { exists: false, count: 0 };
}
}, []);
}, [getSshRemoteId]);
/**
* Validate directory and check Git repo status
@@ -153,9 +194,29 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
setDirectoryError(null);
try {
// Check if path exists by attempting to read it
// The git.isRepo check will fail if the directory doesn't exist
const isRepo = await window.maestro.git.isRepo(path);
// First, verify the directory exists by attempting to read it
// This will throw if the directory doesn't exist or is inaccessible
const sshRemoteId = getSshRemoteId();
try {
await window.maestro.fs.readDir(path, sshRemoteId);
} catch (dirError) {
// Directory doesn't exist or can't be accessed
console.error('Directory does not exist:', dirError);
setDirectoryError('Directory not found. Please check the path exists.');
setIsGitRepo(false);
setHasExistingAutoRunDocs(false, 0);
// Announce error
if (shouldAnnounce) {
setAnnouncement('Error: Directory not found. Please check the path exists.');
setAnnouncementKey((prev) => prev + 1);
}
setIsValidating(false);
return;
}
// Directory exists, now check if it's a git repo
const isRepo = await window.maestro.git.isRepo(path, sshRemoteId);
setIsGitRepo(isRepo);
setDirectoryError(null);
@@ -189,7 +250,7 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
}
setIsValidating(false);
}, [setIsGitRepo, setDirectoryError, setHasExistingAutoRunDocs, checkForExistingDocs, state.existingDocsChoice]);
}, [setIsGitRepo, setDirectoryError, setHasExistingAutoRunDocs, checkForExistingDocs, state.existingDocsChoice, getSshRemoteId]);
/**
* Focus input on mount (after detection completes)
@@ -260,7 +321,8 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
// Check if Auto Run Docs folder exists and has files
try {
const autoRunPath = `${state.directoryPath}/${AUTO_RUN_FOLDER_NAME}`;
const result = await window.maestro.autorun.listDocs(autoRunPath);
const sshRemoteId = getSshRemoteId();
const result = await window.maestro.autorun.listDocs(autoRunPath, sshRemoteId);
const docs = result.success ? result.files : [];
if (docs && docs.length > 0) {
@@ -274,7 +336,7 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
}
nextStep();
}, [canProceedToNext, nextStep, state.directoryPath, state.existingDocsChoice, setHasExistingAutoRunDocs]);
}, [canProceedToNext, nextStep, state.directoryPath, state.existingDocsChoice, setHasExistingAutoRunDocs, getSshRemoteId]);
/**
* Handle "Start Fresh" choice - docs already deleted by modal
@@ -369,6 +431,7 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
const isValid = canProceedToNext();
const showContinue = state.directoryPath.trim() !== '';
const isRemoteSession = !!state.sessionSshRemoteConfig?.enabled;
return (
<div
@@ -460,7 +523,10 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
type="text"
value={state.directoryPath}
onChange={handlePathChange}
placeholder="/path/to/your/project"
placeholder={isRemoteSession
? `Enter path on ${sshRemoteHost || 'remote host'} (e.g., /home/user/project)`
: '/path/to/your/project'
}
className="flex-1 px-4 py-3 rounded-lg border text-base outline-none transition-all font-mono"
style={{
backgroundColor: theme.colors.bgMain,
@@ -477,48 +543,74 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
aria-invalid={!!state.directoryError}
aria-describedby={state.directoryError ? 'directory-error' : undefined}
/>
<button
ref={browseButtonRef}
onClick={handleBrowse}
disabled={isBrowsing}
className="px-6 py-3 rounded-lg font-medium transition-all flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.accentForeground,
opacity: isBrowsing ? 0.7 : 1,
['--tw-ring-color' as any]: theme.colors.accent,
['--tw-ring-offset-color' as any]: theme.colors.bgMain,
}}
>
{isBrowsing ? (
<>
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: theme.colors.accentForeground, borderTopColor: 'transparent' }}
/>
<span>Opening...</span>
</>
) : (
<>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
{/* Browse button - hidden for remote sessions */}
{!isRemoteSession && (
<button
ref={browseButtonRef}
onClick={handleBrowse}
disabled={isBrowsing}
className="px-6 py-3 rounded-lg font-medium transition-all flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.accentForeground,
opacity: isBrowsing ? 0.7 : 1,
['--tw-ring-color' as any]: theme.colors.accent,
['--tw-ring-offset-color' as any]: theme.colors.bgMain,
}}
>
{isBrowsing ? (
<>
<div
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: theme.colors.accentForeground, borderTopColor: 'transparent' }}
/>
</svg>
<span>Browse</span>
</>
)}
</button>
<span>Opening...</span>
</>
) : (
<>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<span>Browse</span>
</>
)}
</button>
)}
</div>
{/* Remote session hint */}
{isRemoteSession && (
<p
className="mt-2 text-xs flex items-center gap-1.5"
style={{ color: theme.colors.textDim }}
>
<svg
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
Enter the full path on <strong>{sshRemoteHost || 'the remote host'}</strong> — path will be validated as you type
</p>
)}
{/* Error message */}
{state.directoryError && (
<p

View File

@@ -423,8 +423,8 @@ interface MaestroAPI {
stopServer: () => Promise<{ success: boolean; error?: string }>;
};
agents: {
detect: () => Promise<AgentConfig[]>;
refresh: (agentId?: string) => Promise<{
detect: (sshRemoteId?: string) => Promise<AgentConfig[]>;
refresh: (agentId?: string, sshRemoteId?: string) => Promise<{
agents: AgentConfig[];
debugInfo: {
agentId: string;

View File

@@ -758,6 +758,8 @@ export interface CreateMergedSessionOptions {
groupId?: string;
/** Whether to save completions to history (default: true) */
saveToHistory?: boolean;
/** Whether to show thinking/streaming content (default: false) */
showThinking?: boolean;
}
/**
@@ -801,7 +803,8 @@ export function createMergedSession(options: CreateMergedSessionOptions): Create
mergedLogs,
usageStats,
groupId,
saveToHistory = true
saveToHistory = true,
showThinking = false
} = options;
const sessionId = generateId();
@@ -819,7 +822,8 @@ export function createMergedSession(options: CreateMergedSessionOptions): Create
usageStats,
createdAt: Date.now(),
state: 'idle',
saveToHistory
saveToHistory,
showThinking
};
// Create the merged session with standard structure