diff --git a/ui/src/components/AppUpdateBanner.tsx b/ui/src/components/AppUpdateBanner.tsx new file mode 100644 index 00000000000..64e196dcbb5 --- /dev/null +++ b/ui/src/components/AppUpdateBanner.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { RefreshCw, X } from "lucide-react"; +import { getLoadedBundleId, startAppUpdateWatcher } from "../lib/app-update-check"; + +/** + * Shows a persistent, dismissible banner when a newer frontend build has been + * deployed while this tab was open, so long-lived tabs don't keep running a + * stale (possibly buggy) bundle until they happen to reload. See + * `lib/app-update-check.ts` for how a new build is detected. + */ +export function AppUpdateBanner() { + const [updateAvailable, setUpdateAvailable] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + const stop = startAppUpdateWatcher({ + currentBundleId: getLoadedBundleId(), + onUpdateAvailable: () => setUpdateAvailable(true), + }); + return stop; + }, []); + + if (!updateAvailable || dismissed) return null; + + return ( + + ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index ccb9fb3f4f9..f12cdbae9bb 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -15,6 +15,7 @@ import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet"; import { ToastViewport } from "./ToastViewport"; +import { AppUpdateBanner } from "./AppUpdateBanner"; import { MobileBottomNav } from "./MobileBottomNav"; import { WorktreeBanner } from "./WorktreeBanner"; import { DevRestartBanner } from "./DevRestartBanner"; @@ -658,6 +659,7 @@ export function Layout() { + ); diff --git a/ui/src/lib/app-update-check.test.ts b/ui/src/lib/app-update-check.test.ts new file mode 100644 index 00000000000..0cb5d98fc46 --- /dev/null +++ b/ui/src/lib/app-update-check.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getLoadedBundleId, + parseBundleIdFromHtml, + startAppUpdateWatcher, +} from "./app-update-check"; + +describe("parseBundleIdFromHtml", () => { + it("extracts the hashed entry bundle from index.html", () => { + const html = ` + + `; + expect(parseBundleIdFromHtml(html)).toBe("index-B85r4qUa.js"); + }); + + it("returns null when no entry bundle is present", () => { + expect(parseBundleIdFromHtml("")).toBeNull(); + }); +}); + +describe("getLoadedBundleId", () => { + it("reads the bundle filename from a script tag", () => { + const doc = { + querySelectorAll: () => [ + { getAttribute: () => "/assets/vendor-x.js" }, + { getAttribute: () => "https://host/assets/index-CB24WRl4.js" }, + ], + } as unknown as Document; + expect(getLoadedBundleId(doc)).toBe("index-CB24WRl4.js"); + }); +}); + +describe("startAppUpdateWatcher", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("fires once when the deployed bundle differs, then latches", async () => { + const onUpdate = vi.fn(); + let deployed = "index-AAA.js"; + const stop = startAppUpdateWatcher({ + currentBundleId: "index-AAA.js", + onUpdateAvailable: onUpdate, + intervalMs: 1000, + getDeployedBundleId: () => Promise.resolve(deployed), + }); + + await vi.advanceTimersByTimeAsync(1000); + expect(onUpdate).not.toHaveBeenCalled(); // same bundle + + deployed = "index-BBB.js"; + await vi.advanceTimersByTimeAsync(1000); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith("index-BBB.js"); + + // latched — does not fire again on subsequent polls + await vi.advanceTimersByTimeAsync(5000); + expect(onUpdate).toHaveBeenCalledTimes(1); + stop(); + }); + + it("does nothing when the current bundle id is unknown", () => { + const onUpdate = vi.fn(); + const stop = startAppUpdateWatcher({ + currentBundleId: null, + onUpdateAvailable: onUpdate, + intervalMs: 1000, + getDeployedBundleId: () => Promise.resolve("index-BBB.js"), + }); + vi.advanceTimersByTime(5000); + expect(onUpdate).not.toHaveBeenCalled(); + stop(); + }); + + it("stop() halts polling", async () => { + const onUpdate = vi.fn(); + const getter = vi.fn(() => Promise.resolve("index-AAA.js")); + const stop = startAppUpdateWatcher({ + currentBundleId: "index-AAA.js", + onUpdateAvailable: onUpdate, + intervalMs: 1000, + getDeployedBundleId: getter, + }); + stop(); + await vi.advanceTimersByTimeAsync(5000); + expect(getter).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/app-update-check.ts b/ui/src/lib/app-update-check.ts new file mode 100644 index 00000000000..1467d644e73 --- /dev/null +++ b/ui/src/lib/app-update-check.ts @@ -0,0 +1,91 @@ +/** + * Detects when a new frontend build has been deployed while a tab is still open. + * + * Long-lived tabs keep running the JS bundle they loaded at page-load, so a + * deployed fix (e.g. the React perf-measure memory fix) doesn't reach them until + * they happen to reload. The service worker (`public/sw.js`) is a static file + * that doesn't change between builds, so the normal SW `updatefound` flow never + * fires for a JS-only deploy. Instead we compare the *hashed bundle filename* + * (which Vite changes every build) against the freshly-served `index.html`. + */ + +// Vite emits the entry as `assets/index-.js`. Match just the filename. +const BUNDLE_FILENAME_RE = /(index-[A-Za-z0-9_-]+\.js)/; + +/** The bundle filename this page actually loaded, from its