Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -3496,6 +3497,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
</header>

{/* Error banner */}
<ConnectionStatusBanner />
<ProviderHealthBanner status={activeProviderStatus} />
<ThreadErrorBanner
error={activeThread.error}
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/chat/ConnectionStatusBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi, beforeAll } from "vitest";
import { ConnectionStatusBanner } from "./ConnectionStatusBanner";

// Mock wsNativeApi
vi.mock("../../wsNativeApi", () => {
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(
<ConnectionStatusBanner initialIsOnline={true} initialTransportState="open" />,
);
expect(markup).toBe("");
});

it("renders offline message when initialIsOnline is false", async () => {
const markup = renderToStaticMarkup(
<ConnectionStatusBanner initialIsOnline={false} initialTransportState="open" />,
);
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(
<ConnectionStatusBanner initialIsOnline={true} initialTransportState="closed" />,
);
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(
<ConnectionStatusBanner initialIsOnline={true} initialTransportState="reconnecting" />,
);
expect(markup).toContain("Disconnected from server");
expect(markup).toContain("Attempting to reconnect");
});

it("renders nothing when transport is disposed", () => {
const markup = renderToStaticMarkup(
<ConnectionStatusBanner initialIsOnline={true} initialTransportState="disposed" />,
);
expect(markup).toBe("");
});
});
62 changes: 62 additions & 0 deletions apps/web/src/components/chat/ConnectionStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<TransportState>(
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();
};
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Online state not re-synced inside useEffect

Low Severity

The transport state is properly synchronized inside the useEffect because onTransportStateChange immediately invokes the listener with the current state (line 27–29). However, isOnline is only read once in the useState initializer (line 13–14) and is never re-synced inside the effect. If an offline event fires between the initial render and the effect setup, the event is missed and isOnline remains stale — the banner stays hidden until the next online/offline event. Adding a setIsOnline(navigator.onLine) call inside the effect after registering the event listeners would close this gap, consistent with how transport state is already handled.

Fix in Cursor Fix in Web


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 (
<div className="pt-3 mx-auto max-w-3xl">
<Alert variant="warning">
{!isOnline ? <WifiOffIcon className="size-4" /> : <CloudOffIcon className="size-4" />}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
</div>
);
});
14 changes: 13 additions & 1 deletion apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down Expand Up @@ -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;

Expand Down
33 changes: 27 additions & 6 deletions apps/web/src/wsTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -55,6 +55,7 @@ export class WsTransport {
private nextId = 1;
private readonly pending = new Map<string, PendingRequest>();
private readonly listeners = new Map<string, Set<(message: WsPush) => void>>();
private readonly stateListeners = new Set<(state: TransportState) => void>();
private readonly latestPushByChannel = new Map<string, WsPush>();
private readonly outboundQueue: string[] = [];
private reconnectAttempt = 0;
Expand All @@ -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<T = unknown>(
method: string,
params?: unknown,
Expand Down Expand Up @@ -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;
Expand All @@ -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();
});
Expand All @@ -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();
});

Expand Down