From 94e026404ed6d75ffb420f628c8276eda740defa Mon Sep 17 00:00:00 2001 From: Great Love <68994918+nobottomline@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:25:56 +0300 Subject: [PATCH 1/3] feat(web): choose whether app updates include provider CLIs --- .../settings/UpdateTrackSwitchDialog.tsx | 4 +- .../SidebarUpdatePill.browser.test.tsx | 102 ++++++++++-- .../components/sidebar/SidebarUpdatePill.tsx | 34 +++- .../sidebar/UpdateEverythingDialog.tsx | 16 ++ .../useUpdateEverything.browser.test.tsx | 149 ++++++++++++++++++ .../components/sidebar/useUpdateEverything.ts | 22 ++- docs/user/updating.md | 9 ++ 7 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index 83547b8712fa1..82e7bb3f796cc 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -81,7 +81,9 @@ function SwitchPlan({ // After a partial switch this app may already be on the track; then only remotes move. const switchesLocal = crossesLocal && !updatesOff; const [checking, setChecking] = useState(false); - const everything = useUpdateEverything(switchesLocal ? release.version : null, release); + const everything = useUpdateEverything(switchesLocal ? release.version : null, { + channelSwitch: release, + }); const label = channelSwitchLabel(release.channel); // This app's reinstall restarts every server it hosts, so each of their databases must allow it. const localBlocked = localSwitchBlockReason( diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 18ebd70c6162c..d1117e529e5d3 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => { return { update: null as DesktopUpdateState | null, hasUpdates: false, + hasProviderUpdates: false, + hasAppUpdates: false, subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); @@ -33,16 +35,28 @@ vi.mock("../../env", async (importOriginal) => ({ ...(await importOriginal()), isElectron: true, })); -vi.mock("./useUpdateEverything", () => ({ - useUpdateEverything: () => ({ - hasUpdates: mocks.hasUpdates, - summary: "Update everything", - version: null, - providerCount: 0, - rows: [], - restarts: [], - }), -})); +vi.mock("./useUpdateEverything", async () => { + const { useSyncExternalStore } = await import("react"); + return { + useUpdateEverything: ( + _version: string | null, + options?: { includeProviderUpdates: boolean }, + ) => { + const hasAppUpdates = useSyncExternalStore(mocks.subscribe, () => mocks.hasAppUpdates); + return { + hasUpdates: mocks.hasUpdates, + hasAppUpdates: _version !== null || hasAppUpdates, + canChooseProviderUpdates: mocks.hasProviderUpdates && (_version !== null || hasAppUpdates), + summary: "Update everything", + version: null, + providerCount: + mocks.hasProviderUpdates && options?.includeProviderUpdates !== false ? 1 : 0, + rows: [], + restarts: [], + }; + }, + }; +}); function nightlyUpdate(releaseNotes: DesktopUpdateState["releaseNotes"]): DesktopUpdateState { return { @@ -69,9 +83,14 @@ const RELEASE_NOTES: DesktopUpdateState["releaseNotes"] = [ { version: "0.0.50-nightly.2", items: ["Faster sidebar."], totalItems: 1 }, ]; -function setup(update: DesktopUpdateState | null, { hasUpdates = false } = {}) { +function setup( + update: DesktopUpdateState | null, + { hasUpdates = false, hasProviderUpdates = false, hasAppUpdates = false } = {}, +) { mocks.update = update; mocks.hasUpdates = hasUpdates; + mocks.hasProviderUpdates = hasProviderUpdates; + mocks.hasAppUpdates = hasAppUpdates; } function publish(update: DesktopUpdateState | null) { @@ -106,6 +125,67 @@ describe("SidebarUpdatePill release notes", () => { }); describe("SidebarUpdatePill update everything", () => { + it("requires a new confirmation if the last app-update target disappears", async () => { + setup(null, { hasUpdates: true, hasProviderUpdates: true, hasAppUpdates: true }); + await using screen = await render(); + const button = screen.getByRole("button", { name: "Update everything" }); + await userEvent.click(button); + const checkbox = page.getByRole("checkbox", { name: "Include provider updates" }); + await expect.element(checkbox).toBeVisible(); + + await act(() => { + mocks.hasAppUpdates = false; + mocks.notify(); + }); + await expect.element(page.getByRole("alertdialog")).not.toBeInTheDocument(); + await userEvent.click(button); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + await expect.element(checkbox).not.toBeInTheDocument(); + }); + + it.each([false, true])( + "remembers the provider choice in another client (already mounted: %s)", + async (alreadyMounted) => { + setup(nightlyUpdate([]), { hasUpdates: true, hasProviderUpdates: true }); + const key = "t3code:include-providers-in-app-updates"; + const previous = localStorage.getItem(key); + using _storage = { + [Symbol.dispose]() { + if (previous === null) localStorage.removeItem(key); + else localStorage.setItem(key, previous); + }, + }; + localStorage.removeItem(key); + const renderClient = (id: string) => { + const container = document.createElement("div"); + container.id = id; + return render(, { container }); + }; + await using clients = new AsyncDisposableStack(); + const screen = clients.use(await renderClient("update-client-first")); + const other = alreadyMounted ? clients.use(await renderClient("update-client-second")) : null; + await userEvent.click(screen.getByRole("button", { name: "Update everything" })); + const checkbox = page.getByRole("checkbox", { name: "Include provider updates" }); + await expect.element(checkbox).toBeChecked(); + await userEvent.click(checkbox); + await expect.element(checkbox).not.toBeChecked(); + expect(localStorage.getItem(key)).toBe("false"); + await screen.unmount(); + + const restored = other ?? clients.use(await renderClient("update-client-second")); + await userEvent.click(restored.getByRole("button", { name: "Update everything" })); + await expect.element(checkbox).not.toBeChecked(); + await act(() => { + localStorage.setItem(key, "true"); + window.dispatchEvent(new StorageEvent("storage", { key, newValue: "true" })); + }); + await expect.element(checkbox).not.toBeChecked(); + await userEvent.click(checkbox); + await expect.element(checkbox).toBeChecked(); + expect(localStorage.getItem(key)).toBe("true"); + }, + ); + it("does not refocus the update button after a pointer cancel", async () => { setup(null, { hasUpdates: true }); await using screen = await render(); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 7e0ee498cece5..6b8b0e04c38b9 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,10 +1,12 @@ import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; import { TriangleAlertIcon } from "lucide-react"; +import * as Schema from "effect/Schema"; import { type ComponentProps, useId, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; import { cn } from "../../lib/utils"; import { ensureLocalApi } from "../../localApi"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -44,6 +46,8 @@ import { } from "./updateEverythingRun"; import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything"; +const INCLUDE_PROVIDER_UPDATES_KEY = "t3code:include-providers-in-app-updates"; + type SidebarUpdatePopoverChangeDetails = Parameters< NonNullable["onOpenChange"]> >[1]; @@ -194,6 +198,7 @@ function SidebarUpdateControl() { const state = useDesktopUpdateState(); const [isActionPending, setIsActionPending] = useState(false); const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); + const [isConfirmingAppUpdates, setIsConfirmingAppUpdates] = useState(false); const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); const suppressReleaseNotesFocusOpen = useRef(false); @@ -222,7 +227,21 @@ function SidebarUpdateControl() { const isDownloading = state?.status === "downloading"; const localUpdateVersion = state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; - const everything = useUpdateEverything(localUpdateVersion); + const [savedIncludeProviderUpdates, saveIncludeProviderUpdates] = useLocalStorage( + INCLUDE_PROVIDER_UPDATES_KEY, + true, + Schema.Boolean, + ); + const [includeProviderUpdates, setIncludeProviderUpdates] = useState(savedIncludeProviderUpdates); + const everything = useUpdateEverything(localUpdateVersion, { + includeProviderUpdates: isConfirmingEverything + ? includeProviderUpdates + : savedIncludeProviderUpdates, + }); + // A provider-only plan needs a new confirmation after app targets disappear. + if (isConfirmingEverything && isConfirmingAppUpdates && !everything.hasAppUpdates) { + setIsConfirmingEverything(false); + } const runsEverything = everything.hasUpdates && !isDownloading; const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; @@ -275,6 +294,8 @@ function SidebarUpdateControl() { if (isInteractionDisabled) return; if (runsEverything) { + setIncludeProviderUpdates(savedIncludeProviderUpdates); + setIsConfirmingAppUpdates(everything.hasAppUpdates); setIsConfirmingEverything(true); return; } @@ -401,6 +422,17 @@ function SidebarUpdateControl() { providerCount={everything.providerCount} rows={everything.rows} restarts={everything.restarts} + {...(everything.canChooseProviderUpdates + ? { + providerUpdates: { + included: includeProviderUpdates, + onIncludedChange: (included: boolean) => { + setIncludeProviderUpdates(included); + saveIncludeProviderUpdates(included); + }, + }, + } + : {})} onConfirm={() => void runEverything()} /> ); diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index 9bf101b6fa4e7..b2bdbfb10c2f0 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -15,6 +15,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { MiddleTruncate } from "../ui/middle-truncate"; import { ScrollArea } from "../ui/scroll-area"; @@ -47,6 +48,10 @@ interface UpdateEverythingDialogProps { readonly rows: ReadonlyArray; readonly restarts: ReadonlyArray; readonly onConfirm: () => void; + readonly providerUpdates?: { + readonly included: boolean; + readonly onIncludedChange: (included: boolean) => void; + }; /** Overrides the "Update N machines?" title, for an update-track switch. */ readonly title?: string; readonly confirmLabel?: string; @@ -96,6 +101,7 @@ function UpdateEverythingDialogContent({ rows, restarts, onConfirm, + providerUpdates, cancelRef, title, confirmLabel = "Update", @@ -145,6 +151,16 @@ function UpdateEverythingDialogContent({
+ {providerUpdates ? ( + + ) : null} {notes.map((note) => (

{note} diff --git a/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx b/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx new file mode 100644 index 0000000000000..889105f72c9bd --- /dev/null +++ b/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx @@ -0,0 +1,149 @@ +import { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { useUpdateEverything } from "./useUpdateEverything"; + +const mocks = vi.hoisted(() => ({ + updateProviders: vi.fn(), + updateServer: vi.fn(), + hasServerUpdate: false, +})); + +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ + environments: mocks.hasServerUpdate + ? [ + { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + connection: { phase: "connected" }, + serverConfig: { + environment: { + serverVersion: "0.0.1", + capabilities: { serverSelfUpdate: "boot-service" }, + }, + settings: { continueThreadsAfterServerUpdate: false }, + }, + }, + ] + : [], + }), +})); +vi.mock("~/state/session", () => ({ environmentSessionStatesAtom: () => Atom.make([null]) })); +vi.mock("~/state/server", () => ({ + environmentUpdateStatesAtom: () => Atom.make([{ status: "idle" }]), +})); +vi.mock("../ProviderUpdatesAction", () => ({ + useProviderUpdates: () => ({ + machines: [ + { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + candidates: [{ driver: ProviderDriverKind.make("claudeAgent") }], + }, + ], + updateAll: mocks.updateProviders, + }), +})); +vi.mock("../ServerUpdateAction", () => ({ + canUpdateServer: () => true, + useServerUpdate: () => mocks.updateServer, +})); +vi.mock("./updateEverythingRun", () => ({ + clearSavedUpdateEverythingResult: vi.fn(), + saveUpdateEverythingResult: vi.fn(), + setUpdateEverythingRun: vi.fn(), + settleUpdateRunMachine: vi.fn(), + showUpdateEverythingResult: vi.fn(), + waitForServerUpdateHandoff: vi.fn(), + waitForUpdateRestart: vi.fn(), +})); + +function setup(hasServerUpdate = false) { + mocks.updateProviders.mockReset().mockResolvedValue([]); + mocks.updateServer.mockReset().mockResolvedValue({ status: "updated" }); + mocks.hasServerUpdate = hasServerUpdate; +} + +function UpdatePlan({ + localVersion, + options, +}: { + readonly localVersion: string | null; + readonly options?: Parameters[1]; +}) { + const plan = useUpdateEverything(localVersion, options); + return ( + <> + {String(plan.hasUpdates)} + + {String(plan.canChooseProviderUpdates)} + + {plan.providerCount} + {plan.rows.flatMap((row) => row.providers).join(", ")} + + + ); +} + +describe("useUpdateEverything provider selection", () => { + it.each([ + { included: undefined, calls: 1, count: 1, remote: false }, + { included: true, calls: 1, count: 1, remote: false }, + { included: false, calls: 0, count: 0, remote: false }, + { included: false, calls: 0, count: 0, remote: true }, + ])( + "selects providers consistently (included=$included, remote=$remote)", + async ({ included, calls, count, remote }) => { + setup(remote); + await using screen = await render( + , + ); + expect(screen.getByLabelText("Updates available").element().textContent).toBe("true"); + expect(screen.getByLabelText("Provider choice available").element().textContent).toBe("true"); + expect(screen.getByLabelText("Provider count").element().textContent).toBe(String(count)); + expect(screen.getByLabelText("Providers").element().textContent).toBe(count ? "Claude" : ""); + + await act(() => screen.container.querySelector("button")!.click()); + expect(mocks.updateProviders).toHaveBeenCalledTimes(calls); + expect(mocks.updateServer).toHaveBeenCalledTimes(remote ? 1 : 0); + }, + ); + + it("keeps provider-only updates available after excluding them from app updates", async () => { + setup(); + await using screen = await render( + , + ); + expect(screen.getByLabelText("Updates available").element().textContent).toBe("true"); + expect(screen.getByLabelText("Provider choice available").element().textContent).toBe("false"); + expect(screen.getByLabelText("Provider count").element().textContent).toBe("1"); + + await act(() => screen.container.querySelector("button")!.click()); + expect(mocks.updateProviders).toHaveBeenCalledTimes(1); + }); + + it("excludes providers from track switches regardless of the app-update preference", async () => { + setup(); + await using screen = await render( + , + ); + expect(screen.getByLabelText("Provider choice available").element().textContent).toBe("false"); + expect(screen.getByLabelText("Provider count").element().textContent).toBe("0"); + + await act(() => screen.container.querySelector("button")!.click()); + expect(mocks.updateProviders).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index 05127d9885768..56c31a1d41151 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -50,7 +50,13 @@ export type ReportLocalUpdateError = (title: string, description: string) => voi /** `channelSwitch` makes the run move the fleet to that release's track instead of updating it. */ export function useUpdateEverything( localUpdateVersion: string | null, - channelSwitch?: ChannelSwitchRelease, + { + channelSwitch, + includeProviderUpdates = true, + }: { + readonly channelSwitch?: ChannelSwitchRelease; + readonly includeProviderUpdates?: boolean; + } = {}, ) { const targetVersion = localUpdateVersion ?? APP_VERSION; const { environments } = useEnvironments(); @@ -73,8 +79,14 @@ export function useUpdateEverything( const moving = servers.filter((server) => server.trackOnly !== true); const restarts = collectRestartingMachines(machines, moving, localUpdateVersion !== null); const providerUpdates = useProviderUpdates(); - // A track switch moves app and servers only; provider updates stay a separate choice. - const providers = channelSwitch ? { machines: [], updateAll: async () => [] } : providerUpdates; + const hasAppUpdates = localUpdateVersion !== null || servers.length > 0; + const canChooseProviderUpdates = + !channelSwitch && hasAppUpdates && providerUpdates.machines.length > 0; + // Provider-only updates remain available regardless of the choice for app updates. + const providers = + channelSwitch || (hasAppUpdates && !includeProviderUpdates) + ? { machines: [], updateAll: async () => [] } + : providerUpdates; const updateServer = useServerUpdate(); const view = describeUpdateEverything({ @@ -92,7 +104,9 @@ export function useUpdateEverything( }); return { - hasUpdates: servers.length > 0 || providers.machines.length > 0, + hasUpdates: servers.length > 0 || (!channelSwitch && providerUpdates.machines.length > 0), + hasAppUpdates, + canChooseProviderUpdates, ...view, restarts, run: async ({ diff --git a/docs/user/updating.md b/docs/user/updating.md index 6f8b35945cf6b..a6809f5f7243a 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -14,6 +14,15 @@ background service stays on its current release channel. A remote desktop app installs the latest release from its own channel. Hover the button to see what it will update. +Clear **Include provider updates** in the confirmation to update T3 Code apps +and servers while leaving provider CLI updates for later. Your choice is +remembered on this device. Provider version checks and **Settings → Providers → +Update all** are unchanged. When only providers have updates, the sidebar button +still offers to update them. + +To update only the desktop app you are using, use **Settings → About**. +Runtimes managed by T3 Code can still change with an app update. + While it runs, the button widens to show progress. It names a machine that is taking longer than usual, and offers **Retry** for a server that failed. Click it to see every machine. The desktop app restarts once each server has started From 56fb5aacb7f854efef9b0605fca3e55cf3b792ca Mon Sep 17 00:00:00 2001 From: Great Love <68994918+nobottomline@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:56:55 +0300 Subject: [PATCH 2/3] test(web): cover combined app and multi-server updates --- .../useUpdateEverything.browser.test.tsx | 87 +++++++++++-------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx b/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx index 889105f72c9bd..857a5be71fc79 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx +++ b/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx @@ -8,42 +8,40 @@ import { useUpdateEverything } from "./useUpdateEverything"; const mocks = vi.hoisted(() => ({ updateProviders: vi.fn(), updateServer: vi.fn(), - hasServerUpdate: false, + remoteCount: 0, })); vi.mock("~/state/environments", () => ({ useEnvironments: () => ({ - environments: mocks.hasServerUpdate - ? [ - { - environmentId: EnvironmentId.make("remote"), - label: "Remote", - connection: { phase: "connected" }, - serverConfig: { - environment: { - serverVersion: "0.0.1", - capabilities: { serverSelfUpdate: "boot-service" }, - }, - settings: { continueThreadsAfterServerUpdate: false }, - }, - }, - ] - : [], + environments: Array.from({ length: mocks.remoteCount }, (_, index) => ({ + environmentId: EnvironmentId.make(`remote-${index}`), + label: `Remote ${index}`, + connection: { phase: "connected" }, + serverConfig: { + environment: { + serverVersion: "0.0.1", + capabilities: { serverSelfUpdate: "boot-service" }, + }, + settings: { continueThreadsAfterServerUpdate: false }, + }, + })), }), })); -vi.mock("~/state/session", () => ({ environmentSessionStatesAtom: () => Atom.make([null]) })); +vi.mock("~/state/session", () => ({ + environmentSessionStatesAtom: () => + Atom.make(Array.from({ length: mocks.remoteCount }, () => null)), +})); vi.mock("~/state/server", () => ({ - environmentUpdateStatesAtom: () => Atom.make([{ status: "idle" }]), + environmentUpdateStatesAtom: () => + Atom.make(Array.from({ length: mocks.remoteCount }, () => ({ status: "idle" }))), })); vi.mock("../ProviderUpdatesAction", () => ({ useProviderUpdates: () => ({ - machines: [ - { - environmentId: EnvironmentId.make("remote"), - label: "Remote", - candidates: [{ driver: ProviderDriverKind.make("claudeAgent") }], - }, - ], + machines: Array.from({ length: Math.max(1, mocks.remoteCount) }, (_, index) => ({ + environmentId: EnvironmentId.make(`remote-${index}`), + label: `Remote ${index}`, + candidates: [{ driver: ProviderDriverKind.make("claudeAgent") }], + })), updateAll: mocks.updateProviders, }), })); @@ -61,10 +59,10 @@ vi.mock("./updateEverythingRun", () => ({ waitForUpdateRestart: vi.fn(), })); -function setup(hasServerUpdate = false) { +function setup(remoteCount = 0) { mocks.updateProviders.mockReset().mockResolvedValue([]); mocks.updateServer.mockReset().mockResolvedValue({ status: "updated" }); - mocks.hasServerUpdate = hasServerUpdate; + mocks.remoteCount = remoteCount; } function UpdatePlan({ @@ -83,6 +81,8 @@ function UpdatePlan({ {plan.providerCount} {plan.rows.flatMap((row) => row.providers).join(", ")} + {plan.rows.filter((row) => row.app !== null).length} + {plan.restarts.length} @@ -92,28 +92,41 @@ function UpdatePlan({ describe("useUpdateEverything provider selection", () => { it.each([ - { included: undefined, calls: 1, count: 1, remote: false }, - { included: true, calls: 1, count: 1, remote: false }, - { included: false, calls: 0, count: 0, remote: false }, - { included: false, calls: 0, count: 0, remote: true }, + { included: undefined, calls: 1, count: 1, remote: 0, local: true }, + { included: true, calls: 1, count: 1, remote: 0, local: true }, + { included: false, calls: 0, count: 0, remote: 0, local: true }, + { included: false, calls: 0, count: 0, remote: 1, local: false }, + { included: true, calls: 1, count: 3, remote: 3, local: true }, + { included: false, calls: 0, count: 0, remote: 3, local: true }, ])( - "selects providers consistently (included=$included, remote=$remote)", - async ({ included, calls, count, remote }) => { + "selects providers consistently (included=$included, remote=$remote, local=$local)", + async ({ included, calls, count, remote, local }) => { setup(remote); await using screen = await render( , ); expect(screen.getByLabelText("Updates available").element().textContent).toBe("true"); expect(screen.getByLabelText("Provider choice available").element().textContent).toBe("true"); expect(screen.getByLabelText("Provider count").element().textContent).toBe(String(count)); - expect(screen.getByLabelText("Providers").element().textContent).toBe(count ? "Claude" : ""); + expect(screen.getByLabelText("Providers").element().textContent).toBe( + Array.from({ length: count }, () => "Claude").join(", "), + ); + expect(screen.getByLabelText("App updates").element().textContent).toBe( + String(remote + Number(local)), + ); + expect(screen.getByLabelText("Restarting machines").element().textContent).toBe( + String(remote), + ); await act(() => screen.container.querySelector("button")!.click()); expect(mocks.updateProviders).toHaveBeenCalledTimes(calls); - expect(mocks.updateServer).toHaveBeenCalledTimes(remote ? 1 : 0); + expect(mocks.updateServer).toHaveBeenCalledTimes(remote); + expect(mocks.updateServer.mock.calls.map(([target]) => target.environmentId)).toEqual( + Array.from({ length: remote }, (_, index) => `remote-${index}`), + ); }, ); From ccec659f73b9a41fd54d16766f710f4f1ac249b6 Mon Sep 17 00:00:00 2001 From: Great Love <68994918+nobottomline@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:14:52 +0300 Subject: [PATCH 3/3] fix(web): place provider update choice beside confirmation buttons --- .../sidebar/UpdateEverythingDialog.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index b2bdbfb10c2f0..96d54f07fea1c 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -151,16 +151,6 @@ function UpdateEverythingDialogContent({

- {providerUpdates ? ( - - ) : null} {notes.map((note) => (

{note} @@ -229,13 +219,11 @@ function UpdateEverythingDialogContent({ ) : null}

- + {disabledReason !== null ? ( -

- {disabledReason} -

+

{disabledReason}

) : running > 0 ? ( -

+

{stopped === 0 ? `${threads} will be interrupted and can resume after the restart.` : continuing === 0 @@ -243,12 +231,26 @@ function UpdateEverythingDialogContent({ : `${threads} will be interrupted. ${continuing} can resume after the restart.`}

) : null} - }> - Cancel - - +
+ {providerUpdates ? ( + + ) : null} +
+ }> + Cancel + + +
+
);