Skip to content

Commit 30b475f

Browse files
authored
Merge pull request #3886 from bobleer/bob/fix-remote-workspace-status-pill
fix(web-ui): never show an unknown remote workspace status
2 parents a891309 + 2e7232d commit 30b475f

7 files changed

Lines changed: 125 additions & 46 deletions

File tree

‎src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,9 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
295295
return null;
296296
}
297297

298-
const status = remoteConnStatus ?? 'unknown';
299-
const statusLabel = t(`nav.workspaces.remote.status.${status}`, {
300-
defaultValue: t('nav.workspaces.remote.status.unknown'),
301-
});
298+
// No status yet means the first check for this connection is still running.
299+
const status = remoteConnStatus ?? 'connecting';
300+
const statusLabel = t(`nav.workspaces.remote.status.${status}`);
302301
const badge = remoteBadge ?? remoteWorkspaceBadge(workspace);
303302
const connectionLabel = badge?.connection ?? '';
304303
const hostLabel = badge?.host || connectionLabel;
@@ -307,8 +306,6 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
307306
status,
308307
statusLabel,
309308
badgeLabel: badge?.label ?? connectionLabel,
310-
/** A green dot already reads as "fine"; spell out only the states that need attention. */
311-
showStatusText: status !== 'connected',
312309
tooltip: t('nav.workspaces.remote.tooltip', {
313310
connection: connectionLabel,
314311
host: hostLabel,
@@ -1356,14 +1353,10 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
13561353
className={`openbitfun-nav-panel__workspace-item-status-dot is-${remoteMeta.status}`}
13571354
aria-hidden="true"
13581355
/>
1356+
{/* Same shape in every state: the dot and tint carry the status, the tooltip spells it out. */}
13591357
<span className="openbitfun-nav-panel__workspace-item-remote-host">
13601358
{remoteMeta.badgeLabel}
13611359
</span>
1362-
{remoteMeta.showStatusText ? (
1363-
<span className="openbitfun-nav-panel__workspace-item-remote-status">
1364-
{remoteMeta.statusLabel}
1365-
</span>
1366-
) : null}
13671360
</span>
13681361
</Tooltip>
13691362
)}

‎src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -445,18 +445,6 @@
445445
text-overflow: ellipsis;
446446
}
447447

448-
&__workspace-item-remote-status {
449-
flex-shrink: 0;
450-
white-space: nowrap;
451-
opacity: 0.85;
452-
453-
&::before {
454-
content: '·';
455-
margin-right: 4px;
456-
opacity: 0.6;
457-
}
458-
}
459-
460448
&__workspace-item:not(.is-active) &__workspace-item-remote {
461449
opacity: 0.8;
462450
}
@@ -477,10 +465,6 @@
477465
background: var(--openbitfun-color-status-danger-emphasis);
478466
}
479467

480-
&.is-unknown {
481-
background: var(--openbitfun-color-content-muted);
482-
}
483-
484468
&.is-connecting {
485469
background: var(--openbitfun-color-status-warning-emphasis);
486470
animation: openbitfun-status-dot-pulse 1.4s ease-in-out infinite;

‎src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,9 +1046,10 @@ describe('SSHRemoteProvider workspace connection state', () => {
10461046
expect(ACPClientAPI.invalidateRemoteRequirementProbeCache).toHaveBeenCalledWith('conn-1');
10471047
});
10481048

1049-
it('mirrors peer-owned remote workspaces as connected without starting the reconnect timeout', async () => {
1049+
it('reads peer-owned remote workspace status from the peer without starting the reconnect timeout', async () => {
10501050
vi.useFakeTimers();
10511051
peerModeFlagMock.active = true;
1052+
sshApiMock.isConnected.mockResolvedValue(true);
10521053

10531054
const remoteWorkspace = createRemoteWorkspace();
10541055
workspaceManagerMock.getState.mockReturnValue({
@@ -1063,7 +1064,10 @@ describe('SSHRemoteProvider workspace connection state', () => {
10631064
expect(sshApiMock.listSavedConnections).not.toHaveBeenCalled();
10641065

10651066
emitWorkspaceManagerEvent({ type: 'workspace:opened', workspace: remoteWorkspace });
1067+
await act(async () => { await Promise.resolve(); });
10661068

1069+
expect(sshApiMock.isConnected).toHaveBeenCalledWith('conn-1');
1070+
expect(sshApiMock.connect).not.toHaveBeenCalled();
10671071
expect(latestStatuses['conn-1']).toBe('connected');
10681072

10691073
await act(async () => {
@@ -1075,6 +1079,67 @@ describe('SSHRemoteProvider workspace connection state', () => {
10751079
expect(notificationService.error).not.toHaveBeenCalled();
10761080
});
10771081

1082+
it('gives every peer remote workspace a status even when the hydrated list emits no per-workspace event', async () => {
1083+
peerModeFlagMock.active = true;
1084+
const remoteWorkspace = createRemoteWorkspace();
1085+
const second = { ...createRemoteWorkspace(), id: 'ws-remote-2', connectionId: 'conn-2' };
1086+
workspaceManagerMock.getState.mockReturnValue({
1087+
loading: false,
1088+
openedWorkspaces: new Map([[remoteWorkspace.id, remoteWorkspace], [second.id, second]]),
1089+
activeWorkspaceId: null,
1090+
});
1091+
sshApiMock.isConnected.mockImplementation(async (connectionId: string) => connectionId === 'conn-1');
1092+
1093+
await renderProvider();
1094+
emitWorkspaceManagerEvent({ type: 'workspace:loading', loading: false });
1095+
await act(async () => { await Promise.resolve(); });
1096+
1097+
expect(latestStatuses).toEqual({ 'conn-1': 'connected', 'conn-2': 'disconnected' });
1098+
});
1099+
1100+
it('keeps a peer that does not report SSH state mirrored as connected, and an unreachable peer as an error', async () => {
1101+
peerModeFlagMock.active = true;
1102+
const remoteWorkspace = createRemoteWorkspace();
1103+
const second = { ...createRemoteWorkspace(), id: 'ws-remote-2', connectionId: 'conn-2' };
1104+
workspaceManagerMock.getState.mockReturnValue({
1105+
loading: false,
1106+
openedWorkspaces: new Map([[remoteWorkspace.id, remoteWorkspace], [second.id, second]]),
1107+
activeWorkspaceId: null,
1108+
});
1109+
sshApiMock.isConnected.mockImplementation(async (connectionId: string) => {
1110+
throw new Error(connectionId === 'conn-1'
1111+
? "command 'ssh_is_connected' is not supported on cli peer host: the CLI peer host does not run this desktop IDE surface"
1112+
: 'peer request timed out');
1113+
});
1114+
1115+
await renderProvider();
1116+
emitWorkspaceManagerEvent({ type: 'workspace:active-changed', workspace: null });
1117+
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
1118+
1119+
expect(latestStatuses).toEqual({ 'conn-1': 'connected', 'conn-2': 'error' });
1120+
});
1121+
1122+
it('reads a newly rendered peer surface again instead of reusing another device status', async () => {
1123+
peerModeFlagMock.active = true;
1124+
const remoteWorkspace = createRemoteWorkspace();
1125+
workspaceManagerMock.getState.mockReturnValue({
1126+
loading: false,
1127+
openedWorkspaces: new Map([[remoteWorkspace.id, remoteWorkspace]]),
1128+
activeWorkspaceId: remoteWorkspace.id,
1129+
});
1130+
sshApiMock.isConnected.mockResolvedValue(true);
1131+
1132+
await renderProvider();
1133+
emitWorkspaceManagerEvent({ type: 'workspace:opened', workspace: remoteWorkspace });
1134+
await act(async () => { await Promise.resolve(); });
1135+
expect(latestStatuses['conn-1']).toBe('connected');
1136+
1137+
sshApiMock.isConnected.mockResolvedValue(false);
1138+
await act(async () => { activateSurface('peer-c'); await Promise.resolve(); });
1139+
1140+
expect(latestStatuses['conn-1']).toBe('disconnected');
1141+
});
1142+
10781143
it('cancels the pending reconnect timeout when peer device mode activates', async () => {
10791144
vi.useFakeTimers();
10801145

‎src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx‎

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFla
1616
import {
1717
getActiveSurfaceScope,
1818
isSurfaceChangedError,
19+
onSurfaceActivated,
1920
runInSurfaceScope,
2021
type SurfaceScope,
2122
} from '@/infrastructure/peer-device/deviceSurface';
23+
import { classifyPeerHostRefusal } from '@/infrastructure/peer-device/peerHostRefusal';
2224
import { useI18n } from '@/infrastructure/i18n';
2325
import { confirmWarning } from '@/infrastructure/confirm-dialog';
2426
import {
@@ -328,6 +330,40 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ children }
328330
setWorkspaceStatuses(prev => ({ ...prev, [connId]: st }));
329331
}, []);
330332

333+
/**
334+
* Peer Device Mode: the peer owns every SSH transport, so ask it instead of
335+
* leaving its remote workspaces without a status. A host that does not report
336+
* SSH transport state (a CLI peer) still owns the workspace it opened, so it
337+
* keeps the mirrored `connected` state; only an unreachable peer is an error.
338+
*/
339+
const peerStatusProbesRef = useRef(new Map<string, number>());
340+
const reconcilePeerRemoteStatuses = useCallback((force: boolean) => {
341+
if (!mountedRef.current || !isPeerDeviceModeActive()) return;
342+
const scope = getActiveSurfaceScope();
343+
const probes = peerStatusProbesRef.current;
344+
for (const workspace of workspaceManager.getState().openedWorkspaces.values()) {
345+
const connId = workspace.workspaceKind === WorkspaceKind.Remote ? workspace.connectionId?.trim() : '';
346+
if (!connId || probes.get(connId) === scope.epoch) continue;
347+
if (!force && workspaceStatusesRef.current[connId]) continue;
348+
probes.set(connId, scope.epoch);
349+
void runInSurfaceScope(scope, 'probe peer SSH status', () => sshApi.isConnected(connId))
350+
.then(
351+
(connected): ConnectionStatus => (connected ? 'connected' : 'disconnected'),
352+
(error): ConnectionStatus | null => {
353+
if (isSurfaceChangedError(error)) return null;
354+
if (classifyPeerHostRefusal(error)) return 'connected';
355+
log.warn('Failed to read peer SSH connection status', { connectionId: connId, error });
356+
return 'error';
357+
},
358+
)
359+
.then(status => {
360+
if (probes.get(connId) === scope.epoch) probes.delete(connId);
361+
if (!status || !mountedRef.current || !scope.isCurrent() || !isPeerDeviceModeActive()) return;
362+
setWorkspaceStatuses(prev => (prev[connId] === status ? prev : { ...prev, [connId]: status }));
363+
});
364+
}
365+
}, []);
366+
331367
const reportRemoteWorkspaceReconnectFailure = useCallback((workspace: RemoteWorkspace) => {
332368
if (isPeerDeviceModeActive()) {
333369
return;
@@ -969,6 +1005,7 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ children }
9691005
}
9701006
workspaceStatusTimeouts.current.clear();
9711007
log.info('Paused SSH heartbeat while peer device mode is active');
1008+
reconcilePeerRemoteStatuses(true);
9721009
} else if (detail?.active === false) {
9731010
if (showFileBrowserRef.current && connectionIdRef.current && isConnectedRef.current
9741011
&& usedConnectionIds().has(connectionIdRef.current)) {
@@ -979,7 +1016,11 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ children }
9791016
};
9801017
window.addEventListener('peer-mode:changed', onPeerModeChanged);
9811018
return () => window.removeEventListener('peer-mode:changed', onPeerModeChanged);
982-
}, [stopHeartbeat, usedConnectionIds]);
1019+
}, [reconcilePeerRemoteStatuses, stopHeartbeat, usedConnectionIds]);
1020+
1021+
// Statuses are keyed by connection ID, which another device may reuse: a
1022+
// newly rendered peer surface reads every one of its connections again.
1023+
useEffect(() => onSurfaceActivated(() => reconcilePeerRemoteStatuses(true)), [reconcilePeerRemoteStatuses]);
9831024

9841025
useEffect(() => {
9851026
return workspaceManager.addEventListener(event => {
@@ -988,6 +1029,15 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ children }
9881029
pruneHeartbeats();
9891030
return;
9901031
}
1032+
if (isPeerDeviceModeActive()) {
1033+
// Peer Device Mode: the peer owns the SSH connection lifecycle. Never
1034+
// run the controller-side reconnect/timeout removal path — those
1035+
// invokes route to the peer and would delete the peer's workspace.
1036+
// A hydrated list may arrive without per-workspace events, so every
1037+
// change reads the status of any connection that has none yet.
1038+
reconcilePeerRemoteStatuses(false);
1039+
return;
1040+
}
9911041
const workspace =
9921042
'workspace' in event && event.workspace?.workspaceKind === WorkspaceKind.Remote
9931043
? event.workspace
@@ -999,23 +1049,13 @@ export const SSHRemoteProvider: React.FC<SSHRemoteProviderProps> = ({ children }
9991049
if (!connId) {
10001050
return;
10011051
}
1002-
if (isPeerDeviceModeActive()) {
1003-
// Peer Device Mode: the peer owns the SSH connection lifecycle. Mirror
1004-
// its opened remote workspaces as connected and never run the
1005-
// controller-side reconnect/timeout removal path — those invokes route
1006-
// to the peer and would delete the peer's workspace.
1007-
if (workspaceStatusesRef.current[connId] !== 'connected') {
1008-
setWorkspaceStatus(connId, 'connected');
1009-
}
1010-
return;
1011-
}
10121052
if (!workspaceStatusesRef.current[connId]) {
10131053
setWorkspaceStatus(connId, 'connecting');
10141054
}
10151055
const metadataOnly = event.type === 'workspace:updated' || event.type === 'workspace:primary-assistant-changed';
10161056
void checkRemoteWorkspaceRef.current(!metadataOnly);
10171057
});
1018-
}, [pruneHeartbeats, restoredScope, setWorkspaceStatus]);
1058+
}, [pruneHeartbeats, reconcilePeerRemoteStatuses, restoredScope, setWorkspaceStatus]);
10191059

10201060
const connect = useCallback(async (
10211061
_connId: string,

‎src/web-ui/src/locales/en-US/common.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -678,8 +678,7 @@
678678
"connected": "Connected",
679679
"connecting": "Connecting",
680680
"disconnected": "Disconnected",
681-
"error": "Connection error",
682-
"unknown": "Status unknown"
681+
"error": "Connection error"
683682
}
684683
},
685684
"actions": {

‎src/web-ui/src/locales/zh-CN/common.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -678,8 +678,7 @@
678678
"connected": "已连接",
679679
"connecting": "连接中",
680680
"disconnected": "已断开",
681-
"error": "连接异常",
682-
"unknown": "状态未知"
681+
"error": "连接异常"
683682
}
684683
},
685684
"actions": {

‎src/web-ui/src/locales/zh-TW/common.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -678,8 +678,7 @@
678678
"connected": "已連線",
679679
"connecting": "連線中",
680680
"disconnected": "已中斷",
681-
"error": "連線異常",
682-
"unknown": "狀態未知"
681+
"error": "連線異常"
683682
}
684683
},
685684
"actions": {

0 commit comments

Comments
 (0)