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..8cd5c5ca777d5 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -1,6 +1,8 @@ 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 { environmentThreadShells } from "~/state/threads"; import { @@ -13,7 +15,10 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import type { RestartingMachine, SkippedMachine } from "./updateEverything.logic"; +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"; const isRunning = (status: string) => status === "preparing" || status === "starting" || status === "running"; @@ -37,35 +42,54 @@ 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; } +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"}> - + 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) { + cancelRef, +}: UpdateEverythingDialogContentProps) { const stopped = useAtomValue( runningThreadCountAtom( restarts @@ -84,16 +108,96 @@ 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 ( <> - - {summary}? - - {lines.join("\n")} + + + Update {updating.length} {updating.length === 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 + +
+ {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} +
+
+ {running > 0 ? ( -

+

{stopped === 0 ? `${threads} will be interrupted and can resume after the restart.` : continuing === 0 @@ -101,16 +205,16 @@ 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 + }> + 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..d43bc670a18b6 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,43 @@ 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", + localMachines: [ + { environmentId: id("local"), label: "Mainbook" }, + { environmentId: id("wsl"), label: "Mainbook WSL" }, + ], + 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"]], + ["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 d4fb711dfe6ab..71247b6214028 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; + /** Machines whose servers this app hosts; this app's update restarts them. */ + readonly localMachines: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; 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 machines 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,46 @@ 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) { + 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) { - 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..af38bbf5d4bc4 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -78,9 +78,11 @@ export function useUpdateEverything(localUpdateVersion: string | null) { const providers = useProviderUpdates(); const updateServer = useServerUpdate(); - const { summary, lines } = describeUpdateEverything({ + const view = describeUpdateEverything({ localVersion: localUpdateVersion, + localMachines: machines.filter((machine) => machine.hostedByThisApp), servers, + skipped, providerMachines: providers.machines.map((machine) => ({ environmentId: machine.environmentId, label: machine.label, @@ -92,10 +94,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,