Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions apps/web/src/components/ProviderUpdatesAction.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { AuthProvidersManageScope, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
import {
AuthProvidersManageScope,
type EnvironmentId,
PROVIDER_DISPLAY_NAMES,
} from "@t3tools/contracts";
import {
isAtomCommandInterrupted,
settlePromise,
Expand All @@ -16,6 +20,7 @@ import {
getProviderUpdateRunToastView,
type ProviderUpdateRun,
} from "./ProviderUpdateLaunchNotification.logic";
import { selectUpdateMachines } from "./sidebar/updateEverything.logic";
import { Button } from "./ui/button";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
Expand Down Expand Up @@ -90,19 +95,26 @@ export function useProviderUpdates() {
: [];
});

const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
const updateAll = async ({
quiet = false,
environmentIds,
}: {
readonly quiet?: boolean;
readonly environmentIds?: ReadonlyArray<EnvironmentId>;
} = {}) => {
const runs = await Promise.all(
machines.flatMap(({ environmentId, label, candidates }) =>
candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
environmentId,
machineLabel: label,
driver: candidate.driver,
instanceId: candidate.instanceId,
result: await updateProvider({
selectUpdateMachines(machines, environmentIds).flatMap(
({ environmentId, label, candidates }) =>
candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
environmentId,
input: { provider: candidate.driver, instanceId: candidate.instanceId },
}),
})),
machineLabel: label,
driver: candidate.driver,
instanceId: candidate.instanceId,
result: await updateProvider({
environmentId,
input: { provider: candidate.driver, instanceId: candidate.instanceId },
}),
})),
),
);
const view = quiet ? null : getProviderUpdateRunToastView(runs);
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ import {
import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
import { SidebarUpdateCapsule } from "./SidebarUpdateCapsule";
import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
import type { UpdateEverythingRun, UpdateRunLocalState } from "./updateEverything.logic";
import type {
UpdateEverythingRun,
UpdateRunLocalState,
UpdateMachineSelection,
} from "./updateEverything.logic";
import {
clearSavedUpdateEverythingResult,
saveUpdateEverythingResult,
Expand Down Expand Up @@ -361,12 +365,13 @@ function SidebarUpdateControl() {
void Promise.allSettled([appCheck, providersChecked]).then(() => setIsActionPending(false));
};

const runEverything = async () => {
const runEverything = async (selection?: UpdateMachineSelection) => {
const bridge = window.desktopBridge;
setIsConfirmingEverything(false);
setIsActionPending(true);
const result = await settlePromise(() =>
everything.run({
...(selection ? { selection } : {}),
...(bridge && action === "download"
? {
downloadLocal: (reportError) =>
Expand Down Expand Up @@ -420,7 +425,8 @@ function SidebarUpdateControl() {
rows={everything.rows}
restarts={everything.restarts}
checking={providerCheck === "dialog"}
onConfirm={() => void runEverything()}
selectable
onConfirm={(selection) => void runEverything(selection)}
/>
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { EnvironmentId } from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import "~/index.css";
import { render } from "../../../test/render";
import { describeUpdateEverything } from "./updateEverything.logic";
import { UpdateEverythingDialog } from "./UpdateEverythingDialog";

it("lets a remote user update the laptop without updating the PC host", async () => {
const pc = EnvironmentId.make("pc");
const laptop = EnvironmentId.make("laptop");
const onConfirm = vi.fn();
await using _screen = await render(
<UpdateEverythingDialog
open
onOpenChange={() => {}}
selectable
version="0.2.0"
providerCount={2}
rows={[
{
environmentId: pc,
label: "PC host",
app: "this-app",
providers: ["Codex"],
skipped: null,
},
{
environmentId: laptop,
label: "Laptop",
app: "desktop-app",
providers: ["Claude"],
skipped: null,
},
]}
restarts={[]}
onConfirm={onConfirm}
/>,
);
await expect.element(page.getByRole("heading", { name: "Update 2 machines?" })).toBeVisible();
await userEvent.click(page.getByRole("checkbox", { name: "PC host" }));
await expect.element(page.getByRole("heading", { name: "Update 1 machine?" })).toBeVisible();
await expect.element(page.getByText(/1 provider update$/)).toBeVisible();
await userEvent.click(page.getByRole("checkbox", { name: "Laptop" }));
await expect.element(page.getByRole("button", { name: "Update", exact: true })).toBeDisabled();
await userEvent.click(page.getByRole("checkbox", { name: "Laptop" }));
await userEvent.click(page.getByRole("button", { name: "Update", exact: true }));
expect(onConfirm).toHaveBeenCalledWith({ environmentIds: [laptop], updateLocal: false });
});
it("keeps local provider exclusions when an app update arrives", async () => {
const pc = EnvironmentId.make("pc");
const laptop = EnvironmentId.make("laptop");
const onConfirm = vi.fn();
const dialog = (app: "this-app" | null) => (
<UpdateEverythingDialog
open
onOpenChange={() => {}}
selectable
checking
version={app === null ? null : "0.2.0"}
providerCount={2}
rows={
describeUpdateEverything({
localVersion: app === null ? null : "0.2.0",
localMachines: [{ environmentId: pc, label: "PC host" }],
servers: [],
providerMachines: [
{ environmentId: pc, label: "PC host", providers: ["Codex"] },
{ environmentId: laptop, label: "Laptop", providers: ["Claude"] },
],
skipped: [],
}).rows
}
restarts={[]}
onConfirm={onConfirm}
/>
);
await using screen = await render(dialog(null));
await userEvent.click(page.getByRole("checkbox", { name: "PC host" }));
await screen.rerender(dialog("this-app"));
await expect.element(page.getByRole("checkbox", { name: "PC host" })).not.toBeChecked();
await expect
.element(page.getByText(/1 provider update/))
.toHaveTextContent("1 provider update · Checking for more…");
await userEvent.click(page.getByRole("button", { name: "Update", exact: true }));
expect(onConfirm).toHaveBeenCalledWith({ environmentIds: [laptop], updateLocal: false });
await userEvent.click(page.getByRole("checkbox", { name: "Laptop" }));
await expect.element(page.getByText("Checking for more…", { exact: true })).toBeVisible();
});
82 changes: 67 additions & 15 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, useRef, useState } from "react";

import { environmentThreadShells } from "~/state/threads";
import {
Expand All @@ -14,11 +14,16 @@ import {
AlertDialogPopup,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Checkbox } from "../ui/checkbox";
import { Button } from "../ui/button";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
import { MiddleTruncate } from "../ui/middle-truncate";
import { ScrollArea } from "../ui/scroll-area";
import type { RestartingMachine, UpdateEverythingRow } from "./updateEverything.logic";
import type {
RestartingMachine,
UpdateEverythingRow,
UpdateMachineSelection,
} from "./updateEverything.logic";

const isRunning = (status: string) =>
status === "preparing" || status === "starting" || status === "running";
Expand Down Expand Up @@ -46,7 +51,8 @@ interface UpdateEverythingDialogProps {
readonly providerCount: number;
readonly rows: ReadonlyArray<UpdateEverythingRow>;
readonly restarts: ReadonlyArray<RestartingMachine>;
readonly onConfirm: () => void;
readonly onConfirm: (selection?: UpdateMachineSelection) => void;
readonly selectable?: boolean;
/** Overrides the "Update N machines?" title, for an update-track switch. */
readonly title?: string;
readonly confirmLabel?: string;
Expand Down Expand Up @@ -96,8 +102,9 @@ function UpdateEverythingDialogContent({
version,
providerCount,
rows,
restarts,
restarts: allRestarts,
onConfirm,
selectable = false,
cancelRef,
title,
confirmLabel = "Update",
Expand All @@ -106,6 +113,21 @@ function UpdateEverythingDialogContent({
busy = false,
checking = false,
}: UpdateEverythingDialogContentProps) {
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
const rowKey = (row: UpdateEverythingRow) =>
row.hostedByThisApp || row.app === "this-app" ? "this-app" : (row.environmentId ?? "this-app");
const updating = rows.filter((row) => row.app !== null || row.providers.length > 0);
const selected = updating.filter((row) => !selectable || !excluded.has(rowKey(row)));
const selectedIds = new Set(
selected.flatMap((row) => (row.environmentId === null ? [] : [row.environmentId])),
);
const restarts = allRestarts.filter(
(machine) => !selectable || selectedIds.has(machine.environmentId),
);
const selectedProviderCount = selectable
? selected.reduce((count, row) => count + row.providers.length, 0)
: providerCount;
const selectedVersion = selected.some((row) => row.app !== null) ? version : null;
const stopped = useAtomValue(
runningThreadCountAtom(
restarts
Expand All @@ -125,27 +147,26 @@ function UpdateEverythingDialogContent({
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);

return (
<>
<AlertDialogHeader className="shrink-0">
<AlertDialogTitle>
{title ?? `Update ${updating.length} ${updating.length === 1 ? "machine" : "machines"}?`}
{title ?? `Update ${selected.length} ${selected.length === 1 ? "machine" : "machines"}?`}
</AlertDialogTitle>
<AlertDialogDescription>
{version !== null ? (
{selectedVersion !== null ? (
<>
T3 Code <span className="font-mono text-xs">{version}</span>
T3 Code <span className="font-mono text-xs">{selectedVersion}</span>
</>
) : null}
{version !== null && providerCount > 0 ? " · " : null}
{providerCount > 0
? `${providerCount} provider ${providerCount === 1 ? "update" : "updates"}`
{selectedVersion !== null && selectedProviderCount > 0 ? " · " : null}
{selectedProviderCount > 0
? `${selectedProviderCount} provider ${selectedProviderCount === 1 ? "update" : "updates"}`
: null}
{checking
? `${version !== null || providerCount > 0 ? " · " : ""}Checking for more…`
? `${selectedVersion !== null || selectedProviderCount > 0 ? " · " : ""}Checking for more…`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
Expand All @@ -156,6 +177,11 @@ function UpdateEverythingDialogContent({
{note}
</p>
))}
{selectable ? (
<p className="text-muted-foreground text-sm">
Choose which machines to update. Only selected machines will be updated or restarted.
</p>
) : null}
{/* Machine names truncate first. Provider lists wrap between names, never inside one. */}
<div className="grid grid-cols-[minmax(4rem,max-content)_max-content_minmax(min-content,1fr)_max-content] gap-x-4 text-sm">
<div className="col-span-full grid grid-cols-subgrid border-b pb-1.5 text-muted-foreground text-xs">
Expand All @@ -169,9 +195,23 @@ function UpdateEverythingDialogContent({
key={row.environmentId ?? "this-app"}
className="col-span-full grid grid-cols-subgrid border-b border-border/50 py-1.5 last:border-b-0"
>
<span className="max-w-32 min-w-0">
<label className="flex max-w-40 min-w-0 items-center gap-2">
{selectable ? (
<Checkbox
checked={!excluded.has(rowKey(row))}
disabled={busy}
onCheckedChange={(checked) =>
setExcluded((previous) => {
const next = new Set(previous);
if (checked) next.delete(rowKey(row));
else next.add(rowKey(row));
return next;
})
}
/>
) : null}
<MiddleTruncate value={row.label} />
</span>
</label>
<span>
{row.app !== null ? APP_LABELS[row.app] : null}
{row.app === null && row.skipped !== null ? (
Expand Down Expand Up @@ -236,7 +276,19 @@ function UpdateEverythingDialogContent({
<AlertDialogClose ref={cancelRef} render={<Button variant="outline" disabled={busy} />}>
Cancel
</AlertDialogClose>
<Button disabled={disabledReason !== null || busy} onClick={onConfirm}>
<Button
disabled={disabledReason !== null || busy || (selectable && selected.length === 0)}
onClick={() =>
onConfirm(
selectable
? {
environmentIds: [...selectedIds],
updateLocal: selected.some((row) => row.app === "this-app"),
}
: undefined,
)
}
>
{confirmLabel}
</Button>
</AlertDialogFooter>
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/sidebar/updateEverything.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
describeUpdateCapsule,
describeUpdateEverything,
runUpdateEverything,
selectUpdateMachines,
type UpdateEverythingMachine,
} from "./updateEverything.logic";

Expand Down Expand Up @@ -361,6 +362,16 @@ describe("describeUpdateEverything", () => {
],
});

expect(
describeUpdateEverything({
localVersion: null,
localMachines: [{ environmentId: id("local"), label: "Mainbook" }],
servers: [],
providerMachines: [{ environmentId: id("local"), label: "Mainbook", providers: ["Codex"] }],
skipped: [],
}).rows[0],
).toMatchObject({ app: null, hostedByThisApp: true });

expect(
rows.map(({ label, app, skipped, providers }) => [label, app, skipped, providers]),
).toEqual([
Expand Down Expand Up @@ -402,3 +413,13 @@ describe("describeUpdateCapsule", () => {
);
});
});

describe("selectUpdateMachines", () => {
it("updates only selected machines and preserves the update-all default", () => {
const pc = { environmentId: EnvironmentId.make("pc") };
const laptop = { environmentId: EnvironmentId.make("laptop") };
expect(selectUpdateMachines([pc, laptop], [laptop.environmentId])).toEqual([laptop]);
expect(selectUpdateMachines([pc, laptop], [])).toEqual([]);
expect(selectUpdateMachines([pc, laptop])).toEqual([pc, laptop]);
});
});
Loading
Loading