diff --git a/build/large-figure-gradient-2.icon/Assets/figure-large.svg b/build/large-figure-gradient-2.icon/Assets/figure-large.svg new file mode 100644 index 00000000..99479c48 --- /dev/null +++ b/build/large-figure-gradient-2.icon/Assets/figure-large.svg @@ -0,0 +1,50852 @@ + + + + + + + + + + + + + + + + + + + diff --git a/build/large-figure-gradient-2.icon/icon.json b/build/large-figure-gradient-2.icon/icon.json new file mode 100644 index 00000000..94fc3d4a --- /dev/null +++ b/build/large-figure-gradient-2.icon/icon.json @@ -0,0 +1,51 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:0.37581,0.00107,0.39677,1.00000", + "display-p3:0.92931,0.00000,0.97255,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 1 + }, + "stop" : { + "x" : 0.5, + "y" : 0.3 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "figure-large.svg", + "name" : "figure-large", + "position" : { + "scale" : 1, + "translation-in-points" : [ + -14, + -19 + ] + } + } + ], + "name" : "images", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/build/large-figure-gradient-bckgrnd.icon/Assets/figure-large.svg b/build/large-figure-gradient-bckgrnd.icon/Assets/figure-large.svg new file mode 100644 index 00000000..99479c48 --- /dev/null +++ b/build/large-figure-gradient-bckgrnd.icon/Assets/figure-large.svg @@ -0,0 +1,50852 @@ + + + + + + + + + + + + + + + + + + + diff --git a/build/large-figure-gradient-bckgrnd.icon/Assets/maestro-full-background.png b/build/large-figure-gradient-bckgrnd.icon/Assets/maestro-full-background.png new file mode 100644 index 00000000..a75d4b60 Binary files /dev/null and b/build/large-figure-gradient-bckgrnd.icon/Assets/maestro-full-background.png differ diff --git a/build/large-figure-gradient-bckgrnd.icon/icon.json b/build/large-figure-gradient-bckgrnd.icon/icon.json new file mode 100644 index 00000000..d862c5fd --- /dev/null +++ b/build/large-figure-gradient-bckgrnd.icon/icon.json @@ -0,0 +1,43 @@ +{ + "fill" : { + "solid" : "extended-gray:1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "figure-large.svg", + "name" : "figure-large", + "position" : { + "scale" : 1, + "translation-in-points" : [ + -14, + -19 + ] + } + }, + { + "glass" : false, + "image-name" : "maestro-full-background.png", + "name" : "maestro-full-background" + } + ], + "name" : "images", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/build/maestro-circle-bckgrnd.icon/Assets/figure-large 2.svg b/build/maestro-circle-bckgrnd.icon/Assets/figure-large 2.svg new file mode 100644 index 00000000..99479c48 --- /dev/null +++ b/build/maestro-circle-bckgrnd.icon/Assets/figure-large 2.svg @@ -0,0 +1,50852 @@ + + + + + + + + + + + + + + + + + + + diff --git a/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-1 2.svg b/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-1 2.svg new file mode 100644 index 00000000..1c44c1d8 --- /dev/null +++ b/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-1 2.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-2 2.svg b/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-2 2.svg new file mode 100644 index 00000000..a3b365e5 --- /dev/null +++ b/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-2 2.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-3 2.svg b/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-3 2.svg new file mode 100644 index 00000000..4435ece9 --- /dev/null +++ b/build/maestro-circle-bckgrnd.icon/Assets/maestro-bckgrnd-3 2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/build/maestro-circle-bckgrnd.icon/icon.json b/build/maestro-circle-bckgrnd.icon/icon.json new file mode 100644 index 00000000..339aecb4 --- /dev/null +++ b/build/maestro-circle-bckgrnd.icon/icon.json @@ -0,0 +1,43 @@ +{ + "fill" : "automatic", + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "figure-large 2.svg", + "name" : "figure-large 2" + }, + { + "glass" : false, + "image-name" : "maestro-bckgrnd-3 2.svg", + "name" : "maestro-bckgrnd-3 2" + }, + { + "glass" : false, + "image-name" : "maestro-bckgrnd-2 2.svg", + "name" : "maestro-bckgrnd-2 2" + }, + { + "glass" : false, + "image-name" : "maestro-bckgrnd-1 2.svg", + "name" : "maestro-bckgrnd-1 2" + } + ], + "name" : "images", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : [ + "macOS" + ] + } +} \ No newline at end of file diff --git a/build/maestro-purple-figure.icon/Assets/Image.png b/build/maestro-purple-figure.icon/Assets/Image.png new file mode 100644 index 00000000..f8fd1260 Binary files /dev/null and b/build/maestro-purple-figure.icon/Assets/Image.png differ diff --git a/build/maestro-purple-figure.icon/icon.json b/build/maestro-purple-figure.icon/icon.json new file mode 100644 index 00000000..22fe1c50 --- /dev/null +++ b/build/maestro-purple-figure.icon/icon.json @@ -0,0 +1,52 @@ +{ + "fill" : { + "linear-gradient" : [ + "extended-gray:1.00000,1.00000", + "display-p3:0.90452,0.90132,0.89173,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 1 + }, + "stop" : { + "x" : 0.5, + "y" : 0.3 + } + } + }, + "groups" : [ + { + "hidden" : false, + "layers" : [ + { + "glass" : false, + "hidden" : false, + "image-name" : "Image.png", + "name" : "Image", + "position" : { + "scale" : 0.5, + "translation-in-points" : [ + -24, + -12.5 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index 968df9e7..0f001a45 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -777,7 +777,7 @@ describe('SettingsModal', () => { }); describe('General tab - History toggle', () => { - it('should call setDefaultSaveToHistory when checkbox is changed', async () => { + it('should call setDefaultSaveToHistory when toggle switch is changed', async () => { const setDefaultSaveToHistory = vi.fn(); render(); @@ -785,10 +785,13 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(100); }); - const historyCheckbox = screen.getByText('Enable "History" by default for new tabs').closest('label')?.querySelector('input[type="checkbox"]'); - expect(historyCheckbox).toBeDefined(); + // SettingCheckbox uses a button with role="switch" instead of input[type="checkbox"] + const titleElement = screen.getByText('Enable "History" by default for new tabs'); + const toggleContainer = titleElement.closest('[role="button"]'); + const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]'); + expect(toggleSwitch).toBeDefined(); - fireEvent.click(historyCheckbox!); + fireEvent.click(toggleSwitch!); expect(setDefaultSaveToHistory).toHaveBeenCalledWith(false); }); }); @@ -1031,7 +1034,7 @@ describe('SettingsModal', () => { expect(screen.getByText('Enable OS Notifications')).toBeInTheDocument(); }); - it('should call setOsNotificationsEnabled when checkbox is changed', async () => { + it('should call setOsNotificationsEnabled when toggle switch is changed', async () => { const setOsNotificationsEnabled = vi.fn(); render(); @@ -1039,13 +1042,16 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(100); }); - const checkbox = screen.getByText('Enable OS Notifications').closest('label')?.querySelector('input[type="checkbox"]'); - fireEvent.click(checkbox!); + // SettingCheckbox uses a button with role="switch" instead of input[type="checkbox"] + const titleElement = screen.getByText('Enable OS Notifications'); + const toggleContainer = titleElement.closest('[role="button"]'); + const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]'); + fireEvent.click(toggleSwitch!); expect(setOsNotificationsEnabled).toHaveBeenCalledWith(false); }); - it('should update checkbox state when prop changes (regression test for memo bug)', async () => { + it('should update toggle state when prop changes (regression test for memo bug)', async () => { // This test ensures the component re-renders when props change // A previous bug had an overly restrictive memo comparator that prevented re-renders const { rerender } = render(); @@ -1054,9 +1060,11 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(100); }); - // Verify initial checked state - const checkbox = screen.getByText('Enable OS Notifications').closest('label')?.querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(checkbox.checked).toBe(true); + // SettingCheckbox uses a button with role="switch" and aria-checked instead of input[type="checkbox"] + const titleElement = screen.getByText('Enable OS Notifications'); + const toggleContainer = titleElement.closest('[role="button"]'); + const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]') as HTMLButtonElement; + expect(toggleSwitch.getAttribute('aria-checked')).toBe('true'); // Rerender with changed prop (simulating what happens after onChange) rerender(); @@ -1065,8 +1073,8 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(50); }); - // The checkbox should now be unchecked - this would fail with the old memo comparator - expect(checkbox.checked).toBe(false); + // The toggle should now be unchecked - this would fail with the old memo comparator + expect(toggleSwitch.getAttribute('aria-checked')).toBe('false'); }); it('should test notification when button is clicked', async () => { @@ -1090,7 +1098,7 @@ describe('SettingsModal', () => { expect(screen.getByText('Enable Audio Feedback')).toBeInTheDocument(); }); - it('should call setAudioFeedbackEnabled when checkbox is changed', async () => { + it('should call setAudioFeedbackEnabled when toggle switch is changed', async () => { const setAudioFeedbackEnabled = vi.fn(); render(); @@ -1098,8 +1106,11 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(100); }); - const checkbox = screen.getByText('Enable Audio Feedback').closest('label')?.querySelector('input[type="checkbox"]'); - fireEvent.click(checkbox!); + // SettingCheckbox uses a button with role="switch" instead of input[type="checkbox"] + const titleElement = screen.getByText('Enable Audio Feedback'); + const toggleContainer = titleElement.closest('[role="button"]'); + const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]'); + fireEvent.click(toggleSwitch!); expect(setAudioFeedbackEnabled).toHaveBeenCalledWith(true); }); diff --git a/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx b/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx index 8336d922..4ddf48fd 100644 --- a/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx +++ b/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx @@ -535,6 +535,13 @@ describe('Usage Dashboard State Transition Animations', () => { }); it('animations do not interfere with data refresh', async () => { + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockStats.onStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( { expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); - // Click refresh button - const refreshButton = screen.getByTitle('Refresh'); - fireEvent.click(refreshButton); - - // Data should still update - await waitFor(() => { - expect(mockStats.getAggregation).toHaveBeenCalledTimes(2); // Initial + refresh + // Trigger real-time update via the stats callback + act(() => { + if (statsCallback) statsCallback(); }); + + // Data should still update (callback was triggered, debounce will handle timing) + expect(mockStats.onStatsUpdate).toHaveBeenCalled(); }); it('animations apply correctly after modal reopen', async () => { diff --git a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx index 1bf5ee61..0fabacc6 100644 --- a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx +++ b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx @@ -674,10 +674,18 @@ describe('UsageDashboardModal', () => { expect(mockOnStatsUpdate.mock.calls[0]).toBeDefined(); }); - it('refresh button does not show loading spinner that hides data', async () => { + it('real-time updates do not show loading spinner that hides data', async () => { const initialData = createSampleData(); + const updatedData = { ...createSampleData(), totalQueries: 300 }; mockGetAggregation.mockResolvedValueOnce(initialData); + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockOnStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( ); @@ -688,22 +696,15 @@ describe('UsageDashboardModal', () => { }); // Setup next fetch - mockGetAggregation.mockResolvedValueOnce(createSampleData()); + mockGetAggregation.mockResolvedValueOnce(updatedData); - // Click the manual refresh button - const refreshButton = screen.getByTitle('Refresh'); - fireEvent.click(refreshButton); - - // Content should still be visible (refresh uses showRefresh=true path, not skeleton) - expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument(); - expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); - - // Wait for refresh to complete - await waitFor(() => { - expect(mockGetAggregation).toHaveBeenCalledTimes(2); + // Trigger real-time update via the stats callback + act(() => { + if (statsCallback) statsCallback(); }); - // After refresh completes, content should still be visible + // Content should still be visible (real-time updates don't show skeleton) + expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument(); expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); @@ -731,11 +732,18 @@ describe('UsageDashboardModal', () => { expect(unsubscribeMock).toHaveBeenCalled(); }); - it('content persists when refresh is triggered after initial load', async () => { + it('content persists when real-time update is triggered after initial load', async () => { const initialData = createSampleData(); const refreshedData = { ...createSampleData(), totalQueries: 300 }; mockGetAggregation.mockResolvedValueOnce(initialData); + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockOnStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( ); @@ -748,17 +756,14 @@ describe('UsageDashboardModal', () => { // Setup next fetch mockGetAggregation.mockResolvedValueOnce(refreshedData); - // Click refresh - fireEvent.click(screen.getByTitle('Refresh')); - - // Critical: content should NOT disappear during refresh (no skeleton shown) - expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument(); - - // Wait for update to complete - await waitFor(() => { - expect(mockGetAggregation).toHaveBeenCalledTimes(2); + // Trigger real-time update via the stats callback + act(() => { + if (statsCallback) statsCallback(); }); + // Critical: content should NOT disappear during real-time update (no skeleton shown) + expect(screen.queryByTestId('dashboard-skeleton')).not.toBeInTheDocument(); + // Verify content still there expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); @@ -810,6 +815,14 @@ describe('UsageDashboardModal', () => { }); describe('New Data Visual Indicator', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('indicator does NOT appear for initial load', async () => { const initialData = createSampleData(); mockGetAggregation.mockResolvedValue(initialData); @@ -827,10 +840,17 @@ describe('UsageDashboardModal', () => { expect(screen.queryByTestId('new-data-indicator')).not.toBeInTheDocument(); }); - it('indicator appears after manual refresh completes', async () => { + it('indicator appears after real-time update completes', async () => { const initialData = createSampleData(); mockGetAggregation.mockResolvedValue(initialData); + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockOnStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( ); @@ -840,10 +860,13 @@ describe('UsageDashboardModal', () => { expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); - // Click refresh button - fireEvent.click(screen.getByTitle('Refresh')); + // Trigger real-time update via the stats callback and wait for debounce + await act(async () => { + if (statsCallback) statsCallback(); + await vi.advanceTimersByTimeAsync(1100); + }); - // Wait for indicator to appear after refresh completes + // Wait for indicator to appear after update completes await waitFor(() => { expect(screen.getByTestId('new-data-indicator')).toBeInTheDocument(); }, { timeout: 2000 }); @@ -856,6 +879,13 @@ describe('UsageDashboardModal', () => { const initialData = createSampleData(); mockGetAggregation.mockResolvedValue(initialData); + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockOnStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( ); @@ -865,8 +895,11 @@ describe('UsageDashboardModal', () => { expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); - // Click refresh button to trigger indicator - fireEvent.click(screen.getByTitle('Refresh')); + // Trigger real-time update via the stats callback and wait for debounce + await act(async () => { + if (statsCallback) statsCallback(); + await vi.advanceTimersByTimeAsync(1100); + }); // Wait for indicator await waitFor(() => { @@ -880,6 +913,13 @@ describe('UsageDashboardModal', () => { const initialData = createSampleData(); mockGetAggregation.mockResolvedValue(initialData); + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockOnStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( ); @@ -889,8 +929,11 @@ describe('UsageDashboardModal', () => { expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); - // Click refresh button to trigger indicator - fireEvent.click(screen.getByTitle('Refresh')); + // Trigger real-time update via the stats callback and wait for debounce + await act(async () => { + if (statsCallback) statsCallback(); + await vi.advanceTimersByTimeAsync(1100); + }); // Wait for indicator with pulsing dot await waitFor(() => { @@ -905,6 +948,13 @@ describe('UsageDashboardModal', () => { const initialData = createSampleData(); mockGetAggregation.mockResolvedValue(initialData); + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockOnStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( ); @@ -914,8 +964,11 @@ describe('UsageDashboardModal', () => { expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); - // Click refresh button to trigger indicator - fireEvent.click(screen.getByTitle('Refresh')); + // Trigger real-time update via the stats callback and wait for debounce + await act(async () => { + if (statsCallback) statsCallback(); + await vi.advanceTimersByTimeAsync(1100); + }); // Wait for indicator and check theme styling await waitFor(() => { @@ -929,6 +982,13 @@ describe('UsageDashboardModal', () => { const initialData = createSampleData(); mockGetAggregation.mockResolvedValue(initialData); + // Store the callback for triggering updates + let statsCallback: (() => void) | null = null; + mockOnStatsUpdate.mockImplementation((callback: () => void) => { + statsCallback = callback; + return vi.fn(); + }); + render( ); @@ -938,8 +998,11 @@ describe('UsageDashboardModal', () => { expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument(); }); - // Click refresh button to trigger indicator - fireEvent.click(screen.getByTitle('Refresh')); + // Trigger real-time update via the stats callback and wait for debounce + await act(async () => { + if (statsCallback) statsCallback(); + await vi.advanceTimersByTimeAsync(1100); + }); // Wait for indicator and check dot styling await waitFor(() => { diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index eededd73..7eb8d8af 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -430,7 +430,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { }, [session.fullPath, session.changedFiles, session.fileExplorerExpanded, session.id, previewFile?.path, activeFocus, activeRightTab, selectedFileIndex, theme, toggleFolder, setSessions, setSelectedFileIndex, setActiveFocus, handleFileClick, fileTreeFilter, handleContextMenu]); return ( -
+
{/* File Tree Filter */} {fileTreeFilterOpen && (
@@ -650,7 +650,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { {/* Status bar at bottom */} {session.fileTreeStats && (
handleRefreshAgent(agent.id)} refreshingAgent={refreshingAgent === agent.id} showBuiltInEnvVars - sshRemotes={sshRemotes} - sshRemoteConfig={agentSshRemoteConfigs[agent.id]} - onSshRemoteConfigChange={(config) => { - setAgentSshRemoteConfigs(prev => ({ - ...prev, - [agent.id]: config - })); - }} - globalDefaultSshRemoteId={globalDefaultSshRemoteId} />
)} @@ -704,6 +696,22 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
)} + {/* SSH Remote Execution - Top Level */} + {sshRemotes.length > 0 && selectedAgent && ( + { + setAgentSshRemoteConfigs(prev => ({ + ...prev, + [selectedAgent]: config + })); + }} + globalDefaultSshRemoteId={globalDefaultSshRemoteId} + /> + )} + {/* Nudge Message */}
- {/* SSH Remote Configuration - only shown when props are provided */} - {sshRemotes !== undefined && onSshRemoteConfigChange && ( -
- - - {/* SSH Remote Selection */} -
- {/* Dropdown to select remote */} -
- - -
- - {/* Status indicator showing effective remote */} - {(() => { - const effectiveRemoteId = sshRemoteConfig?.enabled === false - ? null - : sshRemoteConfig?.remoteId || globalDefaultSshRemoteId || null; - const effectiveRemote = effectiveRemoteId - ? sshRemotes.find(r => r.id === effectiveRemoteId && r.enabled) - : null; - const isForceLocal = sshRemoteConfig?.enabled === false; - - return ( -
- {isForceLocal ? ( - <> - - - Agent will run locally (SSH disabled) - - - ) : effectiveRemote ? ( - <> - - - Agent will run on {effectiveRemote.name} - ({effectiveRemote.host}) - - - ) : ( - <> - - - Agent will run locally - - - )} -
- ); - })()} - - {/* No remotes configured hint */} - {sshRemotes.filter(r => r.enabled).length === 0 && ( -

- No SSH remotes configured.{' '} - - Configure remotes in Settings → SSH Remotes. - -

- )} -
- -

- Execute this agent on a remote host via SSH instead of locally -

-
- )} - {/* Agent-specific configuration options (contextWindow, model, etc.) */} {agent.configOptions && agent.configOptions.length > 0 && agent.configOptions.map((option: AgentConfigOption) => (
void; + globalDefaultSshRemoteId?: string | null; + /** Optional: compact mode with less padding */ + compact?: boolean; +} + +export function SshRemoteSelector({ + theme, + sshRemotes, + sshRemoteConfig, + onSshRemoteConfigChange, + globalDefaultSshRemoteId, + compact = false, +}: SshRemoteSelectorProps): JSX.Element { + const padding = compact ? 'p-2' : 'p-3'; + + return ( +
+ + + {/* SSH Remote Selection */} +
+ {/* Dropdown to select remote */} +
+ + +
+ + {/* Status indicator showing effective remote */} + {(() => { + const effectiveRemoteId = sshRemoteConfig?.enabled === false + ? null + : sshRemoteConfig?.remoteId || globalDefaultSshRemoteId || null; + const effectiveRemote = effectiveRemoteId + ? sshRemotes.find(r => r.id === effectiveRemoteId && r.enabled) + : null; + const isForceLocal = sshRemoteConfig?.enabled === false; + + return ( +
+ {isForceLocal ? ( + <> + + + Agent will run locally (SSH disabled) + + + ) : effectiveRemote ? ( + <> + + + Agent will run on {effectiveRemote.name} + ({effectiveRemote.host}) + + + ) : ( + <> + + + Agent will run locally + + + )} +
+ ); + })()} + + {/* No remotes configured hint */} + {sshRemotes.filter(r => r.enabled).length === 0 && ( +

+ No SSH remotes configured.{' '} + + Configure remotes in Settings → SSH Remotes. + +

+ )} +
+ +

+ Execute this agent on a remote host via SSH instead of locally +

+
+ ); +}