From cea8f186659a6ff023fdabd8927974af64ce080e Mon Sep 17 00:00:00 2001 From: bowen628 Date: Tue, 31 Mar 2026 09:54:36 +0800 Subject: [PATCH] feat(web-ui): parallel SSH remote reconnect with stable global workspace pick --- .../features/ssh-remote/SSHRemoteProvider.tsx | 183 +++++++++++------- 1 file changed, 112 insertions(+), 71 deletions(-) diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 05010619..c870ee77 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -32,6 +32,39 @@ function sshHostForRemoteWorkspace(connectionId: string, remotePath: string): st return undefined; } +/** After parallel reconnects: prefer the user's active remote workspace, else last in sidebar order (matches legacy serial last-write). */ +function pickGlobalRemoteAfterReconnect( + connected: Array<{ workspace: RemoteWorkspace; connectionId: string }>, + orderedList: RemoteWorkspace[] +): { workspace: RemoteWorkspace; connectionId: string } | null { + if (connected.length === 0) return null; + const st = workspaceManager.getState(); + const aid = st.activeWorkspaceId; + if (aid) { + const aw = st.openedWorkspaces.get(aid); + if (aw && aw.workspaceKind === WorkspaceKind.Remote && aw.connectionId) { + const arp = normalizeRemoteWorkspacePath(aw.rootPath); + const hit = connected.find( + c => + c.connectionId === aw.connectionId && + normalizeRemoteWorkspacePath(c.workspace.remotePath) === arp + ); + if (hit) return hit; + } + } + for (let i = orderedList.length - 1; i >= 0; i--) { + const ws = orderedList[i]; + const hit = connected.find( + c => + c.connectionId === ws.connectionId && + normalizeRemoteWorkspacePath(c.workspace.remotePath) === + normalizeRemoteWorkspacePath(ws.remotePath) + ); + if (hit) return hit; + } + return connected[connected.length - 1] ?? null; +} + interface SSHRemoteProviderProps { children: React.ReactNode; } @@ -195,7 +228,8 @@ export const SSHRemoteProvider: React.FC = ({ children } const checkRemoteWorkspace = useCallback(async () => { try { // ── Collect all remote workspaces to reconnect ────────────────────── - const allWorkspaces = Array.from(workspaceManager.getState().openedWorkspaces.values()); + const wmState0 = workspaceManager.getState(); + const allWorkspaces = Array.from(wmState0.openedWorkspaces.values()); const openedRemote = allWorkspaces.filter( ws => ws.workspaceKind === WorkspaceKind.Remote && ws.connectionId ); @@ -246,83 +280,90 @@ export const SSHRemoteProvider: React.FC = ({ children } } setWorkspaceStatuses(prev => ({ ...prev, ...initialStatuses })); - // ── Process each workspace ────────────────────────────────────────── - for (const [, workspace] of toReconnect) { - const isAlreadyOpened = openedRemote.some( - ws => - ws.connectionId === workspace.connectionId && - normalizeRemoteWorkspacePath(ws.rootPath) === - normalizeRemoteWorkspacePath(workspace.remotePath) - ); - - // Check if SSH is already live - const alreadyConnected = await sshApi.isConnected(workspace.connectionId).catch(() => false); - - if (alreadyConnected) { - log.info('Remote workspace already connected', { connectionId: workspace.connectionId }); - // Register with SSH state manager (idempotent) - await sshApi.openWorkspace(workspace.connectionId, workspace.remotePath).catch(() => {}); - setWorkspaceStatus(workspace.connectionId, 'connected'); - setIsConnected(true); - setConnectionId(workspace.connectionId); - setRemoteWorkspace(workspace); - startHeartbeatRef.current(workspace.connectionId); - - if (!isAlreadyOpened) { - await workspaceManager.openRemoteWorkspace(workspace).catch(() => {}); + // ── Process each workspace in parallel (slow servers no longer block others) ── + const reconnectList = Array.from(toReconnect.values()); + + type ConnectedEntry = { workspace: RemoteWorkspace; connectionId: string }; + const results = await Promise.all( + reconnectList.map(async workspace => { + const isAlreadyOpened = openedRemote.some( + ws => + ws.connectionId === workspace.connectionId && + normalizeRemoteWorkspacePath(ws.rootPath) === + normalizeRemoteWorkspacePath(workspace.remotePath) + ); + + const alreadyConnected = await sshApi.isConnected(workspace.connectionId).catch(() => false); + + if (alreadyConnected) { + log.info('Remote workspace already connected', { connectionId: workspace.connectionId }); + await sshApi.openWorkspace(workspace.connectionId, workspace.remotePath).catch(() => {}); + setWorkspaceStatus(workspace.connectionId, 'connected'); + + if (!isAlreadyOpened) { + await workspaceManager.openRemoteWorkspace(workspace).catch(() => {}); + } + void flowChatStore + .initializeFromDisk( + workspace.remotePath, + workspace.connectionId, + workspace.sshHost?.trim() || + sshHostForRemoteWorkspace(workspace.connectionId, workspace.remotePath) + ) + .catch(() => {}); + + return { ok: true as const, connected: { workspace, connectionId: workspace.connectionId } }; } - // Re-initialize sessions now that the workspace is registered in the state manager - void flowChatStore - .initializeFromDisk( - workspace.remotePath, - workspace.connectionId, - workspace.sshHost?.trim() || - sshHostForRemoteWorkspace(workspace.connectionId, workspace.remotePath) - ) - .catch(() => {}); - continue; - } - // Not connected — attempt auto-reconnect - log.info('Remote workspace disconnected, attempting auto-reconnect', { - connectionId: workspace.connectionId, - remotePath: workspace.remotePath, - }); - - const result = await tryReconnectWithRetry(workspace, 5, 5000); - - if (result !== false) { - log.info('Reconnection successful', { newConnectionId: result.connectionId }); - setWorkspaceStatus(result.workspace.connectionId, 'connected'); - setIsConnected(true); - setConnectionId(result.connectionId); - setRemoteWorkspace(result.workspace); - startHeartbeatRef.current(result.connectionId); - - if (!isAlreadyOpened) { - await workspaceManager.openRemoteWorkspace(result.workspace).catch(() => {}); + log.info('Remote workspace disconnected, attempting auto-reconnect', { + connectionId: workspace.connectionId, + remotePath: workspace.remotePath, + }); + const result = await tryReconnectWithRetry(workspace, 5, 5000); + + if (result !== false) { + log.info('Reconnection successful', { newConnectionId: result.connectionId }); + setWorkspaceStatus(result.workspace.connectionId, 'connected'); + + if (!isAlreadyOpened) { + await workspaceManager.openRemoteWorkspace(result.workspace).catch(() => {}); + } + void flowChatStore + .initializeFromDisk( + result.workspace.remotePath, + result.workspace.connectionId, + result.workspace.sshHost?.trim() || + sshHostForRemoteWorkspace( + result.workspace.connectionId, + result.workspace.remotePath + ) + ) + .catch(() => {}); + + return { + ok: true as const, + connected: { workspace: result.workspace, connectionId: result.connectionId }, + }; } - // Re-initialize sessions now that the workspace is registered in the state manager - void flowChatStore - .initializeFromDisk( - result.workspace.remotePath, - result.workspace.connectionId, - result.workspace.sshHost?.trim() || - sshHostForRemoteWorkspace( - result.workspace.connectionId, - result.workspace.remotePath - ) - ) - .catch(() => {}); - } else { - // Reconnection failed (or skipped for password auth) — remove the workspace - // from the sidebar. Password-auth workspaces can never auto-reconnect, and - // showing a permanently-broken entry would confuse the user. + log.warn('Auto-reconnect failed, removing workspace from sidebar', { connectionId: workspace.connectionId, }); await workspaceManager.removeRemoteWorkspace(workspace.connectionId, workspace.remotePath).catch(() => {}); - } + return { ok: false as const }; + }) + ); + + const connectedEntries: ConnectedEntry[] = results + .filter((r): r is { ok: true; connected: ConnectedEntry } => r.ok) + .map(r => r.connected); + + const chosen = pickGlobalRemoteAfterReconnect(connectedEntries, reconnectList); + if (chosen) { + setIsConnected(true); + setConnectionId(chosen.connectionId); + setRemoteWorkspace(chosen.workspace); + startHeartbeatRef.current(chosen.connectionId); } } catch (e) { log.error('checkRemoteWorkspace failed', e);