-
Notifications
You must be signed in to change notification settings - Fork 2.3k
feat(pwa): reconnect on network/visibility change #2820
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| ] | ||
| } |
| 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"); | ||
| }); | ||
| }); |
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Banner hidden during offline gapMedium Severity
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> | ||
| ); | ||
| } | ||
| 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"'); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
components/WebSocketConnectionSurface.tsx:212handleVisibilityChangecallsresetWsReconnectBackoff()before the debounce check intriggerAutoReconnect. If the user switches tabs withinFORCED_WS_RECONNECT_DEBOUNCE_MSof the last reconnect,resetWsReconnectBackoffclearsnextRetryAtand setsreconnectPhaseto"idle", thentriggerAutoReconnectreturns early due to debounce. The connection remains stuck in"idle"with no scheduled retry and no watchdog recovery (which requiresreconnectPhase === "waiting"), leaving the user offline until another visibility event fires after the debounce window expires.🤖 Copy this AI Prompt to have your agent fix this: