Skip to content
31 changes: 31 additions & 0 deletions apps/web/src/components/ServerUpdateAction.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -237,4 +248,24 @@ describe("ServerUpdatesAction", () => {
expect.objectContaining({ type: "success", title: "Office updated" }),
);
});

const withDesktop: ReadonlyArray<ServerUpdateTarget> = [
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);
});
});
15 changes: 7 additions & 8 deletions apps/web/src/components/ServerUpdateAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const UPDATE_STAGE_LABELS: Record<ServerUpdateStage, string> = {
resuming: "Restarting…",
};
const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
const DESKTOP_UPDATE_INTERRUPTION_WARNING = "Updates interrupt tasks and tool calls.";
Comment thread
shivamhwp marked this conversation as resolved.
Resolved

export function serverUpdateStageLabel(stage: ServerUpdateStage): string {
return UPDATE_STAGE_LABELS[stage];
Expand Down Expand Up @@ -199,10 +200,9 @@ export function ServerUpdatesAction({
);
const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed");
if (desktopTargets.length > 0) {
Comment thread
shivamhwp marked this conversation as resolved.
Resolved
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)));
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>();
Expand Down Expand Up @@ -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(
<UpdateEverythingDialog
open
onOpenChange={() => {}}
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);
});
});
53 changes: 24 additions & 29 deletions apps/web/src/components/sidebar/UpdateEverythingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,7 +51,7 @@ interface UpdateEverythingDialogProps {
readonly title?: string;
readonly confirmLabel?: string;
readonly notes?: ReadonlyArray<string>;
/** 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;
Expand All @@ -62,6 +62,7 @@ type UpdateEverythingDialogContentProps = Omit<
"open" | "onOpenChange"
> & {
readonly cancelRef: RefObject<HTMLButtonElement | null>;
readonly descriptionId: string;
};

export function UpdateEverythingDialog({
Expand All @@ -70,15 +71,25 @@ export function UpdateEverythingDialog({
...content
}: UpdateEverythingDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const descriptionId = useId();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
{/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */}
<AlertDialogPopup
className="sm:max-w-xl"
aria-describedby={
(content.disabledReason ?? null) === null && content.rows.some((row) => row.app !== null)
? `${descriptionId} ${descriptionId}-interruption`
: descriptionId
}
initialFocus={cancelRef}
finalFocus={(closeType) => closeType === "keyboard"}
>
<UpdateEverythingDialogContent {...content} cancelRef={cancelRef} />
<UpdateEverythingDialogContent
{...content}
cancelRef={cancelRef}
descriptionId={descriptionId}
/>
</AlertDialogPopup>
</AlertDialog>
);
Expand All @@ -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);
Expand All @@ -131,7 +125,7 @@ function UpdateEverythingDialogContent({
<AlertDialogTitle>
{title ?? `Update ${updating.length} ${updating.length === 1 ? "machine" : "machines"}?`}
</AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogDescription id={descriptionId}>
{version !== null ? (
<>
T3 Code <span className="font-mono text-xs">{version}</span>
Expand Down Expand Up @@ -214,17 +208,18 @@ function UpdateEverythingDialogContent({
</div>
</ScrollArea>
<AlertDialogFooter>
{/* Thread counts come from client snapshots that can still be loading, so the warning
cannot depend on them. */}
{disabledReason !== null ? (
<p className="self-center text-sm max-sm:order-1 max-sm:text-center sm:mr-auto">
{disabledReason}
</p>
) : running > 0 ? (
<p className="self-center text-muted-foreground text-sm max-sm:order-1 max-sm:text-center sm:mr-auto">
{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) ? (
<p
id={`${descriptionId}-interruption`}
className="self-center text-muted-foreground text-sm max-sm:order-1 max-sm:text-center sm:mr-auto"
>
Updates interrupt tasks and tool calls.
</p>
) : null}
<AlertDialogClose ref={cancelRef} render={<Button variant="outline" disabled={busy} />}>
Expand Down
Loading