mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1082,6 +1082,7 @@ function setupIpcHandlers() {
|
||||
registerAgentsHandlers({
|
||||
getAgentDetector: () => agentDetector,
|
||||
agentConfigsStore,
|
||||
settingsStore: store,
|
||||
});
|
||||
|
||||
// Process management operations - extracted to src/main/ipc/handlers/process.ts
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -129,6 +129,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
|
||||
registerAgentsHandlers({
|
||||
getAgentDetector: deps.getAgentDetector,
|
||||
agentConfigsStore: deps.agentConfigsStore,
|
||||
settingsStore: deps.settingsStore,
|
||||
});
|
||||
registerProcessHandlers({
|
||||
getProcessManager: deps.getProcessManager,
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
4
src/renderer/global.d.ts
vendored
4
src/renderer/global.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user