diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9bc4010b4b..ed8d8d6cac 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -162,6 +162,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; +import { ConnectionStatusBanner } from "./chat/ConnectionStatusBanner"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { @@ -3496,6 +3497,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} + { + return { + onTransportStateChange: vi.fn(() => { + return () => {}; + }), + }; +}); + +describe("ConnectionStatusBanner", () => { + beforeAll(() => { + vi.stubGlobal("navigator", { + onLine: true, + }); + }); + + it("renders nothing when online and transport is open", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toBe(""); + }); + + it("renders offline message when initialIsOnline is false", async () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("No internet connection"); + expect(markup).toContain("T3 Code is offline"); + }); + + it("renders disconnected message when initialTransportState is closed", async () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Disconnected from server"); + expect(markup).toContain("connection to the T3 Code server was lost"); + }); + + it("renders reconnecting message when initialTransportState is reconnecting", async () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Disconnected from server"); + expect(markup).toContain("Attempting to reconnect"); + }); + + it("renders nothing when transport is disposed", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toBe(""); + }); +}); diff --git a/apps/web/src/components/chat/ConnectionStatusBanner.tsx b/apps/web/src/components/chat/ConnectionStatusBanner.tsx new file mode 100644 index 0000000000..cdd159fe1a --- /dev/null +++ b/apps/web/src/components/chat/ConnectionStatusBanner.tsx @@ -0,0 +1,62 @@ +import { memo, useEffect, useState } from "react"; +import { onTransportStateChange, type TransportState } from "../../wsNativeApi"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { WifiOffIcon, CloudOffIcon } from "lucide-react"; + +export const ConnectionStatusBanner = memo(function ConnectionStatusBanner({ + initialIsOnline, + initialTransportState, +}: { + initialIsOnline?: boolean; + initialTransportState?: TransportState; +}) { + const [isOnline, setIsOnline] = useState( + initialIsOnline ?? (typeof navigator !== "undefined" ? navigator.onLine : true), + ); + const [transportState, setTransportState] = useState( + initialTransportState ?? "open", + ); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + const unsub = onTransportStateChange((state) => { + setTransportState(state); + }); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + unsub(); + }; + }, []); + + const shouldShow = + !isOnline || + (transportState !== "open" && transportState !== "connecting" && transportState !== "disposed"); + + if (!shouldShow) { + return null; + } + + const title = !isOnline ? "No internet connection" : "Disconnected from server"; + const message = !isOnline + ? "T3 Code is offline. Please check your internet connection." + : transportState === "reconnecting" + ? "Attempting to reconnect to the T3 Code server..." + : "The connection to the T3 Code server was lost."; + + return ( +
+ + {!isOnline ? : } + {title} + {message} + +
+ ); +}); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 042875f6f7..421edf6048 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -11,7 +11,8 @@ import { } from "@t3tools/contracts"; import { showContextMenuFallback } from "./contextMenuFallback"; -import { WsTransport } from "./wsTransport"; +import { WsTransport, type TransportState } from "./wsTransport"; +export type { TransportState }; let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); @@ -64,6 +65,17 @@ export function onServerConfigUpdated( }; } +/** + * Subscribe to WebSocket transport state changes. + */ +export function onTransportStateChange(listener: (state: TransportState) => void): () => void { + if (!instance) { + // Force instance creation if it doesn't exist + createWsNativeApi(); + } + return instance!.transport.onStateChange(listener); +} + export function createWsNativeApi(): NativeApi { if (instance) return instance.api; diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 12c9a6d958..445ce7eaa5 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -25,7 +25,7 @@ interface RequestOptions { readonly timeoutMs?: number | null; } -type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed"; +export type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed"; const REQUEST_TIMEOUT_MS = 60_000; const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; @@ -55,6 +55,7 @@ export class WsTransport { private nextId = 1; private readonly pending = new Map(); private readonly listeners = new Map void>>(); + private readonly stateListeners = new Set<(state: TransportState) => void>(); private readonly latestPushByChannel = new Map(); private readonly outboundQueue: string[] = []; private reconnectAttempt = 0; @@ -76,6 +77,26 @@ export class WsTransport { this.connect(); } + onStateChange(listener: (state: TransportState) => void): () => void { + listener(this.state); + this.stateListeners.add(listener); + return () => { + this.stateListeners.delete(listener); + }; + } + + private setState(next: TransportState) { + if (this.state === next) return; + this.state = next; + for (const listener of this.stateListeners) { + try { + listener(next); + } catch { + // Swallow listener errors + } + } + } + async request( method: string, params?: unknown, @@ -152,7 +173,7 @@ export class WsTransport { dispose() { this.disposed = true; - this.state = "disposed"; + this.setState("disposed"); if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; @@ -174,12 +195,12 @@ export class WsTransport { return; } - this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting"; + this.setState(this.reconnectAttempt > 0 ? "reconnecting" : "connecting"); const ws = new WebSocket(this.url); ws.addEventListener("open", () => { this.ws = ws; - this.state = "open"; + this.setState("open"); this.reconnectAttempt = 0; this.flushQueue(); }); @@ -201,10 +222,10 @@ export class WsTransport { } } if (this.disposed) { - this.state = "disposed"; + this.setState("disposed"); return; } - this.state = "closed"; + this.setState("closed"); this.scheduleReconnect(); });