diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx index 3c2f376f8e..2657a5a331 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx @@ -392,14 +392,16 @@ describe('SSHRemoteProvider startup restore', () => { }); }); -describe('SSHRemoteProvider peer device mode', () => { +describe('SSHRemoteProvider workspace connection state', () => { let container: HTMLDivElement; let root: Root; let latestStatuses: Record; + let statusHistory: Array>; function StatusProbe() { const ctx = React.useContext(SSHContext); latestStatuses = ctx?.workspaceStatuses ?? {}; + statusHistory.push(latestStatuses); return null; } @@ -424,6 +426,7 @@ describe('SSHRemoteProvider peer device mode', () => { vi.clearAllMocks(); peerModeFlagMock.active = false; latestStatuses = {}; + statusHistory = []; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -477,6 +480,59 @@ describe('SSHRemoteProvider peer device mode', () => { }); } + it.each([true, false])('keeps an established connection green during a session-switch probe (saved profile: %s)', async (hasSavedProfile) => { + const workspace = createRemoteWorkspace(); + workspaceManagerMock.getState.mockReturnValue({ + loading: false, openedWorkspaces: new Map([[workspace.id, workspace]]), activeWorkspaceId: workspace.id, + }); + sshApiMock.listSavedConnections.mockResolvedValue(hasSavedProfile ? [{ + id: 'conn-1', name: 'dev-box', host: 'example.com', port: 22, username: 'dev', + authType: { type: 'PrivateKey', keyPath: '/tmp/key' }, + }] : []); + sshApiMock.isConnected.mockResolvedValue(true); + await renderProvider(); + expect(latestStatuses['conn-1']).toBe('connected'); + statusHistory = []; + + let finishProbe!: (connected: boolean) => void; + sshApiMock.isConnected.mockImplementationOnce(() => new Promise(resolve => { finishProbe = resolve; })); + emitWorkspaceManagerEvent({ type: 'workspace:switched', workspace }); + await act(async () => { await Promise.resolve(); }); + expect(finishProbe).toBeTypeOf('function'); + expect(latestStatuses['conn-1']).toBe('connected'); + await act(async () => { finishProbe(true); }); + + expect(statusHistory.every(statuses => statuses['conn-1'] === 'connected')).toBe(true); + expect(sshApiMock.connect).not.toHaveBeenCalled(); + }); + + it('shows connecting after a probe confirms a real disconnect and clears it when reconnection finishes', async () => { + const workspace = createRemoteWorkspace(); + workspaceManagerMock.getState.mockReturnValue({ + loading: false, openedWorkspaces: new Map([[workspace.id, workspace]]), activeWorkspaceId: workspace.id, + }); + sshApiMock.listSavedConnections.mockResolvedValue([{ + id: 'conn-1', name: 'dev-box', host: 'example.com', port: 22, username: 'dev', + authType: { type: 'PrivateKey', keyPath: '/tmp/key' }, + }]); + sshApiMock.isConnected.mockResolvedValue(true); + await renderProvider(); + expect(latestStatuses['conn-1']).toBe('connected'); + + let finishProbe!: (connected: boolean) => void; + let finishReconnect!: (result: { success: boolean; connectionId: string }) => void; + sshApiMock.isConnected.mockImplementationOnce(() => new Promise(resolve => { finishProbe = resolve; })); + sshApiMock.connect.mockImplementationOnce(() => new Promise(resolve => { finishReconnect = resolve; })); + emitWorkspaceManagerEvent({ type: 'workspace:switched', workspace }); + await act(async () => { await Promise.resolve(); }); + expect(latestStatuses['conn-1']).toBe('connected'); + await act(async () => { finishProbe(false); }); + expect(sshApiMock.connect).toHaveBeenCalledTimes(1); + expect(latestStatuses['conn-1']).toBe('connecting'); + await act(async () => { finishReconnect({ success: true, connectionId: 'conn-1' }); }); + expect(latestStatuses['conn-1']).toBe('connected'); + }); + it('mirrors peer-owned remote workspaces as connected without starting the reconnect timeout', async () => { vi.useFakeTimers(); peerModeFlagMock.active = true; diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 511f726a5f..72f57202bc 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -558,7 +558,10 @@ export const SSHRemoteProvider: React.FC = ({ children } ? 'error' : 'connecting'; } - setWorkspaceStatuses(prev => ({ ...prev, ...initialStatuses })); + // A background check is not a reconnect. Keep the last observed state + // while the probe is pending, including when session selection checks + // every opened workspace again. + setWorkspaceStatuses(prev => ({ ...initialStatuses, ...prev })); type ConnectedEntry = { workspace: RemoteWorkspace; connectionId: string }; const results = await Promise.all( @@ -617,6 +620,7 @@ export const SSHRemoteProvider: React.FC = ({ children } connectionId: workspace.connectionId, remotePath: workspace.remotePath, }); + setWorkspaceStatuses(prev => ({ ...prev, [workspace.connectionId]: 'connecting' })); const result = await tryReconnectWithRetry(workspace); if (result !== false) {