From 493b51f3c8ccf291e45b4712a99297f5b31277b3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 17:09:21 -0700 Subject: [PATCH 1/3] fix(web): update-everything dialog lists machines as rows Co-Authored-By: Claude Opus 5.5 --- .../SidebarUpdatePill.browser.test.tsx | 5 +- .../components/sidebar/SidebarUpdatePill.tsx | 6 +- .../sidebar/UpdateEverythingDialog.tsx | 116 +++++++++++++++--- .../sidebar/updateEverything.logic.test.ts | 37 ++++++ .../sidebar/updateEverything.logic.ts | 68 +++++++--- .../components/sidebar/useUpdateEverything.ts | 9 +- 6 files changed, 194 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index c7b1ca448775c..714d1564bac0a 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -37,9 +37,10 @@ vi.mock("./useUpdateEverything", () => ({ useUpdateEverything: () => ({ hasUpdates: mocks.hasUpdates, summary: "Update everything", - lines: [], + version: null, + providerCount: 0, + rows: [], restarts: [], - skipped: [], }), })); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 89c992f8cc9ed..ecfeb4d664a3b 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -396,10 +396,10 @@ function SidebarUpdateControl() { void runEverything()} /> ); diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index e235047d36e1e..e5d35b4649d6a 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -1,7 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; import { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; +import { Fragment } from "react"; +import { cn } from "~/lib/utils"; import { environmentThreadShells } from "~/state/threads"; import { AlertDialog, @@ -13,7 +15,9 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import type { RestartingMachine, SkippedMachine } from "./updateEverything.logic"; +import { MiddleTruncate } from "../ui/middle-truncate"; +import { ScrollArea } from "../ui/scroll-area"; +import type { RestartingMachine, UpdateEverythingRow } from "./updateEverything.logic"; const isRunning = (status: string) => status === "preparing" || status === "starting" || status === "running"; @@ -37,10 +41,10 @@ const runningThreadCountAtom = Atom.family((environmentIds: string) => interface UpdateEverythingDialogProps { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; - readonly summary: string; - readonly lines: ReadonlyArray; + readonly version: string | null; + readonly providerCount: number; + readonly rows: ReadonlyArray; readonly restarts: ReadonlyArray; - readonly skipped: ReadonlyArray; readonly onConfirm: () => void; } @@ -52,18 +56,27 @@ export function UpdateEverythingDialog({ return ( {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} - closeType === "keyboard"}> + closeType === "keyboard"} + > ); } +const APP_LABELS = { + "this-app": "This app", + server: "Server", + "desktop-app": "Desktop app", +} as const; + function UpdateEverythingDialogContent({ - summary, - lines, + version, + providerCount, + rows, restarts, - skipped, onConfirm, }: Omit) { const stopped = useAtomValue( @@ -84,16 +97,81 @@ function UpdateEverythingDialogContent({ ); const running = stopped + continuing; const threads = `${running} running ${running === 1 ? "thread" : "threads"}`; + const restarting = new Set(restarts.map((machine) => machine.environmentId)); + const machineCount = rows.filter((row) => row.app !== null || row.providers.length > 0).length; return ( <> - - {summary}? - - {lines.join("\n")} + + + Update {machineCount} {machineCount === 1 ? "machine" : "machines"}? + + + {version !== null ? ( + <> + T3 Code {version} + + ) : null} + {version !== null && providerCount > 0 ? " ยท " : null} + {providerCount > 0 + ? `${providerCount} provider ${providerCount === 1 ? "update" : "updates"}` + : null} + + + {/* Machine names truncate first. Provider lists wrap between names, never inside one. */} +
+
+ Machine + T3 Code + Providers + +
+ {rows.map((row) => ( +
+ + + + + {row.app !== null ? APP_LABELS[row.app] : null} + {row.app === null && row.skipped !== null ? ( + Skipped + ) : null} + {row.app === "this-app" ? ( + restarts last + ) : null} + + + {row.providers.map((provider, index) => ( + + {provider} + {index < row.providers.length - 1 ? ", " : null} + + ))} + + + {row.environmentId !== null && restarting.has(row.environmentId) ? ( + + ) : null} + + {row.skipped !== null ? ( + + {row.skipped} + + ) : null} +
+ ))} +
+
+ {running > 0 ? ( -

+

{stopped === 0 ? `${threads} will be interrupted and can resume after the restart.` : continuing === 0 @@ -101,16 +179,14 @@ function UpdateEverythingDialogContent({ : `${threads} will be interrupted. ${continuing} can resume after the restart.`}

) : null} - {skipped.length > 0 ? ( -

- {skipped.map((machine) => `Skips ${machine.label}: ${machine.reason}`).join("\n")} -

- ) : null} -
- }>Cancel ); } + +function RunningThreads({ environmentId }: { readonly environmentId: EnvironmentId }) { + const count = useAtomValue(runningThreadCountAtom(environmentId)); + return count > 0 ? {count} running : null; +} diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 2bcfd7530b0db..881fe7d9bbf95 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { collectRestartingMachines, collectServerUpdateTargets, + describeUpdateEverything, runUpdateEverything, type UpdateEverythingMachine, } from "./updateEverything.logic"; @@ -142,3 +143,39 @@ describe("runUpdateEverything", () => { expect(result.localReady).toBe(false); }); }); + +describe("describeUpdateEverything", () => { + it("gives each machine one row with its T3 Code and provider updates", () => { + const id = EnvironmentId.make; + const { rows } = describeUpdateEverything({ + localVersion: "1.1.0", + localMachine: { environmentId: id("local"), label: "Mainbook" }, + servers: [ + { + environmentId: id("npm"), + serverLabel: "bb-1", + selfUpdate: "respawn", + targetVersion: "1.1.0", + }, + ], + providerMachines: [ + { environmentId: id("npm"), label: "bb-1", providers: ["Codex", "Claude"] }, + { environmentId: id("local"), label: "Mainbook", providers: ["Codex"] }, + { environmentId: id("manual"), label: "alvin", providers: ["Claude"] }, + ], + skipped: [ + { environmentId: id("manual"), label: "alvin", reason: "needs a manual update" }, + { environmentId: id("offline"), label: "TheoRoom", reason: "not connected" }, + ], + }); + + expect( + rows.map(({ label, app, skipped, providers }) => [label, app, skipped, providers]), + ).toEqual([ + ["Mainbook", "this-app", null, ["Codex"]], + ["bb-1", "server", null, ["Codex", "Claude"]], + ["alvin", null, "needs a manual update", ["Claude"]], + ["TheoRoom", null, "not connected", []], + ]); + }); +}); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index d4fb711dfe6ab..b97b7019f1725 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -356,6 +356,11 @@ export function getUpdateEverythingToastView( export interface UpdateEverythingPlan { readonly localVersion: string | null; + /** The machine whose server this app hosts; this app's update restarts it. */ + readonly localMachine: { + readonly environmentId: EnvironmentId; + readonly label: string; + } | null; readonly servers: ReadonlyArray< Pick >; @@ -364,6 +369,15 @@ export interface UpdateEverythingPlan { readonly label: string; readonly providers: ReadonlyArray; }>; + readonly skipped: ReadonlyArray; +} + +export interface UpdateEverythingRow { + readonly environmentId: EnvironmentId | null; + readonly label: string; + readonly app: "this-app" | "server" | "desktop-app" | null; + readonly skipped: string | null; + readonly providers: ReadonlyArray; } function countLabel(count: number, noun: string): string | null { @@ -375,9 +389,12 @@ function joinList(parts: ReadonlyArray): string { return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`; } +/** One row per machine: this app's machine first, then servers, provider-only, and skipped. */ export function describeUpdateEverything(plan: UpdateEverythingPlan): { readonly summary: string; - readonly lines: ReadonlyArray; + readonly version: string | null; + readonly providerCount: number; + readonly rows: ReadonlyArray; } { const providerCount = plan.providerMachines.reduce( (count, machine) => count + machine.providers.length, @@ -391,30 +408,45 @@ export function describeUpdateEverything(plan: UpdateEverythingPlan): { ].filter((part) => part !== null), )}`; - const machines = new Map(); - const itemsFor = (environmentId: EnvironmentId, label: string) => { - const existing = machines.get(environmentId); - if (existing) return existing.items; - const items: string[] = []; - machines.set(environmentId, { label, items }); - return items; + const rows = new Map(); + const update = ( + environmentId: EnvironmentId | null, + label: string, + change: Partial, + ) => { + const row = rows.get(environmentId) ?? { + environmentId, + label, + app: null, + skipped: null, + providers: [], + }; + rows.set(environmentId, { ...row, ...change }); }; + if (plan.localVersion) { + update(plan.localMachine?.environmentId ?? null, plan.localMachine?.label ?? "This app", { + app: "this-app", + }); + } for (const server of plan.servers) { - itemsFor(server.environmentId, server.serverLabel).push( - server.selfUpdate === "desktop-managed" - ? "desktop app update" - : `server to ${server.targetVersion}`, - ); + update(server.environmentId, server.serverLabel, { + app: server.selfUpdate === "desktop-managed" ? "desktop-app" : "server", + }); } for (const machine of plan.providerMachines) { - itemsFor(machine.environmentId, machine.label).push(...machine.providers); + update(machine.environmentId, machine.label, { providers: machine.providers }); + } + for (const machine of plan.skipped) { + update(machine.environmentId, machine.label, { skipped: machine.reason }); } return { summary, - lines: [ - ...(plan.localVersion ? [`This app: ${plan.localVersion}, restarts last`] : []), - ...Array.from(machines.values(), ({ label, items }) => `${label}: ${items.join(", ")}`), - ], + version: + plan.localVersion ?? + plan.servers.find((server) => server.selfUpdate !== "desktop-managed")?.targetVersion ?? + null, + providerCount, + rows: Array.from(rows.values()), }; } diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index e9e5a9b78efc6..08e8ef6427daa 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -78,9 +78,12 @@ export function useUpdateEverything(localUpdateVersion: string | null) { const providers = useProviderUpdates(); const updateServer = useServerUpdate(); - const { summary, lines } = describeUpdateEverything({ + const localMachine = machines.find((machine) => machine.hostedByThisApp) ?? null; + const view = describeUpdateEverything({ localVersion: localUpdateVersion, + localMachine, servers, + skipped, providerMachines: providers.machines.map((machine) => ({ environmentId: machine.environmentId, label: machine.label, @@ -92,10 +95,8 @@ export function useUpdateEverything(localUpdateVersion: string | null) { return { hasUpdates: servers.length > 0 || providers.machines.length > 0, - summary, - lines, + ...view, restarts, - skipped, run: async ({ downloadLocal, installLocal, From 75acb2ca2be052306b8690c404b86048b4092b38 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 19:01:33 -0700 Subject: [PATCH 2/3] fix(web): fold skipped machines in the update dialog Co-Authored-By: Claude Opus 5.5 --- .../sidebar/UpdateEverythingDialog.tsx | 136 +++++++++++------- 1 file changed, 82 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index e5d35b4649d6a..8cd5c5ca777d5 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -1,9 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; import { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; -import { Fragment } from "react"; +import { ChevronRightIcon } from "lucide-react"; +import { Fragment, type RefObject, useRef } from "react"; -import { cn } from "~/lib/utils"; import { environmentThreadShells } from "~/state/threads"; import { AlertDialog, @@ -15,6 +15,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; 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"; @@ -48,19 +49,28 @@ interface UpdateEverythingDialogProps { readonly onConfirm: () => void; } +type UpdateEverythingDialogContentProps = Omit< + UpdateEverythingDialogProps, + "open" | "onOpenChange" +> & { + readonly cancelRef: RefObject; +}; + export function UpdateEverythingDialog({ open, onOpenChange, ...content }: UpdateEverythingDialogProps) { + const cancelRef = useRef(null); return ( {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} closeType === "keyboard"} > - + ); @@ -78,7 +88,8 @@ function UpdateEverythingDialogContent({ rows, restarts, onConfirm, -}: Omit) { + cancelRef, +}: UpdateEverythingDialogContentProps) { const stopped = useAtomValue( runningThreadCountAtom( restarts @@ -98,13 +109,14 @@ function UpdateEverythingDialogContent({ const running = stopped + continuing; const threads = `${running} running ${running === 1 ? "thread" : "threads"}`; const restarting = new Set(restarts.map((machine) => machine.environmentId)); - const machineCount = rows.filter((row) => row.app !== null || row.providers.length > 0).length; + const updating = rows.filter((row) => row.app !== null || row.providers.length > 0); + const skipped = rows.filter((row) => row.skipped !== null); return ( <> - Update {machineCount} {machineCount === 1 ? "machine" : "machines"}? + Update {updating.length} {updating.length === 1 ? "machine" : "machines"}? {version !== null ? ( @@ -119,54 +131,68 @@ function UpdateEverythingDialogContent({ - {/* Machine names truncate first. Provider lists wrap between names, never inside one. */} -
-
- Machine - T3 Code - Providers - -
- {rows.map((row) => ( -
- - - - - {row.app !== null ? APP_LABELS[row.app] : null} - {row.app === null && row.skipped !== null ? ( - Skipped - ) : null} - {row.app === "this-app" ? ( - restarts last - ) : null} - - - {row.providers.map((provider, index) => ( - - {provider} - {index < row.providers.length - 1 ? ", " : null} - - ))} - - - {row.environmentId !== null && restarting.has(row.environmentId) ? ( - - ) : null} - - {row.skipped !== null ? ( - - {row.skipped} - - ) : null} +
+ {/* Machine names truncate first. Provider lists wrap between names, never inside one. */} +
+
+ Machine + T3 Code + Providers +
- ))} + {updating.map((row) => ( +
+ + + + + {row.app !== null ? APP_LABELS[row.app] : null} + {row.app === null && row.skipped !== null ? ( + Skipped + ) : null} + {row.app === "this-app" ? ( + restarts last + ) : null} + + + {row.providers.map((provider, index) => ( + + {provider} + {index < row.providers.length - 1 ? ", " : null} + + ))} + + + {row.environmentId !== null && restarting.has(row.environmentId) ? ( + + ) : null} + +
+ ))} +
+ {skipped.length > 0 ? ( + + + + {skipped.length} skipped + + +
+ {skipped.map((row) => ( + + + + + {row.skipped} + + ))} +
+
+
+ ) : null}
@@ -179,7 +205,9 @@ function UpdateEverythingDialogContent({ : `${threads} will be interrupted. ${continuing} can resume after the restart.`}

) : null} - }>Cancel + }> + Cancel +
From 446b91961084d3865f5d67d7c9597eb42f0a5359 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 19:17:28 -0700 Subject: [PATCH 3/3] fix(web): show every backend this app hosts in the update dialog Co-Authored-By: Claude Opus 5.5 --- .../sidebar/updateEverything.logic.test.ts | 6 +++++- .../components/sidebar/updateEverything.logic.ts | 15 ++++++++------- .../src/components/sidebar/useUpdateEverything.ts | 3 +-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 881fe7d9bbf95..d43bc670a18b6 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -149,7 +149,10 @@ describe("describeUpdateEverything", () => { const id = EnvironmentId.make; const { rows } = describeUpdateEverything({ localVersion: "1.1.0", - localMachine: { environmentId: id("local"), label: "Mainbook" }, + localMachines: [ + { environmentId: id("local"), label: "Mainbook" }, + { environmentId: id("wsl"), label: "Mainbook WSL" }, + ], servers: [ { environmentId: id("npm"), @@ -173,6 +176,7 @@ describe("describeUpdateEverything", () => { rows.map(({ label, app, skipped, providers }) => [label, app, skipped, providers]), ).toEqual([ ["Mainbook", "this-app", null, ["Codex"]], + ["Mainbook WSL", "this-app", null, []], ["bb-1", "server", null, ["Codex", "Claude"]], ["alvin", null, "needs a manual update", ["Claude"]], ["TheoRoom", null, "not connected", []], diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index b97b7019f1725..71247b6214028 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -356,11 +356,11 @@ export function getUpdateEverythingToastView( export interface UpdateEverythingPlan { readonly localVersion: string | null; - /** The machine whose server this app hosts; this app's update restarts it. */ - readonly localMachine: { + /** Machines whose servers this app hosts; this app's update restarts them. */ + readonly localMachines: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string; - } | null; + }>; readonly servers: ReadonlyArray< Pick >; @@ -389,7 +389,7 @@ function joinList(parts: ReadonlyArray): string { return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`; } -/** One row per machine: this app's machine first, then servers, provider-only, and skipped. */ +/** One row per machine: this app's machines first, then servers, provider-only, and skipped. */ export function describeUpdateEverything(plan: UpdateEverythingPlan): { readonly summary: string; readonly version: string | null; @@ -424,9 +424,10 @@ export function describeUpdateEverything(plan: UpdateEverythingPlan): { rows.set(environmentId, { ...row, ...change }); }; if (plan.localVersion) { - update(plan.localMachine?.environmentId ?? null, plan.localMachine?.label ?? "This app", { - app: "this-app", - }); + if (plan.localMachines.length === 0) update(null, "This app", { app: "this-app" }); + for (const machine of plan.localMachines) { + update(machine.environmentId, machine.label, { app: "this-app" }); + } } for (const server of plan.servers) { update(server.environmentId, server.serverLabel, { diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index 08e8ef6427daa..af38bbf5d4bc4 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -78,10 +78,9 @@ export function useUpdateEverything(localUpdateVersion: string | null) { const providers = useProviderUpdates(); const updateServer = useServerUpdate(); - const localMachine = machines.find((machine) => machine.hostedByThisApp) ?? null; const view = describeUpdateEverything({ localVersion: localUpdateVersion, - localMachine, + localMachines: machines.filter((machine) => machine.hostedByThisApp), servers, skipped, providerMachines: providers.machines.map((machine) => ({