diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts index 2e371b7d76..60fd539b55 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +++ b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { WsConnectionStatus } from "../rpc/wsConnectionState"; -import { shouldAutoReconnect } from "./WebSocketConnectionSurface"; +import { getBlockingStateDescriptor, shouldAutoReconnect } from "./WebSocketConnectionSurface"; function makeStatus(overrides: Partial = {}): WsConnectionStatus { return { @@ -25,6 +25,23 @@ function makeStatus(overrides: Partial = {}): WsConnectionSt } describe("WebSocketConnectionSurface.logic", () => { + it("shows a waiting-for-network initial surface when the browser starts offline", () => { + expect(getBlockingStateDescriptor("connecting", makeStatus({ online: false }))).toMatchObject({ + connectionLabel: "Waiting for network", + eyebrow: "Offline", + title: "Waiting for network", + tone: "offline", + }); + }); + + it("keeps the normal connecting surface when the browser is online", () => { + expect(getBlockingStateDescriptor("connecting", makeStatus())).toMatchObject({ + connectionLabel: "Opening WebSocket", + eyebrow: "Starting Session", + tone: "connecting", + }); + }); + it("forces reconnect on online when the app was offline", () => { expect( shouldAutoReconnect( diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 1855046a62..3ef3470d20 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -91,6 +91,14 @@ function describeSlowRpcAckToast(requests: ReadonlyArray): Re return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } +interface BlockingStateDescriptor { + readonly connectionLabel: string; + readonly description: string; + readonly eyebrow: string; + readonly title: string; + readonly tone: "connecting" | "error" | "offline"; +} + export function shouldAutoReconnect( status: WsConnectionStatus, trigger: WsAutoReconnectTrigger, @@ -113,44 +121,59 @@ export function shouldAutoReconnect( ); } -function buildBlockingCopy( +export function getBlockingStateDescriptor( uiState: WsConnectionUiState, status: WsConnectionStatus, -): { - readonly description: string; - readonly eyebrow: string; - readonly title: string; -} { +): BlockingStateDescriptor { + if (uiState === "connecting" && !status.online) { + return { + connectionLabel: "Waiting for network", + description: + "Your browser appears offline. The app will keep trying to open the initial WebSocket connection when network access returns.", + eyebrow: "Offline", + title: "Waiting for network", + tone: "offline", + }; + } + if (uiState === "connecting") { return { + connectionLabel: "Opening WebSocket", description: `Opening the WebSocket connection to the ${APP_DISPLAY_NAME} server and waiting for the initial config snapshot.`, eyebrow: "Starting Session", title: `Connecting to ${APP_DISPLAY_NAME}`, + tone: "connecting", }; } if (uiState === "offline") { return { + connectionLabel: "Waiting for network", description: "Your browser is offline, so the web client cannot reach the T3 server. Reconnect to the network and the app will retry automatically.", eyebrow: "Offline", title: "WebSocket connection unavailable", + tone: "offline", }; } if (status.lastError?.trim()) { return { + connectionLabel: "Retrying server connection", description: `${status.lastError} Verify that the T3 server is running and reachable, then reload the app if needed.`, eyebrow: "Connection Error", title: "Cannot reach the T3 server", + tone: "error", }; } return { + connectionLabel: "Retrying server connection", description: "The web client could not complete its initial WebSocket connection to the T3 server. It will keep retrying in the background.", eyebrow: "Connection Error", title: "Cannot reach the T3 server", + tone: "error", }; } @@ -193,10 +216,10 @@ function WebSocketBlockingState({ readonly status: WsConnectionStatus; readonly uiState: WsConnectionUiState; }) { - const copy = buildBlockingCopy(uiState, status); + const copy = getBlockingStateDescriptor(uiState, status); const disconnectedAt = formatConnectionMoment(status.disconnectedAt ?? status.lastErrorAt); const Icon = - uiState === "connecting" ? LoaderCircle : uiState === "offline" ? CloudOff : AlertTriangle; + copy.tone === "connecting" ? LoaderCircle : copy.tone === "offline" ? CloudOff : AlertTriangle; return (
@@ -214,7 +237,7 @@ function WebSocketBlockingState({

{copy.title}

- +
@@ -225,13 +248,7 @@ function WebSocketBlockingState({

Connection

-

- {uiState === "connecting" - ? "Opening WebSocket" - : uiState === "offline" - ? "Waiting for network" - : "Retrying server connection"} -

+

{copy.connectionLabel}

diff --git a/apps/web/src/rpc/wsConnectionState.test.ts b/apps/web/src/rpc/wsConnectionState.test.ts index f4e8689a58..e08763bf29 100644 --- a/apps/web/src/rpc/wsConnectionState.test.ts +++ b/apps/web/src/rpc/wsConnectionState.test.ts @@ -34,6 +34,20 @@ describe("wsConnectionState", () => { expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("offline"); }); + it("keeps the initial state as connecting when the browser starts offline", () => { + setBrowserOnlineStatus(false); + + expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("connecting"); + }); + + it("treats an initial failed websocket attempt as offline when the browser is offline", () => { + recordWsConnectionAttempt("ws://localhost:3020/ws"); + recordWsConnectionClosed({ code: 1006, reason: "offline" }); + setBrowserOnlineStatus(false); + + expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("offline"); + }); + it("stays in the initial connecting state until the first disconnect", () => { recordWsConnectionAttempt("ws://localhost:3020/ws");