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
4 changes: 4 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<meta name="theme-color" content="#161616" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="T3 Code" />
<script>
(() => {
const LIGHT_BACKGROUND = "#ffffff";
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"tailwindcss": "^4.0.0",
"typescript": "catalog:",
"vite": "^8.0.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "catalog:",
"vitest-browser-react": "^2.0.5"
}
Expand Down
Binary file added apps/web/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/icon-512-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions apps/web/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "T3 Code",
"short_name": "T3 Code",
"description": "T3 Code — AI-assisted terminal and code session manager",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#161616",
"background_color": "#161616",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState";
import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import { ConnectionStatusBanner } from "./mobile/ConnectionStatusBanner";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
import {
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
Expand Down Expand Up @@ -3539,6 +3540,7 @@ export default function ChatView(props: ChatViewProps) {
onToggleDiff={onToggleDiff}
/>
</header>
<ConnectionStatusBanner />

{/* Error banner */}
<ProviderStatusBanner status={activeProviderStatus} />
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/WebSocketConnectionSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLat
import {
getWsConnectionStatus,
getWsConnectionUiState,
resetWsReconnectBackoff,
setBrowserOnlineStatus,
type WsConnectionStatus,
type WsConnectionUiState,
Expand Down Expand Up @@ -208,15 +209,25 @@ export function WebSocketConnectionCoordinator() {
const handleFocus = () => {
triggerAutoReconnect("focus");
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
const currentStatus = getWsConnectionStatus();
if (getWsConnectionUiState(currentStatus) !== "connected") {
resetWsReconnectBackoff();
triggerAutoReconnect("focus");
}
};
Comment on lines +212 to +219
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High components/WebSocketConnectionSurface.tsx:212

handleVisibilityChange calls resetWsReconnectBackoff() before the debounce check in triggerAutoReconnect. If the user switches tabs within FORCED_WS_RECONNECT_DEBOUNCE_MS of the last reconnect, resetWsReconnectBackoff clears nextRetryAt and sets reconnectPhase to "idle", then triggerAutoReconnect returns early due to debounce. The connection remains stuck in "idle" with no scheduled retry and no watchdog recovery (which requires reconnectPhase === "waiting"), leaving the user offline until another visibility event fires after the debounce window expires.

-    const handleVisibilityChange = () => {
-      if (document.visibilityState !== "visible") return;
-      const currentStatus = getWsConnectionStatus();
-      if (getWsConnectionUiState(currentStatus) !== "connected") {
-        resetWsReconnectBackoff();
-        triggerAutoReconnect("focus");
-      }
-    };
🤖 Copy this AI Prompt to have your agent fix this:
In file apps/web/src/components/WebSocketConnectionSurface.tsx around lines 212-219:

`handleVisibilityChange` calls `resetWsReconnectBackoff()` before the debounce check in `triggerAutoReconnect`. If the user switches tabs within `FORCED_WS_RECONNECT_DEBOUNCE_MS` of the last reconnect, `resetWsReconnectBackoff` clears `nextRetryAt` and sets `reconnectPhase` to `"idle"`, then `triggerAutoReconnect` returns early due to debounce. The connection remains stuck in `"idle"` with no scheduled retry and no watchdog recovery (which requires `reconnectPhase === "waiting"`), leaving the user offline until another visibility event fires after the debounce window expires.

Evidence trail:
apps/web/src/components/WebSocketConnectionSurface.tsx lines 212-218 (handleVisibilityChange calls resetWsReconnectBackoff then triggerAutoReconnect), lines 191-203 (triggerAutoReconnect with debounce check at line 198), lines 249-257 (watchdog requires reconnectPhase === 'waiting'). apps/web/src/rpc/wsConnectionState.ts lines 186-193 (resetWsReconnectBackoff sets reconnectPhase to 'idle', nextRetryAt to null). apps/web/src/rpc/wsConnectionState.ts lines 214-243 (applyDisconnectState only called on disconnect events, not relevant when already disconnected). REVIEWED_COMMIT.


syncBrowserOnlineStatus();
window.addEventListener("online", handleOnline);
window.addEventListener("offline", syncBrowserOnlineStatus);
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", syncBrowserOnlineStatus);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);

Expand Down
100 changes: 100 additions & 0 deletions apps/web/src/components/mobile/ConnectionStatusBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi, beforeEach } from "vitest";

import { ConnectionStatusBanner } from "./ConnectionStatusBanner";

const mockStatus = {
attemptCount: 0,
closeCode: null,
closeReason: null,
connectionLabel: null,
connectedAt: null,
disconnectedAt: null,
hasConnected: false,
lastError: null,
lastErrorAt: null,
nextRetryAt: null,
online: true,
phase: "idle" as const,
reconnectAttemptCount: 0,
reconnectMaxAttempts: 8,
reconnectPhase: "idle" as const,
socketUrl: null,
};

vi.mock("~/rpc/wsConnectionState", () => ({
useWsConnectionStatus: vi.fn(() => mockStatus),
getWsConnectionUiState: vi.fn((s) => {
if (s.phase === "connected") return "connected";
if (!s.online && s.disconnectedAt !== null) return "offline";
if (!s.hasConnected) return s.phase === "disconnected" ? "error" : "connecting";
return "reconnecting";
}),
}));

import { useWsConnectionStatus } from "~/rpc/wsConnectionState";

describe("ConnectionStatusBanner", () => {
beforeEach(() => {
vi.mocked(useWsConnectionStatus).mockReturnValue({ ...mockStatus });
});

it("renders nothing when connected", () => {
vi.mocked(useWsConnectionStatus).mockReturnValue({
...mockStatus,
phase: "connected",
hasConnected: true,
online: true,
});
const markup = renderToStaticMarkup(<ConnectionStatusBanner />);
expect(markup).toBe("");
});

it("renders offline banner when browser is offline and disconnected", () => {
vi.mocked(useWsConnectionStatus).mockReturnValue({
...mockStatus,
online: false,
disconnectedAt: new Date().toISOString(),
phase: "disconnected",
hasConnected: true,
});
const markup = renderToStaticMarkup(<ConnectionStatusBanner />);
expect(markup).toContain("No internet");
expect(markup).toContain("bg-warning/10");
});

it("renders reconnecting banner when disconnected after prior connection", () => {
vi.mocked(useWsConnectionStatus).mockReturnValue({
...mockStatus,
online: true,
phase: "disconnected",
hasConnected: true,
disconnectedAt: new Date().toISOString(),
});
const markup = renderToStaticMarkup(<ConnectionStatusBanner />);
expect(markup).toContain("Reconnecting");
});

it("renders connection lost banner on error state", () => {
vi.mocked(useWsConnectionStatus).mockReturnValue({
...mockStatus,
online: true,
phase: "disconnected",
hasConnected: false,
});
const markup = renderToStaticMarkup(<ConnectionStatusBanner />);
expect(markup).toContain("Connection lost");
});

it("has sm:hidden class so it only shows on mobile", () => {
vi.mocked(useWsConnectionStatus).mockReturnValue({
...mockStatus,
online: false,
disconnectedAt: new Date().toISOString(),
phase: "disconnected",
hasConnected: true,
});
const markup = renderToStaticMarkup(<ConnectionStatusBanner />);
expect(markup).toContain("sm:hidden");
});
});
43 changes: 43 additions & 0 deletions apps/web/src/components/mobile/ConnectionStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { AlertCircleIcon, WifiOffIcon } from "lucide-react";

import { getWsConnectionUiState, useWsConnectionStatus } from "~/rpc/wsConnectionState";
import { Spinner } from "~/components/ui/spinner";

export function ConnectionStatusBanner() {
const status = useWsConnectionStatus();
const uiState = getWsConnectionUiState(status);

if (uiState === "connected") return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Banner hidden during offline gap

Medium Severity

ConnectionStatusBanner hides whenever getWsConnectionUiState is connected, but that helper returns connected from status.phase alone and ignores status.online. After airplane mode or offline, the banner stays hidden until the WebSocket closes, so mobile users miss the intended “No internet” warning during that window.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a53ab76. Configure here.


const isOffline = uiState === "offline";

return (
<div
role="status"
aria-live="polite"
className={
"fixed top-0 inset-x-0 z-30 sm:hidden flex items-center gap-2 px-4 py-2 text-xs border-b pt-[env(safe-area-inset-top)] " +
(isOffline
? "bg-warning/10 border-warning/20 text-warning-foreground"
: "bg-destructive/10 border-destructive/20 text-destructive-foreground")
}
>
{isOffline ? (
<>
<WifiOffIcon className="size-3.5 shrink-0" />
No internet
</>
) : uiState === "reconnecting" ? (
<>
<Spinner className="size-3.5 shrink-0" />
Reconnecting…
</>
) : (
<>
<AlertCircleIcon className="size-3.5 shrink-0" />
Connection lost
</>
)}
</div>
);
}
90 changes: 90 additions & 0 deletions apps/web/src/components/mobile/MobileAccessSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { AdvertisedEndpoint } from "@t3tools/contracts";

import { MobileAccessSection } from "./MobileAccessSection";

vi.mock("~/environments/primary", () => ({
createServerPairingCredential: vi.fn().mockResolvedValue({
credential: "test-credential-abc",
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
}),
}));

function makeTailscaleEndpoint(
status: AdvertisedEndpoint["status"] = "available",
): AdvertisedEndpoint {
return {
id: "tailscale-magicdns:mintrose.tail98085b.ts.net",
label: "Tailscale HTTPS",
provider: { id: "tailscale", label: "Tailscale", kind: "tunnel", isAddon: false },
httpBaseUrl: "https://mintrose.tail98085b.ts.net",
wsBaseUrl: "wss://mintrose.tail98085b.ts.net",
reachability: "private-network",
compatibility: { hostedHttpsApp: "compatible", desktopApp: "compatible" },
source: "desktop-core",
status,
};
}

describe("MobileAccessSection", () => {
it("disables the toggle and shows install hint when no Tailscale endpoint is present", () => {
const markup = renderToStaticMarkup(
<MobileAccessSection
endpoints={[]}
isTailscaleServeEnabled={false}
isUpdating={false}
onEnable={() => {}}
onDisable={() => {}}
/>,
);

expect(markup).toContain("Install Tailscale on this machine");
expect(markup).not.toContain("mobile-access-qr-frame");
});

it("shows the connect description when a Tailscale endpoint is present but Serve is off", () => {
const markup = renderToStaticMarkup(
<MobileAccessSection
endpoints={[makeTailscaleEndpoint("unavailable")]}
isTailscaleServeEnabled={false}
isUpdating={false}
onEnable={() => {}}
onDisable={() => {}}
/>,
);

expect(markup).toContain("install T3 Code on your phone");
expect(markup).not.toContain("mobile-access-qr-frame");
});

it("renders the reachable panel when Tailscale Serve is enabled and endpoint is available", () => {
// useEffect does not fire during renderToStaticMarkup, so we verify the
// outer reachable panel mounts; inner QR / spinner depend on async state.
const markup = renderToStaticMarkup(
<MobileAccessSection
endpoints={[makeTailscaleEndpoint("available")]}
isTailscaleServeEnabled={true}
isUpdating={false}
onEnable={() => {}}
onDisable={() => {}}
/>,
);

expect(markup).toContain('data-testid="mobile-access-reachable-panel"');
});

it("does not render the reachable panel when Tailscale Serve is disabled", () => {
const markup = renderToStaticMarkup(
<MobileAccessSection
endpoints={[makeTailscaleEndpoint("available")]}
isTailscaleServeEnabled={false}
isUpdating={false}
onEnable={() => {}}
onDisable={() => {}}
/>,
);

expect(markup).not.toContain('data-testid="mobile-access-reachable-panel"');
});
});
Loading
Loading