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
6 changes: 5 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
} from "../threadRoutes";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { formatRelativeTimeLabel } from "../timestampFormat";
import { ConnectionStatusPill } from "./mobile/ConnectionStatusPill";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import { Kbd } from "./ui/kbd";
import {
Expand Down Expand Up @@ -2482,7 +2483,10 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
{wordmark}
</SidebarHeader>
) : (
<SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">{wordmark}</SidebarHeader>
<SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
{wordmark}
<ConnectionStatusPill />
</SidebarHeader>
);
});

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

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;

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>
);
}
16 changes: 16 additions & 0 deletions apps/web/src/components/mobile/ConnectionStatusPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useWsConnectionStatus } from "~/rpc/wsConnectionState";

export function ConnectionStatusPill() {
const status = useWsConnectionStatus();

if (status.phase !== "connected") return null;

const label = status.connectionLabel?.trim() || "T3 Server";

return (
<div className="sm:hidden inline-flex items-center gap-1.5 self-start rounded-full border border-success/30 bg-success/10 px-2 py-0.5 text-[11px] font-medium text-success-foreground">
<span className="size-1.5 rounded-full bg-success-foreground/90" aria-hidden />
<span className="truncate">Connected to {label}</span>
</div>
);
}
Loading
Loading