diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx
index 7fcbaf462d..f8d5efd8a2 100644
--- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx
+++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx
@@ -30,6 +30,7 @@ import {
import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost';
import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition';
import { useSettingsStore } from '@/app/scenes/settings/settingsStore';
+import { PeerConnectionStatus } from '@/infrastructure/peer-device/PeerConnectionStatus';
import DeviceStatusControl from './DeviceStatusControl';
import AppearanceQuickSwitchMenuItem from './AppearanceQuickSwitchMenuItem';
@@ -164,6 +165,7 @@ const PersistentFooterActions: React.FC = () => {
return (
<>
+
= ({ className = '' }) => {
isExiting={transitionDir === 'returning'}
/>
-
{/* Hello stays available across every client scene, including Welcome. */}
diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts
index dd61b4fc93..16c431856d 100644
--- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts
+++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts
@@ -282,6 +282,18 @@ describe('PeerConnectionManager health', () => {
await manager.disposeAll();
});
+ it('does not flash a reconnect warning when the roster misses a healthy host', async () => {
+ const rpc = createRpc();
+ const manager = createManager(rpc.deviceRpc);
+ const seen = observe(manager);
+ await manager.connect('peer-1', 'Studio');
+ manager.reportPresence([]);
+ await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS);
+ expect(seen.healthTimeline()).toEqual(['connecting', 'ready']);
+ expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(2);
+ await manager.disposeAll();
+ });
+
it('recovers a 2.3 second presence gap on the same attachment', async () => {
const rpc = createRpc();
const manager = createManager(rpc.deviceRpc);
@@ -289,7 +301,7 @@ describe('PeerConnectionManager health', () => {
rpc.failAll();
manager.reportPresence(['peer-2']);
- expect(connection.getState()).toMatchObject({ health: 'degraded', consecutiveFailures: 0 });
+ expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 });
await vi.advanceTimersByTimeAsync(2_300);
rpc.failNext(0);
@@ -333,12 +345,12 @@ describe('PeerConnectionManager health', () => {
.rejects.toThrow('relay unavailable');
}));
- expect(connection.getState()).toMatchObject({ health: 'degraded', consecutiveFailures: 0 });
+ expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 });
expect(rpc.commands().filter(command => command === 'peer_mode_ping')).toHaveLength(1);
await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS);
expect(connection.getState()).toMatchObject({ health: 'ready', consecutiveFailures: 0 });
expect(rpc.commands().filter(command => command === 'peer_mode_ping')).toHaveLength(2);
- expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(2);
+ expect(rpc.commands().filter(command => command === 'peer_control_attach')).toHaveLength(1);
});
it('does not postpone a health retry when more product requests or presence updates fail', async () => {
@@ -732,7 +744,7 @@ describe('PeerConnectionManager recovery races', () => {
await connection.adapter.request('get_opened_workspaces', { request: {} });
await vi.advanceTimersByTimeAsync(KEEPALIVE_MS * 2);
expect(attachCount).toBe(2);
- expect(connection.getState().health).toBe('degraded');
+ expect(connection.getState().health).toBe('ready');
reattach.resolve(JSON.stringify({ resp: 'host_invoke_result', ok: true, value: null }));
await vi.advanceTimersByTimeAsync(0);
diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts
index fc17138645..2a4ca91c66 100644
--- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts
+++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts
@@ -196,6 +196,8 @@ interface ConnectionEntry {
timer: ReturnType | null;
disposed: boolean;
presenceOnline: boolean | null;
+ probeRequested: boolean;
+ needsReattach: boolean;
healthCheckInFlight: Promise | null;
reattachInFlight: Promise | null;
handle: PeerConnection;
@@ -347,7 +349,7 @@ export class PeerConnectionManager {
entry.presenceOnline = online.has(entry.deviceId);
if (!entry.presenceOnline && wasOnline !== false) {
this.requestRecovery(entry, 'presence');
- } else if (entry.presenceOnline && wasOnline === false && entry.health === 'degraded') {
+ } else if (entry.presenceOnline && wasOnline === false && (entry.health === 'degraded' || entry.probeRequested)) {
this.cancelTimer(entry);
void this.runHealthCheck(entry);
}
@@ -379,6 +381,8 @@ export class PeerConnectionManager {
timer: null,
disposed: false,
presenceOnline: null,
+ probeRequested: false,
+ needsReattach: false,
healthCheckInFlight: null,
reattachInFlight: null,
handle: {
@@ -556,8 +560,9 @@ export class PeerConnectionManager {
const check = this.checkHealth(entry).finally(() => {
if (entry.healthCheckInFlight !== check) return;
entry.healthCheckInFlight = null;
+ entry.probeRequested = false;
if (this.entries.get(entry.deviceId) !== entry || entry.disposed) return;
- if (entry.health === 'ready') this.scheduleKeepalive(entry);
+ if (entry.health === 'ready' && !entry.needsReattach) this.scheduleKeepalive(entry);
else this.scheduleReconnect(entry);
});
entry.healthCheckInFlight = check;
@@ -569,7 +574,8 @@ export class PeerConnectionManager {
const capabilities = await this.probeCapabilities(entry);
// A request/presence failure may have arrived while the ping was in
// flight. Check the current state, not the state at probe start.
- if (entry.health === 'degraded') {
+ if (entry.health === 'degraded' || entry.needsReattach) {
+ entry.needsReattach = false;
const reattach = this.sendAttach(entry.deviceId);
entry.reattachInFlight = reattach;
try {
@@ -627,16 +633,15 @@ export class PeerConnectionManager {
if (this.entries.get(entry.deviceId) !== entry || entry.disposed || entry.health !== 'ready') {
return;
}
- entry.health = 'degraded';
- log.warn('Peer connection degraded; checking control link', {
- deviceId: entry.deviceId,
- reason,
- action,
+ // Roster omissions and product timeouts are hints, not proof of a broken
+ // control link. Verify silently; only a failed dedicated probe degrades UI.
+ if (reason === 'presence') entry.needsReattach = true;
+ if (entry.probeRequested) return;
+ entry.probeRequested = true;
+ log.debug('Checking peer control link after a transport hint', {
+ deviceId: entry.deviceId, reason, action,
});
- // One timer/probe owns recovery. A burst of failed product requests must
- // neither start overlapping handshakes nor push the retry further away.
if (!entry.healthCheckInFlight) this.scheduleReconnect(entry);
- this.publish();
}
private cancelTimer(entry: ConnectionEntry): void {
diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss
index 9badbadf1d..53f1240e9e 100644
--- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss
+++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.scss
@@ -1,12 +1,35 @@
.peer-connection-status {
flex-shrink: 0;
- margin: 8px 12px;
+ min-width: 0;
+ max-width: 320px;
+ margin: 0 12px;
+ color: var(--openbitfun-color-content-secondary);
+ font-size: var(--openbitfun-type-label-sm-font-size);
&__content {
display: flex;
align-items: center;
justify-content: space-between;
- flex-wrap: wrap;
- gap: 8px 16px;
+ gap: 8px;
+ min-height: 32px;
+ }
+
+ &__label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+
+ svg {
+ flex-shrink: 0;
+ color: var(--openbitfun-color-status-warning-emphasis);
+ }
+ }
+
+ &__error {
+ display: block;
+ overflow-wrap: anywhere;
+ padding-bottom: 8px;
+ color: var(--openbitfun-color-status-danger-content);
}
}
diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx
index 185ee07597..668de8bc0c 100644
--- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx
+++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.test.tsx
@@ -45,7 +45,7 @@ describe('PeerConnectionStatus', () => {
it('shows the current peer and a manual return action until recovery', async () => {
await render();
- expect(container.querySelector('[role="alert"]')?.textContent).toContain('Reconnecting to Studio');
+ expect(container.querySelector('[role="status"]')?.textContent).toContain('peerConnection.reconnectingShort');
await act(async () => container.querySelector('button')!.click());
expect(peer.switchToLocal).toHaveBeenCalledExactlyOnceWith('manual');
expect(peer.disconnectDevice).not.toHaveBeenCalled();
diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx
index d2fec8645e..0dddec910d 100644
--- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx
+++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionStatus.tsx
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
-import { Alert, Button } from '@openbitfun/ui';
+import { Button, Tooltip } from '@openbitfun/ui';
import { useI18n } from '@/infrastructure/i18n';
import { usePeerDeviceModeOptional } from './peerDeviceContextState';
+import { WifiOff } from 'lucide-react';
import './PeerConnectionStatus.scss';
/** Keep the selected host visible while its control link recovers. */
@@ -35,23 +36,22 @@ export const PeerConnectionStatus: React.FC = () => {
data-openbitfun-component="peer-device"
data-openbitfun-part="connectionStatus"
>
-
- {t('peerConnection.reconnecting', { name: peer.peerMode.deviceName })}
- { void returnLocal(); }}>
- {t(returning ? 'deviceOverview.returningToThisDevice' : 'deviceOverview.backToThisDevice')}
-
+
+
+
+
+ {t('peerConnection.reconnectingShort')}
- )}
- description={error ?? undefined}
- />
+
+ { void returnLocal(); }}>
+ {t(returning ? 'peerConnection.returningShort' : 'peerConnection.returnLocalShort')}
+
+
+ {error && {error} }
);
};
diff --git a/src/web-ui/src/infrastructure/peer-device/README.md b/src/web-ui/src/infrastructure/peer-device/README.md
index 88b869179f..eef7286dc0 100644
--- a/src/web-ui/src/infrastructure/peer-device/README.md
+++ b/src/web-ui/src/infrastructure/peer-device/README.md
@@ -273,8 +273,11 @@ Still to migrate, in order: the interaction mailbox, then history positions.
exposes equivalent replayable attach state and a negotiated response path.
13. **Weak links use bounded, idempotency-aware recovery.** Presence gaps
- and product RPC timeouts keep an attached peer's surface selected and show
- a reconnecting notice. A single dedicated handshake owns recovery and its
+ and product RPC timeouts keep an attached peer's surface selected and request
+ a silent control probe. Only a failed control ping or re-attach marks the
+ connection degraded and shows the compact status beside the device controls.
+ A roster omission still requests event re-attachment even when ping succeeds;
+ a failed product request alone does not require re-attachment. A single dedicated handshake owns recovery and its
retry counter; concurrent product failures must not consume it or postpone
the timer. Retry delay is capped, not retry lifetime. A successful recovery
re-attaches event delivery before publishing `ready`, without changing the
diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json
index c8e0083076..3ef6be9e6f 100644
--- a/src/web-ui/src/locales/en-US/common.json
+++ b/src/web-ui/src/locales/en-US/common.json
@@ -719,6 +719,9 @@
"errorCreateFailed": "Failed to create project"
},
"peerConnection": {
+ "reconnectingShort": "Reconnecting…",
+ "returnLocalShort": "Go local",
+ "returningShort": "Returning…",
"reconnecting": "Reconnecting to {{name}}. Content may be out of date."
},
"peerDirectoryPicker": {
diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json
index 33b65577c3..9ac6217de8 100644
--- a/src/web-ui/src/locales/zh-CN/common.json
+++ b/src/web-ui/src/locales/zh-CN/common.json
@@ -719,6 +719,9 @@
"errorCreateFailed": "创建工作区失败"
},
"peerConnection": {
+ "reconnectingShort": "正在重连…",
+ "returnLocalShort": "返回本机",
+ "returningShort": "正在返回…",
"reconnecting": "正在重新连接「{{name}}」。内容可能暂未更新。"
},
"peerDirectoryPicker": {
diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json
index 593afcba08..58cc951e8f 100644
--- a/src/web-ui/src/locales/zh-TW/common.json
+++ b/src/web-ui/src/locales/zh-TW/common.json
@@ -719,6 +719,9 @@
"errorCreateFailed": "建立工作區失敗"
},
"peerConnection": {
+ "reconnectingShort": "正在重新連線…",
+ "returnLocalShort": "返回本機",
+ "returningShort": "正在返回…",
"reconnecting": "正在重新連線至「{{name}}」。內容可能尚未更新。"
},
"peerDirectoryPicker": {