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..96d54f07fea1c 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", @@ -213,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 @@ -227,12 +231,26 @@ function UpdateEverythingDialogContent({ : `${threads} will be interrupted. ${continuing} can resume after the restart.`}

) : null} - }> - Cancel - - +
+ {providerUpdates ? ( + + ) : null} +
+ }> + Cancel + + +
+
); 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..857a5be71fc79 --- /dev/null +++ b/apps/web/src/components/sidebar/useUpdateEverything.browser.test.tsx @@ -0,0 +1,162 @@ +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(), + remoteCount: 0, +})); + +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ + 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(Array.from({ length: mocks.remoteCount }, () => null)), +})); +vi.mock("~/state/server", () => ({ + environmentUpdateStatesAtom: () => + Atom.make(Array.from({ length: mocks.remoteCount }, () => ({ status: "idle" }))), +})); +vi.mock("../ProviderUpdatesAction", () => ({ + useProviderUpdates: () => ({ + 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, + }), +})); +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(remoteCount = 0) { + mocks.updateProviders.mockReset().mockResolvedValue([]); + mocks.updateServer.mockReset().mockResolvedValue({ status: "updated" }); + mocks.remoteCount = remoteCount; +} + +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(", ")} + {plan.rows.filter((row) => row.app !== null).length} + {plan.restarts.length} + + + ); +} + +describe("useUpdateEverything provider selection", () => { + it.each([ + { 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, 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( + 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); + expect(mocks.updateServer.mock.calls.map(([target]) => target.environmentId)).toEqual( + Array.from({ length: remote }, (_, index) => `remote-${index}`), + ); + }, + ); + + 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