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();
});