Skip to content

Commit 91992bd

Browse files
t3dotggclaude
andauthored
fix(web): update dialog shows one row per machine (#26210)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2de4762 commit 91992bd

6 files changed

Lines changed: 229 additions & 50 deletions

File tree

‎apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ vi.mock("./useUpdateEverything", () => ({
3737
useUpdateEverything: () => ({
3838
hasUpdates: mocks.hasUpdates,
3939
summary: "Update everything",
40-
lines: [],
40+
version: null,
41+
providerCount: 0,
42+
rows: [],
4143
restarts: [],
42-
skipped: [],
4344
}),
4445
}));
4546

‎apps/web/src/components/sidebar/SidebarUpdatePill.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,10 @@ function SidebarUpdateControl() {
396396
<UpdateEverythingDialog
397397
open={isConfirmingEverything}
398398
onOpenChange={setIsConfirmingEverything}
399-
summary={everything.summary}
400-
lines={everything.lines}
399+
version={everything.version}
400+
providerCount={everything.providerCount}
401+
rows={everything.rows}
401402
restarts={everything.restarts}
402-
skipped={everything.skipped}
403403
onConfirm={() => void runEverything()}
404404
/>
405405
);

‎apps/web/src/components/sidebar/UpdateEverythingDialog.tsx‎

Lines changed: 127 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useAtomValue } from "@effect/atom-react";
22
import { EnvironmentId } from "@t3tools/contracts";
33
import { Atom } from "effect/reactivity";
4+
import { ChevronRightIcon } from "lucide-react";
5+
import { Fragment, type RefObject, useRef } from "react";
46

57
import { environmentThreadShells } from "~/state/threads";
68
import {
@@ -13,7 +15,10 @@ import {
1315
AlertDialogTitle,
1416
} from "../ui/alert-dialog";
1517
import { Button } from "../ui/button";
16-
import type { RestartingMachine, SkippedMachine } from "./updateEverything.logic";
18+
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
19+
import { MiddleTruncate } from "../ui/middle-truncate";
20+
import { ScrollArea } from "../ui/scroll-area";
21+
import type { RestartingMachine, UpdateEverythingRow } from "./updateEverything.logic";
1722

1823
const isRunning = (status: string) =>
1924
status === "preparing" || status === "starting" || status === "running";
@@ -37,35 +42,54 @@ const runningThreadCountAtom = Atom.family((environmentIds: string) =>
3742
interface UpdateEverythingDialogProps {
3843
readonly open: boolean;
3944
readonly onOpenChange: (open: boolean) => void;
40-
readonly summary: string;
41-
readonly lines: ReadonlyArray<string>;
45+
readonly version: string | null;
46+
readonly providerCount: number;
47+
readonly rows: ReadonlyArray<UpdateEverythingRow>;
4248
readonly restarts: ReadonlyArray<RestartingMachine>;
43-
readonly skipped: ReadonlyArray<SkippedMachine>;
4449
readonly onConfirm: () => void;
4550
}
4651

52+
type UpdateEverythingDialogContentProps = Omit<
53+
UpdateEverythingDialogProps,
54+
"open" | "onOpenChange"
55+
> & {
56+
readonly cancelRef: RefObject<HTMLButtonElement | null>;
57+
};
58+
4759
export function UpdateEverythingDialog({
4860
open,
4961
onOpenChange,
5062
...content
5163
}: UpdateEverythingDialogProps) {
64+
const cancelRef = useRef<HTMLButtonElement>(null);
5265
return (
5366
<AlertDialog open={open} onOpenChange={onOpenChange}>
5467
{/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */}
55-
<AlertDialogPopup finalFocus={(closeType) => closeType === "keyboard"}>
56-
<UpdateEverythingDialogContent {...content} />
68+
<AlertDialogPopup
69+
className="sm:max-w-xl"
70+
initialFocus={cancelRef}
71+
finalFocus={(closeType) => closeType === "keyboard"}
72+
>
73+
<UpdateEverythingDialogContent {...content} cancelRef={cancelRef} />
5774
</AlertDialogPopup>
5875
</AlertDialog>
5976
);
6077
}
6178

79+
const APP_LABELS = {
80+
"this-app": "This app",
81+
server: "Server",
82+
"desktop-app": "Desktop app",
83+
} as const;
84+
6285
function UpdateEverythingDialogContent({
63-
summary,
64-
lines,
86+
version,
87+
providerCount,
88+
rows,
6589
restarts,
66-
skipped,
6790
onConfirm,
68-
}: Omit<UpdateEverythingDialogProps, "open" | "onOpenChange">) {
91+
cancelRef,
92+
}: UpdateEverythingDialogContentProps) {
6993
const stopped = useAtomValue(
7094
runningThreadCountAtom(
7195
restarts
@@ -84,33 +108,113 @@ function UpdateEverythingDialogContent({
84108
);
85109
const running = stopped + continuing;
86110
const threads = `${running} running ${running === 1 ? "thread" : "threads"}`;
111+
const restarting = new Set(restarts.map((machine) => machine.environmentId));
112+
const updating = rows.filter((row) => row.app !== null || row.providers.length > 0);
113+
const skipped = rows.filter((row) => row.skipped !== null);
87114

88115
return (
89116
<>
90-
<AlertDialogHeader>
91-
<AlertDialogTitle>{summary}?</AlertDialogTitle>
92-
<AlertDialogDescription className="whitespace-pre-line">
93-
{lines.join("\n")}
117+
<AlertDialogHeader className="shrink-0">
118+
<AlertDialogTitle>
119+
Update {updating.length} {updating.length === 1 ? "machine" : "machines"}?
120+
</AlertDialogTitle>
121+
<AlertDialogDescription>
122+
{version !== null ? (
123+
<>
124+
T3 Code <span className="font-mono text-xs">{version}</span>
125+
</>
126+
) : null}
127+
{version !== null && providerCount > 0 ? " · " : null}
128+
{providerCount > 0
129+
? `${providerCount} provider ${providerCount === 1 ? "update" : "updates"}`
130+
: null}
94131
</AlertDialogDescription>
132+
</AlertDialogHeader>
133+
<ScrollArea scrollFade>
134+
<div className="flex flex-col gap-3 px-6 pb-4">
135+
{/* Machine names truncate first. Provider lists wrap between names, never inside one. */}
136+
<div className="grid grid-cols-[minmax(4rem,max-content)_max-content_minmax(min-content,1fr)_max-content] gap-x-4 text-sm">
137+
<div className="col-span-full grid grid-cols-subgrid border-b pb-1.5 text-muted-foreground text-xs">
138+
<span>Machine</span>
139+
<span>T3 Code</span>
140+
<span>Providers</span>
141+
<span />
142+
</div>
143+
{updating.map((row) => (
144+
<div
145+
key={row.environmentId ?? "this-app"}
146+
className="col-span-full grid grid-cols-subgrid border-b border-border/50 py-1.5 last:border-b-0"
147+
>
148+
<span className="max-w-32 min-w-0">
149+
<MiddleTruncate value={row.label} />
150+
</span>
151+
<span>
152+
{row.app !== null ? APP_LABELS[row.app] : null}
153+
{row.app === null && row.skipped !== null ? (
154+
<span className="text-muted-foreground">Skipped</span>
155+
) : null}
156+
{row.app === "this-app" ? (
157+
<span className="block text-muted-foreground text-xs">restarts last</span>
158+
) : null}
159+
</span>
160+
<span>
161+
{row.providers.map((provider, index) => (
162+
<Fragment key={provider}>
163+
<span className="whitespace-nowrap">{provider}</span>
164+
{index < row.providers.length - 1 ? ", " : null}
165+
</Fragment>
166+
))}
167+
</span>
168+
<span className="text-right">
169+
{row.environmentId !== null && restarting.has(row.environmentId) ? (
170+
<RunningThreads environmentId={row.environmentId} />
171+
) : null}
172+
</span>
173+
</div>
174+
))}
175+
</div>
176+
{skipped.length > 0 ? (
177+
<Collapsible>
178+
<CollapsibleTrigger className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground data-panel-open:[&_svg]:rotate-90">
179+
<ChevronRightIcon className="size-3.5 transition-transform" aria-hidden />
180+
{skipped.length} skipped
181+
</CollapsibleTrigger>
182+
<CollapsiblePanel>
183+
<div className="grid grid-cols-[minmax(0,max-content)_1fr] gap-x-3 gap-y-0.5 pt-1.5 ps-4.5 text-muted-foreground text-xs">
184+
{skipped.map((row) => (
185+
<Fragment key={row.environmentId ?? "this-app"}>
186+
<span className="max-w-32 min-w-0">
187+
<MiddleTruncate value={row.label} />
188+
</span>
189+
<span className="wrap-anywhere">{row.skipped}</span>
190+
</Fragment>
191+
))}
192+
</div>
193+
</CollapsiblePanel>
194+
</Collapsible>
195+
) : null}
196+
</div>
197+
</ScrollArea>
198+
<AlertDialogFooter>
95199
{running > 0 ? (
96-
<p className="pt-2 text-sm">
200+
<p className="self-center text-muted-foreground text-sm max-sm:order-1 max-sm:text-center sm:mr-auto">
97201
{stopped === 0
98202
? `${threads} will be interrupted and can resume after the restart.`
99203
: continuing === 0
100204
? `${threads} will be interrupted.`
101205
: `${threads} will be interrupted. ${continuing} can resume after the restart.`}
102206
</p>
103207
) : null}
104-
{skipped.length > 0 ? (
105-
<p className="whitespace-pre-line pt-2 text-sm">
106-
{skipped.map((machine) => `Skips ${machine.label}: ${machine.reason}`).join("\n")}
107-
</p>
108-
) : null}
109-
</AlertDialogHeader>
110-
<AlertDialogFooter>
111-
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
208+
<AlertDialogClose ref={cancelRef} render={<Button variant="outline" />}>
209+
Cancel
210+
</AlertDialogClose>
112211
<Button onClick={onConfirm}>Update</Button>
113212
</AlertDialogFooter>
114213
</>
115214
);
116215
}
216+
217+
function RunningThreads({ environmentId }: { readonly environmentId: EnvironmentId }) {
218+
const count = useAtomValue(runningThreadCountAtom(environmentId));
219+
return count > 0 ? <span className="whitespace-nowrap text-warning">{count} running</span> : null;
220+
}

‎apps/web/src/components/sidebar/updateEverything.logic.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44
import {
55
collectRestartingMachines,
66
collectServerUpdateTargets,
7+
describeUpdateEverything,
78
runUpdateEverything,
89
type UpdateEverythingMachine,
910
} from "./updateEverything.logic";
@@ -136,3 +137,43 @@ describe("runUpdateEverything", () => {
136137
expect(result.localReady).toBe(false);
137138
});
138139
});
140+
141+
describe("describeUpdateEverything", () => {
142+
it("gives each machine one row with its T3 Code and provider updates", () => {
143+
const id = EnvironmentId.make;
144+
const { rows } = describeUpdateEverything({
145+
localVersion: "1.1.0",
146+
localMachines: [
147+
{ environmentId: id("local"), label: "Mainbook" },
148+
{ environmentId: id("wsl"), label: "Mainbook WSL" },
149+
],
150+
servers: [
151+
{
152+
environmentId: id("npm"),
153+
serverLabel: "bb-1",
154+
selfUpdate: "respawn",
155+
targetVersion: "1.1.0",
156+
},
157+
],
158+
providerMachines: [
159+
{ environmentId: id("npm"), label: "bb-1", providers: ["Codex", "Claude"] },
160+
{ environmentId: id("local"), label: "Mainbook", providers: ["Codex"] },
161+
{ environmentId: id("manual"), label: "alvin", providers: ["Claude"] },
162+
],
163+
skipped: [
164+
{ environmentId: id("manual"), label: "alvin", reason: "needs a manual update" },
165+
{ environmentId: id("offline"), label: "TheoRoom", reason: "not connected" },
166+
],
167+
});
168+
169+
expect(
170+
rows.map(({ label, app, skipped, providers }) => [label, app, skipped, providers]),
171+
).toEqual([
172+
["Mainbook", "this-app", null, ["Codex"]],
173+
["Mainbook WSL", "this-app", null, []],
174+
["bb-1", "server", null, ["Codex", "Claude"]],
175+
["alvin", null, "needs a manual update", ["Claude"]],
176+
["TheoRoom", null, "not connected", []],
177+
]);
178+
});
179+
});

‎apps/web/src/components/sidebar/updateEverything.logic.ts‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,11 @@ export function getUpdateEverythingToastView(
351351

352352
export interface UpdateEverythingPlan {
353353
readonly localVersion: string | null;
354+
/** Machines whose servers this app hosts; this app's update restarts them. */
355+
readonly localMachines: ReadonlyArray<{
356+
readonly environmentId: EnvironmentId;
357+
readonly label: string;
358+
}>;
354359
readonly servers: ReadonlyArray<
355360
Pick<ServerUpdateTarget, "environmentId" | "serverLabel" | "selfUpdate" | "targetVersion">
356361
>;
@@ -359,6 +364,15 @@ export interface UpdateEverythingPlan {
359364
readonly label: string;
360365
readonly providers: ReadonlyArray<string>;
361366
}>;
367+
readonly skipped: ReadonlyArray<SkippedMachine>;
368+
}
369+
370+
export interface UpdateEverythingRow {
371+
readonly environmentId: EnvironmentId | null;
372+
readonly label: string;
373+
readonly app: "this-app" | "server" | "desktop-app" | null;
374+
readonly skipped: string | null;
375+
readonly providers: ReadonlyArray<string>;
362376
}
363377

364378
function countLabel(count: number, noun: string): string | null {
@@ -370,9 +384,12 @@ function joinList(parts: ReadonlyArray<string>): string {
370384
return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
371385
}
372386

387+
/** One row per machine: this app's machines first, then servers, provider-only, and skipped. */
373388
export function describeUpdateEverything(plan: UpdateEverythingPlan): {
374389
readonly summary: string;
375-
readonly lines: ReadonlyArray<string>;
390+
readonly version: string | null;
391+
readonly providerCount: number;
392+
readonly rows: ReadonlyArray<UpdateEverythingRow>;
376393
} {
377394
const providerCount = plan.providerMachines.reduce(
378395
(count, machine) => count + machine.providers.length,
@@ -386,30 +403,46 @@ export function describeUpdateEverything(plan: UpdateEverythingPlan): {
386403
].filter((part) => part !== null),
387404
)}`;
388405

389-
const machines = new Map<EnvironmentId, { label: string; items: string[] }>();
390-
const itemsFor = (environmentId: EnvironmentId, label: string) => {
391-
const existing = machines.get(environmentId);
392-
if (existing) return existing.items;
393-
const items: string[] = [];
394-
machines.set(environmentId, { label, items });
395-
return items;
406+
const rows = new Map<EnvironmentId | null, UpdateEverythingRow>();
407+
const update = (
408+
environmentId: EnvironmentId | null,
409+
label: string,
410+
change: Partial<UpdateEverythingRow>,
411+
) => {
412+
const row = rows.get(environmentId) ?? {
413+
environmentId,
414+
label,
415+
app: null,
416+
skipped: null,
417+
providers: [],
418+
};
419+
rows.set(environmentId, { ...row, ...change });
396420
};
421+
if (plan.localVersion) {
422+
if (plan.localMachines.length === 0) update(null, "This app", { app: "this-app" });
423+
for (const machine of plan.localMachines) {
424+
update(machine.environmentId, machine.label, { app: "this-app" });
425+
}
426+
}
397427
for (const server of plan.servers) {
398-
itemsFor(server.environmentId, server.serverLabel).push(
399-
server.selfUpdate === "desktop-managed"
400-
? "desktop app update"
401-
: `server to ${server.targetVersion}`,
402-
);
428+
update(server.environmentId, server.serverLabel, {
429+
app: server.selfUpdate === "desktop-managed" ? "desktop-app" : "server",
430+
});
403431
}
404432
for (const machine of plan.providerMachines) {
405-
itemsFor(machine.environmentId, machine.label).push(...machine.providers);
433+
update(machine.environmentId, machine.label, { providers: machine.providers });
434+
}
435+
for (const machine of plan.skipped) {
436+
update(machine.environmentId, machine.label, { skipped: machine.reason });
406437
}
407438

408439
return {
409440
summary,
410-
lines: [
411-
...(plan.localVersion ? [`This app: ${plan.localVersion}, restarts last`] : []),
412-
...Array.from(machines.values(), ({ label, items }) => `${label}: ${items.join(", ")}`),
413-
],
441+
version:
442+
plan.localVersion ??
443+
plan.servers.find((server) => server.selfUpdate !== "desktop-managed")?.targetVersion ??
444+
null,
445+
providerCount,
446+
rows: Array.from(rows.values()),
414447
};
415448
}

0 commit comments

Comments
 (0)