Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 112 additions & 71 deletions src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -195,7 +228,8 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ 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
);
Expand Down Expand Up @@ -246,83 +280,90 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ 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);
Expand Down
Loading