diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index ed3a723652a56..ace7f45976eff 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -171,6 +171,17 @@ describe("ServerUpdateAction", () => { }, ); + it("does not update a desktop app when no confirmation dialog can be shown", async () => { + using _state = setup(); + testState.updateServer.mockResolvedValue(success("desktop-app")); + await using screen = await renderAction({ + selfUpdate: "desktop-managed", + desktopAppUpdate: true, + }); + await click(screen, "Update"); + expect(testState.updateServer).not.toHaveBeenCalled(); + }); + it("disables the action once maintenance access is removed", async () => { using _state = setup(); await using screen = await renderAction(); @@ -237,4 +248,24 @@ describe("ServerUpdatesAction", () => { expect.objectContaining({ type: "success", title: "Office updated" }), ); }); + + const withDesktop: ReadonlyArray = [ + targets[0]!, + { + environmentId: "batch-desktop" as EnvironmentId, + serverLabel: "Studio", + selfUpdate: "desktop-managed", + desktopAppUpdate: true, + targetVersion: "0.0.31", + }, + ]; + + it("does not update any machine when a desktop app needs a confirmation that cannot be shown", async () => { + using _state = setup(); + testState.updateServer.mockResolvedValue(success()); + await using screen = await mount(withDesktop); + await click(screen, "Update all"); + expect(testState.updateServer).not.toHaveBeenCalled(); + expect(button(screen, "Update all")!.disabled).toBe(false); + }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 32a2811aca189..9eddb99cee13e 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -43,6 +43,7 @@ const UPDATE_STAGE_LABELS: Record = { resuming: "Restarting…", }; const pendingUpdateEnvironmentIds = new Set(); +const DESKTOP_UPDATE_INTERRUPTION_WARNING = "Updates interrupt tasks and tool calls."; export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; @@ -199,10 +200,9 @@ export function ServerUpdatesAction({ ); const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed"); if (desktopTargets.length > 0) { - const confirmed = - (await requestConfirmDialog( - `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, - )) ?? true; + const confirmed = await requestConfirmDialog( + `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}?\n\n${DESKTOP_UPDATE_INTERRUPTION_WARNING}`, + ); if (!confirmed) return; } await Promise.all(available.map((target) => update(target))); @@ -305,10 +305,9 @@ export function ServerUpdateAction({ return; } if (isDesktopAppUpdate) { - const confirmed = - (await requestConfirmDialog( - `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, - )) ?? true; + const confirmed = await requestConfirmDialog( + `Update the T3 Code desktop app that runs the ${serverLabel}?\n\n${DESKTOP_UPDATE_INTERRUPTION_WARNING}`, + ); if (!confirmed) { return; } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 03bb2c72849e2..93a6f38c4de3c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -1,10 +1,11 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopUpdateState, EnvironmentId } from "@t3tools/contracts"; 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 { UpdateEverythingDialog } from "./UpdateEverythingDialog"; const mocks = vi.hoisted(() => { const listeners = new Set<() => void>(); @@ -140,4 +141,31 @@ describe("SidebarUpdatePill update everything", () => { await userEvent.keyboard("{Escape}"); await vi.waitFor(() => expect(document.activeElement).toBe(button.element())); }); + + it("warns that an app restart interrupts work before any threads have loaded", async () => { + setup(null); + const studio = "studio" as EnvironmentId; + await using _screen = await render( + {}} + version={null} + providerCount={0} + rows={[ + { + environmentId: studio, + label: "Studio", + app: "desktop-app", + skipped: null, + providers: [], + }, + ]} + restarts={[{ environmentId: studio, continuesThreads: true }]} + onConfirm={() => {}} + />, + ); + const dialog = page.getByRole("alertdialog"); + await expect.element(dialog.getByText(/interrupt/i)).toBeVisible(); + await expect.element(dialog).toHaveAccessibleDescription(/interrupt/i); + }); }); diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index 9bf101b6fa4e7..56edd9010dceb 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { ChevronRightIcon } from "lucide-react"; -import { Fragment, type RefObject, useRef } from "react"; +import { Fragment, type RefObject, useId, useRef } from "react"; import { environmentThreadShells } from "~/state/threads"; import { @@ -51,7 +51,7 @@ interface UpdateEverythingDialogProps { readonly title?: string; readonly confirmLabel?: string; readonly notes?: ReadonlyArray; - /** Shown in place of running-thread counts; disables the confirm button. */ + /** Shown in place of the interruption warning; disables the confirm button. */ readonly disabledReason?: string | null; /** The confirmed work is starting; it cannot be cancelled from here. */ readonly busy?: boolean; @@ -62,6 +62,7 @@ type UpdateEverythingDialogContentProps = Omit< "open" | "onOpenChange" > & { readonly cancelRef: RefObject; + readonly descriptionId: string; }; export function UpdateEverythingDialog({ @@ -70,15 +71,25 @@ export function UpdateEverythingDialog({ ...content }: UpdateEverythingDialogProps) { const cancelRef = useRef(null); + const descriptionId = useId(); return ( {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} row.app !== null) + ? `${descriptionId} ${descriptionId}-interruption` + : descriptionId + } initialFocus={cancelRef} finalFocus={(closeType) => closeType === "keyboard"} > - + ); @@ -102,25 +113,8 @@ function UpdateEverythingDialogContent({ notes = [], disabledReason = null, busy = false, + descriptionId, }: UpdateEverythingDialogContentProps) { - const stopped = useAtomValue( - runningThreadCountAtom( - restarts - .filter((machine) => !machine.continuesThreads) - .map((machine) => machine.environmentId) - .join("\n"), - ), - ); - const continuing = useAtomValue( - runningThreadCountAtom( - restarts - .filter((machine) => machine.continuesThreads) - .map((machine) => machine.environmentId) - .join("\n"), - ), - ); - const running = stopped + continuing; - const threads = `${running} running ${running === 1 ? "thread" : "threads"}`; 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); @@ -131,7 +125,7 @@ function UpdateEverythingDialogContent({ {title ?? `Update ${updating.length} ${updating.length === 1 ? "machine" : "machines"}?`} - + {version !== null ? ( <> T3 Code {version} @@ -214,17 +208,18 @@ function UpdateEverythingDialogContent({ + {/* Thread counts come from client snapshots that can still be loading, so the warning + cannot depend on them. */} {disabledReason !== null ? (

{disabledReason}

- ) : running > 0 ? ( -

- {stopped === 0 - ? `${threads} will be interrupted and can resume after the restart.` - : continuing === 0 - ? `${threads} will be interrupted.` - : `${threads} will be interrupted. ${continuing} can resume after the restart.`} + ) : updating.some((row) => row.app !== null) ? ( +

+ Updates interrupt tasks and tool calls.

) : null} }>