From 0261781822b11d01fed4dfd0f6bea66042d2bf47 Mon Sep 17 00:00:00 2001
From: Alex Naidis
Date: Sun, 15 Nov 2026 00:08:58 +0100
Subject: [PATCH 1/3] fix(web): update dialog no longer offers Update for 0
machines
When the last pending update finished while the Update everything dialog
was open, it showed "Update 0 machines?" with an empty table and an
Update button that did nothing. On web the dialog vanished instead and
reopened by itself at the next update.
The dialog now says everything is up to date (or that nothing can be
updated right now, when machines were skipped) and offers only Close.
The sidebar keeps the dialog mounted as a stable sibling of the pill so
web keeps it open when the pill hides.
---
.../SidebarUpdatePill.browser.test.tsx | 59 +++--
.../components/sidebar/SidebarUpdatePill.tsx | 204 +++++++++---------
.../SidebarUpdatePill.web.browser.test.tsx | 67 ++++++
.../sidebar/UpdateEverythingDialog.tsx | 137 +++++++-----
4 files changed, 297 insertions(+), 170 deletions(-)
create mode 100644 apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
index 0f751bc52aef6..d1aa2595f66b3 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
@@ -11,8 +11,14 @@ const mocks = vi.hoisted(() => {
const listeners = new Set<() => void>();
return {
update: null as DesktopUpdateState | null,
- hasUpdates: false,
- rows: [] as ReadonlyArray,
+ everything: {
+ hasUpdates: false,
+ summary: "Update everything",
+ version: null,
+ providerCount: 0,
+ rows: [] as ReadonlyArray,
+ restarts: [],
+ },
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -35,15 +41,12 @@ vi.mock("../../env", async (importOriginal) => ({
...(await importOriginal()),
isElectron: true,
}));
-vi.mock("./useUpdateEverything", () => ({
- useUpdateEverything: () => ({
- hasUpdates: mocks.hasUpdates,
- summary: "Update everything",
- version: null,
- providerCount: 0,
- rows: mocks.rows,
- restarts: [],
- }),
+vi.mock("./useUpdateEverything", async () => ({
+ useUpdateEverything: (await import("react")).useSyncExternalStore.bind(
+ null,
+ mocks.subscribe,
+ () => mocks.everything,
+ ),
}));
function nightlyUpdate(releaseNotes: DesktopUpdateState["releaseNotes"]): DesktopUpdateState {
@@ -79,8 +82,7 @@ function setup(
}: { hasUpdates?: boolean; rows?: ReadonlyArray } = {},
) {
mocks.update = update;
- mocks.hasUpdates = hasUpdates;
- mocks.rows = rows;
+ mocks.everything = { ...mocks.everything, hasUpdates, rows };
}
function publish(update: DesktopUpdateState | null) {
@@ -90,6 +92,14 @@ function publish(update: DesktopUpdateState | null) {
});
}
+const LAPTOP_CODEX: UpdateEverythingRow = {
+ environmentId: null,
+ label: "Laptop",
+ app: null,
+ skipped: null,
+ providers: ["Codex"],
+};
+
const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]');
describe("SidebarUpdatePill release notes", () => {
@@ -127,7 +137,7 @@ describe("SidebarUpdatePill release notes", () => {
describe("SidebarUpdatePill update everything", () => {
it("does not refocus the update button after a pointer cancel", async () => {
- setup(null, { hasUpdates: true });
+ setup(null, { hasUpdates: true, rows: [LAPTOP_CODEX] });
await using screen = await render();
const button = screen.getByRole("button", { name: "Update everything" });
await userEvent.click(button);
@@ -140,7 +150,7 @@ describe("SidebarUpdatePill update everything", () => {
});
it("returns focus to the update button after Escape", async () => {
- setup(null, { hasUpdates: true });
+ setup(null, { hasUpdates: true, rows: [LAPTOP_CODEX] });
await using screen = await render();
const button = screen.getByRole("button", { name: "Update everything" });
await userEvent.click(button);
@@ -167,4 +177,23 @@ describe("SidebarUpdatePill update everything", () => {
await expect.element(row.getByRole("cell", { name: label, exact: true })).toBeVisible();
await expect.element(row.getByRole("cell", { name: "Claude", exact: true })).toBeVisible();
});
+
+ it("says everything is up to date when the updates finish while it is open", async () => {
+ setup(null, { hasUpdates: true, rows: [LAPTOP_CODEX] });
+ await using screen = await render();
+ await userEvent.click(screen.getByRole("button", { name: "Update everything" }));
+ await expect.element(page.getByRole("button", { name: "Update", exact: true })).toBeVisible();
+
+ await act(() => {
+ mocks.everything = { ...mocks.everything, hasUpdates: false, rows: [] };
+ mocks.notify();
+ });
+ const dialog = page.getByRole("alertdialog");
+ await expect.element(dialog.getByText("Everything is up to date")).toBeVisible();
+ await expect
+ .element(dialog.getByRole("button", { name: "Update", exact: true }))
+ .not.toBeInTheDocument();
+ await userEvent.click(dialog.getByRole("button", { name: "Close" }));
+ await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
+ });
});
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index 5df8007ef4677..4de0797f35324 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -401,8 +401,6 @@ function SidebarUpdateControl() {
);
};
- if (!isElectron && !runsEverything && !isRunningEverything) return null;
-
const statusIcon = (
void runEverything()}
/>
);
+ // The dialog stays a stable sibling so an open one outlives the pill and never remounts.
+ if (!isElectron && !runsEverything && !isRunningEverything)
+ return (
+ <>
+ {null}
+ {confirmDialog}
+ >
+ );
if (run !== null) {
const local: UpdateRunLocalState =
@@ -433,30 +439,32 @@ function SidebarUpdateControl() {
? { status: "downloading", percent: state.downloadPercent }
: { status: "waiting" };
return (
-
- {
- saveUpdateEverythingResult({
- localVersion: localUpdateVersion,
- updatedServers: 0,
- updatedProviders: 0,
- failures: [],
- restarting: [],
- });
- if (!(await installLocalUpdate(window.desktopBridge!))) {
- clearSavedUpdateEverythingResult();
- }
- },
- }
- : {})}
- />
+ <>
+
+ {
+ saveUpdateEverythingResult({
+ localVersion: localUpdateVersion,
+ updatedServers: 0,
+ updatedProviders: 0,
+ failures: [],
+ restarting: [],
+ });
+ if (!(await installLocalUpdate(window.desktopBridge!))) {
+ clearSavedUpdateEverythingResult();
+ }
+ },
+ }
+ : {})}
+ />
+
{confirmDialog}
-
+ >
);
}
@@ -508,83 +516,85 @@ function SidebarUpdateControl() {
);
return (
-
- {
- if (open && !showReleaseNotesPopover) {
- details.cancel();
- return;
- }
- handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);
- if (!details.isCanceled) setReleaseNotesOpen(open);
- }}
- >
-
-
+ <>
+
+ {
+ if (open && !showReleaseNotesPopover) {
+ details.cancel();
+ return;
}
- />
- {!showReleaseNotesPopover ? (
-
+
+
+ }
+ />
+ {!showReleaseNotesPopover ? (
+
+ {idleTooltip?.detail ? (
+
+
{idleTooltip.title}
+
{idleTooltip.detail}
+
+ ) : (
+ {tooltip}
+ )}
+
+ ) : null}
+
+ {showReleaseNotesPopover && state ? (
+ {
+ if (
+ event.key === "Escape" &&
+ releaseNotesPopupRef.current?.contains(document.activeElement)
+ ) {
+ suppressReleaseNotesFocusOpen.current = true;
+ }
+ }}
+ ref={releaseNotesPopupRef}
side="top"
- variant={showUpdateDetails ? "glass" : "default"}
+ tooltipStyle
>
- {idleTooltip?.detail ? (
-
-
{idleTooltip.title}
-
{idleTooltip.detail}
-
- ) : (
- {tooltip}
- )}
-
+
+
) : null}
-
- {showReleaseNotesPopover && state ? (
- {
- if (
- event.key === "Escape" &&
- releaseNotesPopupRef.current?.contains(document.activeElement)
- ) {
- suppressReleaseNotesFocusOpen.current = true;
- }
- }}
- ref={releaseNotesPopupRef}
- side="top"
- tooltipStyle
- >
-
-
- ) : null}
-
+
+
{confirmDialog}
-
+ >
);
}
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx
new file mode 100644
index 0000000000000..22857c11f678e
--- /dev/null
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+import { page, userEvent } from "vite-plus/test/browser";
+
+import "~/index.css";
+import { act, render } from "../../../test/render";
+import { SidebarUpdatePill } from "./SidebarUpdatePill";
+import type { UpdateEverythingRow } from "./updateEverything.logic";
+
+// Browser mode fixes a mocked export at module load, so the web client needs its own file.
+const mocks = vi.hoisted(() => {
+ const listeners = new Set<() => void>();
+ return {
+ everything: {
+ hasUpdates: true,
+ summary: "Update everything",
+ version: null,
+ providerCount: 0,
+ rows: [] as ReadonlyArray,
+ restarts: [],
+ },
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ notify: () => {
+ for (const listener of listeners) listener();
+ },
+ };
+});
+vi.mock("../../env", async (importOriginal) => ({
+ ...(await importOriginal()),
+ isElectron: false,
+}));
+vi.mock("./useUpdateEverything", async () => ({
+ useUpdateEverything: (await import("react")).useSyncExternalStore.bind(
+ null,
+ mocks.subscribe,
+ () => mocks.everything,
+ ),
+}));
+
+function setup(rows: ReadonlyArray) {
+ mocks.everything = { ...mocks.everything, hasUpdates: true, rows };
+}
+
+describe("SidebarUpdatePill on web", () => {
+ it("keeps an open dialog when the updates finish and the pill hides", async () => {
+ setup([
+ { environmentId: null, label: "Laptop", app: null, skipped: null, providers: ["Codex"] },
+ ]);
+ await using screen = await render();
+ await userEvent.click(screen.getByRole("button", { name: "Update everything" }));
+ await expect.element(page.getByRole("button", { name: "Update", exact: true })).toBeVisible();
+
+ await act(() => {
+ mocks.everything = { ...mocks.everything, hasUpdates: false, rows: [] };
+ mocks.notify();
+ });
+ await expect
+ .element(screen.getByRole("button", { name: "Update everything" }))
+ .not.toBeInTheDocument();
+ const dialog = page.getByRole("alertdialog");
+ await expect.element(dialog.getByText("Everything is up to date")).toBeVisible();
+ await userEvent.click(dialog.getByRole("button", { name: "Close" }));
+ await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
+ });
+});
diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx
index 586438bb4420e..be61cad85c191 100644
--- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx
+++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx
@@ -127,26 +127,43 @@ function UpdateEverythingDialogContent({
const restarting = new Set(restarts.map((machine) => machine.environmentId));
const updating = rows.filter((row) => row.app !== null || row.providers.length > 0);
const skipped = rows.filter((row) => row.skipped !== null);
+ // Updates can finish elsewhere while this is open. A track switch with no rows still saves the track.
+ const empty = title === undefined && updating.length === 0;
return (
<>
- {title ?? `Update ${updating.length} ${updating.length === 1 ? "machine" : "machines"}?`}
+ {empty
+ ? checking
+ ? "Checking for updates…"
+ : skipped.length > 0
+ ? "Nothing to update right now"
+ : "Everything is up to date"
+ : (title ??
+ `Update ${updating.length} ${updating.length === 1 ? "machine" : "machines"}?`)}
- {version !== null ? (
+ {empty ? (
+ checking || skipped.length > 0 ? null : (
+ "Nothing is left to update."
+ )
+ ) : (
<>
- T3 Code {version}
+ {version !== null ? (
+ <>
+ T3 Code {version}
+ >
+ ) : null}
+ {version !== null && providerCount > 0 ? " · " : null}
+ {providerCount > 0
+ ? `${providerCount} provider ${providerCount === 1 ? "update" : "updates"}`
+ : null}
+ {checking
+ ? `${version !== null || providerCount > 0 ? " · " : ""}Checking for more…`
+ : null}
>
- ) : null}
- {version !== null && providerCount > 0 ? " · " : null}
- {providerCount > 0
- ? `${providerCount} provider ${providerCount === 1 ? "update" : "updates"}`
- : null}
- {checking
- ? `${version !== null || providerCount > 0 ? " · " : ""}Checking for more…`
- : null}
+ )}
@@ -156,55 +173,57 @@ function UpdateEverythingDialogContent({
{note}
))}
- {/* Machine names truncate first. Provider lists wrap between names, never inside one. */}
-
+ {empty ? null : (
+ // Machine names truncate first. Provider lists wrap between names, never inside one.
- Machine
- T3 Code
- Providers
-
-
- {updating.map((row) => (
-
- {row.label}
-
-
-
- {row.app !== null ? APP_LABELS[row.app] : null}
- {row.app === null && row.skipped !== null ? (
- Skipped
- ) : null}
- {row.app === "this-app" ? (
- restarts last
- ) : null}
-
-
- {row.providers.map((provider, index) => (
-
- {provider}
- {index < row.providers.length - 1 ? ", " : null}
-
- ))}
-
-
- {row.environmentId !== null && restarting.has(row.environmentId) ? (
-
- ) : null}
-
+ Machine
+ T3 Code
+ Providers
+
- ))}
-
+ {updating.map((row) => (
+
+
+ {row.label}
+
+
+
+ {row.app !== null ? APP_LABELS[row.app] : null}
+ {row.app === null && row.skipped !== null ? (
+ Skipped
+ ) : null}
+ {row.app === "this-app" ? (
+ restarts last
+ ) : null}
+
+
+ {row.providers.map((provider, index) => (
+
+ {provider}
+ {index < row.providers.length - 1 ? ", " : null}
+
+ ))}
+
+
+ {row.environmentId !== null && restarting.has(row.environmentId) ? (
+
+ ) : null}
+
+
+ ))}
+
+ )}
{skipped.length > 0 ? (
@@ -242,11 +261,13 @@ function UpdateEverythingDialogContent({
) : null}
}>
- Cancel
+ {empty ? "Close" : "Cancel"}
-
+ {empty ? null : (
+
+ )}
>
);
From 5436ef2da2138c27fe579b7b1dcd32105cc85605 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sat, 14 Nov 2026 23:31:19 +0000
Subject: [PATCH 2/3] refactor(web): keep the update dialog mounted through one
shared shape
Every path of SidebarUpdateControl now returns the same [pill, dialog]
fragment, replacing the placeholder {null} sibling. Drops the Electron
copy of the empty-state test; the web test already covers it.
Co-Authored-By: Claude Opus 5.5
---
.../SidebarUpdatePill.browser.test.tsx | 47 ++--
.../components/sidebar/SidebarUpdatePill.tsx | 218 +++++++++---------
2 files changed, 119 insertions(+), 146 deletions(-)
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
index d1aa2595f66b3..e490ee79b9b9a 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
@@ -11,14 +11,8 @@ const mocks = vi.hoisted(() => {
const listeners = new Set<() => void>();
return {
update: null as DesktopUpdateState | null,
- everything: {
- hasUpdates: false,
- summary: "Update everything",
- version: null,
- providerCount: 0,
- rows: [] as ReadonlyArray,
- restarts: [],
- },
+ hasUpdates: false,
+ rows: [] as ReadonlyArray,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -41,12 +35,15 @@ vi.mock("../../env", async (importOriginal) => ({
...(await importOriginal()),
isElectron: true,
}));
-vi.mock("./useUpdateEverything", async () => ({
- useUpdateEverything: (await import("react")).useSyncExternalStore.bind(
- null,
- mocks.subscribe,
- () => mocks.everything,
- ),
+vi.mock("./useUpdateEverything", () => ({
+ useUpdateEverything: () => ({
+ hasUpdates: mocks.hasUpdates,
+ summary: "Update everything",
+ version: null,
+ providerCount: 0,
+ rows: mocks.rows,
+ restarts: [],
+ }),
}));
function nightlyUpdate(releaseNotes: DesktopUpdateState["releaseNotes"]): DesktopUpdateState {
@@ -82,7 +79,8 @@ function setup(
}: { hasUpdates?: boolean; rows?: ReadonlyArray } = {},
) {
mocks.update = update;
- mocks.everything = { ...mocks.everything, hasUpdates, rows };
+ mocks.hasUpdates = hasUpdates;
+ mocks.rows = rows;
}
function publish(update: DesktopUpdateState | null) {
@@ -177,23 +175,4 @@ describe("SidebarUpdatePill update everything", () => {
await expect.element(row.getByRole("cell", { name: label, exact: true })).toBeVisible();
await expect.element(row.getByRole("cell", { name: "Claude", exact: true })).toBeVisible();
});
-
- it("says everything is up to date when the updates finish while it is open", async () => {
- setup(null, { hasUpdates: true, rows: [LAPTOP_CODEX] });
- await using screen = await render();
- await userEvent.click(screen.getByRole("button", { name: "Update everything" }));
- await expect.element(page.getByRole("button", { name: "Update", exact: true })).toBeVisible();
-
- await act(() => {
- mocks.everything = { ...mocks.everything, hasUpdates: false, rows: [] };
- mocks.notify();
- });
- const dialog = page.getByRole("alertdialog");
- await expect.element(dialog.getByText("Everything is up to date")).toBeVisible();
- await expect
- .element(dialog.getByRole("button", { name: "Update", exact: true }))
- .not.toBeInTheDocument();
- await userEvent.click(dialog.getByRole("button", { name: "Close" }));
- await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
- });
});
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index 4de0797f35324..6e024265e2e27 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -4,7 +4,7 @@ import {
managedUpdatesDenial,
} from "@t3tools/contracts";
import { TriangleAlertIcon } from "lucide-react";
-import { type ComponentProps, useId, useRef, useState } from "react";
+import { type ComponentProps, type ReactNode, useId, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { isElectron } from "../../env";
@@ -422,14 +422,14 @@ function SidebarUpdateControl() {
onConfirm={() => void runEverything()}
/>
);
- // The dialog stays a stable sibling so an open one outlives the pill and never remounts.
- if (!isElectron && !runsEverything && !isRunningEverything)
- return (
- <>
- {null}
- {confirmDialog}
- >
- );
+ // Every path renders the same [pill, dialog] shape, so an open dialog outlives a hidden pill.
+ const withDialog = (pill: ReactNode) => (
+ <>
+ {pill}
+ {confirmDialog}
+ >
+ );
+ if (!isElectron && !runsEverything && !isRunningEverything) return withDialog(null);
if (run !== null) {
const local: UpdateRunLocalState =
@@ -438,33 +438,30 @@ function SidebarUpdateControl() {
: state?.status === "downloading"
? { status: "downloading", percent: state.downloadPercent }
: { status: "waiting" };
- return (
- <>
-
- {
- saveUpdateEverythingResult({
- localVersion: localUpdateVersion,
- updatedServers: 0,
- updatedProviders: 0,
- failures: [],
- restarting: [],
- });
- if (!(await installLocalUpdate(window.desktopBridge!))) {
- clearSavedUpdateEverythingResult();
- }
- },
- }
- : {})}
- />
-
- {confirmDialog}
- >
+ return withDialog(
+
+ {
+ saveUpdateEverythingResult({
+ localVersion: localUpdateVersion,
+ updatedServers: 0,
+ updatedProviders: 0,
+ failures: [],
+ restarting: [],
+ });
+ if (!(await installLocalUpdate(window.desktopBridge!))) {
+ clearSavedUpdateEverythingResult();
+ }
+ },
+ }
+ : {})}
+ />
+ ,
);
}
@@ -515,86 +512,83 @@ function SidebarUpdateControl() {
);
- return (
- <>
-
- {
- if (open && !showReleaseNotesPopover) {
- details.cancel();
- return;
+ return withDialog(
+
+ {
+ if (open && !showReleaseNotesPopover) {
+ details.cancel();
+ return;
+ }
+ handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);
+ if (!details.isCanceled) setReleaseNotesOpen(open);
+ }}
+ >
+
+
}
- handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);
- if (!details.isCanceled) setReleaseNotesOpen(open);
- }}
- >
-
-
- }
- />
- {!showReleaseNotesPopover ? (
-
- {idleTooltip?.detail ? (
-
-
{idleTooltip.title}
-
{idleTooltip.detail}
-
- ) : (
- {tooltip}
- )}
-
- ) : null}
-
- {showReleaseNotesPopover && state ? (
-
+ {!showReleaseNotesPopover ? (
+ {
- if (
- event.key === "Escape" &&
- releaseNotesPopupRef.current?.contains(document.activeElement)
- ) {
- suppressReleaseNotesFocusOpen.current = true;
- }
- }}
- ref={releaseNotesPopupRef}
side="top"
- tooltipStyle
+ variant={showUpdateDetails ? "glass" : "default"}
>
-
-
+ {idleTooltip?.detail ? (
+
+
{idleTooltip.title}
+
{idleTooltip.detail}
+
+ ) : (
+ {tooltip}
+ )}
+
) : null}
-
-
- {confirmDialog}
- >
+
+ {showReleaseNotesPopover && state ? (
+ {
+ if (
+ event.key === "Escape" &&
+ releaseNotesPopupRef.current?.contains(document.activeElement)
+ ) {
+ suppressReleaseNotesFocusOpen.current = true;
+ }
+ }}
+ ref={releaseNotesPopupRef}
+ side="top"
+ tooltipStyle
+ >
+
+
+ ) : null}
+
+ ,
);
}
From 23b5d20c60f7ad2f574b3c7433f6570d3557b8aa Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sat, 14 Nov 2026 23:35:08 +0000
Subject: [PATCH 3/3] test(web): fold the web update-dialog case into the
existing browser test
A getter-backed env mock lets one file cover both the desktop and web
client, so CI loads one module graph instead of two.
Co-Authored-By: Claude Opus 5.5
---
.../SidebarUpdatePill.browser.test.tsx | 57 +++++++++++-----
.../SidebarUpdatePill.web.browser.test.tsx | 67 -------------------
2 files changed, 41 insertions(+), 83 deletions(-)
delete mode 100644 apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
index e490ee79b9b9a..d908ee2b385a5 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
@@ -11,8 +11,15 @@ const mocks = vi.hoisted(() => {
const listeners = new Set<() => void>();
return {
update: null as DesktopUpdateState | null,
- hasUpdates: false,
- rows: [] as ReadonlyArray,
+ isElectron: true,
+ everything: {
+ hasUpdates: false,
+ summary: "Update everything",
+ version: null,
+ providerCount: 0,
+ rows: [] as ReadonlyArray,
+ restarts: [],
+ },
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -30,20 +37,18 @@ vi.mock("../../state/desktopUpdate", async (importOriginal) => ({
() => mocks.update,
),
}));
-// The pill only renders in the desktop app.
vi.mock("../../env", async (importOriginal) => ({
...(await importOriginal()),
- isElectron: true,
+ get isElectron() {
+ return mocks.isElectron;
+ },
}));
-vi.mock("./useUpdateEverything", () => ({
- useUpdateEverything: () => ({
- hasUpdates: mocks.hasUpdates,
- summary: "Update everything",
- version: null,
- providerCount: 0,
- rows: mocks.rows,
- restarts: [],
- }),
+vi.mock("./useUpdateEverything", async () => ({
+ useUpdateEverything: (await import("react")).useSyncExternalStore.bind(
+ null,
+ mocks.subscribe,
+ () => mocks.everything,
+ ),
}));
function nightlyUpdate(releaseNotes: DesktopUpdateState["releaseNotes"]): DesktopUpdateState {
@@ -76,11 +81,12 @@ function setup(
{
hasUpdates = false,
rows = [],
- }: { hasUpdates?: boolean; rows?: ReadonlyArray } = {},
+ isElectron = true,
+ }: { hasUpdates?: boolean; rows?: ReadonlyArray; isElectron?: boolean } = {},
) {
mocks.update = update;
- mocks.hasUpdates = hasUpdates;
- mocks.rows = rows;
+ mocks.isElectron = isElectron;
+ mocks.everything = { ...mocks.everything, hasUpdates, rows };
}
function publish(update: DesktopUpdateState | null) {
@@ -175,4 +181,23 @@ describe("SidebarUpdatePill update everything", () => {
await expect.element(row.getByRole("cell", { name: label, exact: true })).toBeVisible();
await expect.element(row.getByRole("cell", { name: "Claude", exact: true })).toBeVisible();
});
+
+ it("keeps an open dialog on web when the updates finish and the pill hides", async () => {
+ setup(null, { hasUpdates: true, rows: [LAPTOP_CODEX], isElectron: false });
+ await using screen = await render();
+ await userEvent.click(screen.getByRole("button", { name: "Update everything" }));
+ await expect.element(page.getByRole("button", { name: "Update", exact: true })).toBeVisible();
+
+ await act(() => {
+ mocks.everything = { ...mocks.everything, hasUpdates: false, rows: [] };
+ mocks.notify();
+ });
+ await expect
+ .element(screen.getByRole("button", { name: "Update everything" }))
+ .not.toBeInTheDocument();
+ const dialog = page.getByRole("alertdialog");
+ await expect.element(dialog.getByText("Everything is up to date")).toBeVisible();
+ await userEvent.click(dialog.getByRole("button", { name: "Close" }));
+ await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
+ });
});
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx
deleted file mode 100644
index 22857c11f678e..0000000000000
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.web.browser.test.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, expect, it, vi } from "vite-plus/test";
-import { page, userEvent } from "vite-plus/test/browser";
-
-import "~/index.css";
-import { act, render } from "../../../test/render";
-import { SidebarUpdatePill } from "./SidebarUpdatePill";
-import type { UpdateEverythingRow } from "./updateEverything.logic";
-
-// Browser mode fixes a mocked export at module load, so the web client needs its own file.
-const mocks = vi.hoisted(() => {
- const listeners = new Set<() => void>();
- return {
- everything: {
- hasUpdates: true,
- summary: "Update everything",
- version: null,
- providerCount: 0,
- rows: [] as ReadonlyArray,
- restarts: [],
- },
- subscribe: (listener: () => void) => {
- listeners.add(listener);
- return () => listeners.delete(listener);
- },
- notify: () => {
- for (const listener of listeners) listener();
- },
- };
-});
-vi.mock("../../env", async (importOriginal) => ({
- ...(await importOriginal()),
- isElectron: false,
-}));
-vi.mock("./useUpdateEverything", async () => ({
- useUpdateEverything: (await import("react")).useSyncExternalStore.bind(
- null,
- mocks.subscribe,
- () => mocks.everything,
- ),
-}));
-
-function setup(rows: ReadonlyArray) {
- mocks.everything = { ...mocks.everything, hasUpdates: true, rows };
-}
-
-describe("SidebarUpdatePill on web", () => {
- it("keeps an open dialog when the updates finish and the pill hides", async () => {
- setup([
- { environmentId: null, label: "Laptop", app: null, skipped: null, providers: ["Codex"] },
- ]);
- await using screen = await render();
- await userEvent.click(screen.getByRole("button", { name: "Update everything" }));
- await expect.element(page.getByRole("button", { name: "Update", exact: true })).toBeVisible();
-
- await act(() => {
- mocks.everything = { ...mocks.everything, hasUpdates: false, rows: [] };
- mocks.notify();
- });
- await expect
- .element(screen.getByRole("button", { name: "Update everything" }))
- .not.toBeInTheDocument();
- const dialog = page.getByRole("alertdialog");
- await expect.element(dialog.getByText("Everything is up to date")).toBeVisible();
- await userEvent.click(dialog.getByRole("button", { name: "Close" }));
- await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
- });
-});