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