From c189c94ddc8f3d90f7c7892ad5f487bc5befa9b6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 22:23:02 +0000 Subject: [PATCH 1/4] fix(web): update machines together in auto balance --- apps/web/src/components/ChatView.tsx | 52 ++++-- .../components/ServerUpdateAction.test.tsx | 161 +++++++++++++++++- .../web/src/components/ServerUpdateAction.tsx | 161 +++++++++++++----- .../chat/useAutoBalanceUpdateBanner.tsx | 160 +++++++++++++++++ 4 files changed, 474 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d1906ff975c..cfac490e39ad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -437,6 +437,7 @@ import { } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ServerUpdateAction } from "./ServerUpdateAction"; +import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; import { ComposerServerUpdateIcon, ComposerServerUpdateStatus, @@ -2314,6 +2315,35 @@ export default function ChatView(props: ChatViewProps) { advertisedFileAttachmentBytes === null ? null : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); + const envLocked = Boolean( + activeThread && + (activeThread.messages.length > 0 || + (activeThread.session !== null && activeThread.session.status !== "stopped")), + ); + + const loadBalancingSettings = useClientSettings(); + const automaticEnvironment = Boolean( + clientSettingsHydrated && + draftId && + !envLocked && + hasMultipleEnvironments && + loadBalancingSettings.loadBalancingEnabled && + draftThread?.environmentSelection !== "manual" && + (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && + (!draftThread?.branch || draftThread.environmentSelection === "auto") && + !draftThread?.worktreePath, + ); + const autoUpdateEnvironments = useMemo( + () => + automaticEnvironment + ? logicalProjectEnvironments.flatMap(({ environmentId }) => { + const environment = environmentById.get(environmentId); + return environment ? [environment] : []; + }) + : [], + [automaticEnvironment, logicalProjectEnvironments, environmentById], + ); + const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2417,6 +2447,7 @@ export default function ChatView(props: ChatViewProps) { } } if ( + !automaticEnvironment && serverUpdateEnvironmentId && !reconnectingThroughVersionSkew && (serverUpdateState.status === "idle" @@ -2495,8 +2526,11 @@ export default function ChatView(props: ChatViewProps) { }), }); } + if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); return items; }, [ + automaticEnvironment, + autoBalanceUpdateBanner, activeEnvironmentUnavailableState, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, @@ -3230,24 +3264,6 @@ export default function ChatView(props: ChatViewProps) { } }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); - const envLocked = Boolean( - activeThread && - (activeThread.messages.length > 0 || - (activeThread.session !== null && activeThread.session.status !== "stopped")), - ); - - const loadBalancingSettings = useClientSettings(); - const automaticEnvironment = Boolean( - clientSettingsHydrated && - draftId && - !envLocked && - hasMultipleEnvironments && - loadBalancingSettings.loadBalancingEnabled && - draftThread?.environmentSelection !== "manual" && - (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && - (!draftThread?.branch || draftThread.environmentSelection === "auto") && - !draftThread?.worktreePath, - ); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; const loadBalancingCandidates = useMemo( () => diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 67236361f677..6c388bca4f98 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,9 +1,10 @@ -import type { ReactElement } from "react"; +import { act, type ReactElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { renderToStaticMarkup } from "react-dom/server"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ updateServer: vi.fn(), @@ -30,7 +31,18 @@ vi.mock("./ui/toast", () => ({ toastManager: { add: testState.toast }, })); -import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; +import { + readConfirmDialogState, + registerConfirmDialogHost, + resetConfirmDialogForTests, + respondToConfirmDialog, +} from "~/confirmDialog"; +import { + ServerUpdateAction, + ServerUpdateProgress, + ServerUpdatesAction, + type ServerUpdateTarget, +} from "./ServerUpdateAction"; type ActionElement = ReactElement<{ readonly onClick?: () => void; @@ -194,6 +206,149 @@ describe("ServerUpdateAction", () => { }); }); +describe("ServerUpdatesAction", () => { + let renderer: ReactTestRenderer | undefined; + const targets: ReadonlyArray = [ + { + environmentId: "batch-a" as EnvironmentId, + serverLabel: "Laptop", + selfUpdate: "boot-service", + targetVersion: "0.0.31", + threadContinuation: true, + continueThreadsAfterServerUpdate: true, + }, + { + environmentId: "batch-b" as EnvironmentId, + serverLabel: "Office", + selfUpdate: "respawn", + targetVersion: "0.0.31", + threadContinuation: true, + continueThreadsAfterServerUpdate: false, + }, + { + environmentId: "batch-c" as EnvironmentId, + serverLabel: "Manual", + selfUpdate: null, + targetVersion: "0.0.31", + }, + ]; + const success = AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }); + + async function mount(batch = targets) { + await act(async () => { + renderer = create(); + }); + return renderer!.root.findByType("button"); + } + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + testState.updateServer.mockReset(); + testState.toast.mockReset(); + resetConfirmDialogForTests(); + }); + afterEach(async () => { + await act(async () => { + renderer?.unmount(); + }); + renderer = undefined; + resetConfirmDialogForTests(); + vi.unstubAllGlobals(); + }); + + it("updates both supported machines with their own continuation preference and skips the manual machine", async () => { + testState.updateServer.mockResolvedValue(success); + const button = await mount(); + await act(async () => { + button.props.onClick(); + }); + + expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([ + { + environmentId: "batch-a", + input: { targetVersion: "0.0.31", continueRunningThreads: true }, + }, + { environmentId: "batch-b", input: { targetVersion: "0.0.31" } }, + ]); + expect(testState.toast.mock.calls.map(([toast]) => toast.title)).toEqual([ + "Laptop updated", + "Office updated", + ]); + }); + + it("names a failed machine while letting the other machine complete", async () => { + testState.updateServer + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed")))) + .mockResolvedValueOnce(success); + const button = await mount(); + await act(async () => { + button.props.onClick(); + }); + + expect(testState.updateServer).toHaveBeenCalledTimes(2); + expect(testState.toast).toHaveBeenCalledWith({ + type: "error", + title: "Laptop update failed", + description: "Download failed", + }); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "success", title: "Office updated" }), + ); + expect(button.props.disabled).toBe(false); + }); + + it("starts each machine once when double-clicked and disables the action until both finish", async () => { + const completions: Array<() => void> = []; + testState.updateServer.mockImplementation( + () => + new Promise((resolve) => { + completions.push(() => resolve(success)); + }), + ); + const button = await mount(); + await act(async () => { + button.props.onClick(); + button.props.onClick(); + }); + expect(testState.updateServer).toHaveBeenCalledTimes(2); + expect(button.props.disabled).toBe(true); + await act(async () => { + completions[0]!(); + }); + expect(button.props.disabled).toBe(true); + await act(async () => { + completions[1]!(); + }); + expect(button.props.disabled).toBe(false); + expect(testState.toast).toHaveBeenCalledTimes(2); + }); + + it("asks once for desktop machines and cancels the entire batch", async () => { + registerConfirmDialogHost(); + const button = await mount( + targets.map((target, index) => + index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target, + ), + ); + await act(async () => { + button.props.onClick(); + }); + const confirmation = readConfirmDialogState(); + expect(confirmation).toEqual( + expect.objectContaining({ + status: "confirming", + message: expect.stringContaining("Laptop, Office"), + }), + ); + expect(testState.updateServer).not.toHaveBeenCalled(); + await act(async () => { + respondToConfirmDialog(false); + }); + expect(testState.updateServer).not.toHaveBeenCalled(); + expect(button.props.disabled).toBe(false); + }); +}); + describe("ServerUpdateProgress", () => { it("shows one calm status row for the restart wait", () => { const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 1845ada716b6..92ae193cfa53 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -4,7 +4,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ComponentProps } from "react"; +import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -34,6 +34,117 @@ function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } +export interface ServerUpdateTarget { + readonly environmentId: EnvironmentId; + readonly serverLabel: string; + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly desktopAppUpdate?: boolean; + readonly threadContinuation?: boolean; + readonly targetVersion: string; + readonly continueThreadsAfterServerUpdate?: boolean; +} + +function useServerUpdate() { + const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); + return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { + const { environmentId, serverLabel, selfUpdate, targetVersion } = target; + if (pendingUpdateEnvironmentIds.has(environmentId)) return; + pendingUpdateEnvironmentIds.add(environmentId); + try { + const result = await updateServer({ + environmentId, + input: { + targetVersion, + ...(target.threadContinuation && target.continueThreadsAfterServerUpdate + ? { continueRunningThreads: true } + : {}), + }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + toastManager.add({ + type: "error", + title: failureTitle, + description: updateFailureMessage(squashAtomCommandFailure(result)), + }); + return; + } + toastManager.add({ + type: "success", + title: `${serverLabel} updated`, + description: + selfUpdate === "desktop-managed" + ? `Desktop app relaunched on ${result.value.targetVersion}.` + : `Reconnected on t3@${result.value.targetVersion}.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: failureTitle, + description: updateFailureMessage(error), + }); + } finally { + pendingUpdateEnvironmentIds.delete(environmentId); + } + }; +} + +/** Updates eligible machines independently; manual paths remain in the machine list. */ +export function ServerUpdatesAction({ + targets, + label = "Update all", + variant = "outline", + size = "xs", +}: { + readonly targets: ReadonlyArray; + readonly label?: string; + readonly variant?: ComponentProps["variant"]; + readonly size?: ComponentProps["size"]; +}) { + const update = useServerUpdate(); + const pending = useRef(false); + const [isPending, setIsPending] = useState(false); + const eligible = targets.filter( + (target) => + target.selfUpdate !== null && + (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), + ); + const handleUpdate = async () => { + if (pending.current) return; + pending.current = true; + setIsPending(true); + try { + const available = eligible.filter( + (target) => !pendingUpdateEnvironmentIds.has(target.environmentId), + ); + const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed"); + if (desktopTargets.length > 0) { + 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; + if (!confirmed) return; + } + await Promise.all( + available.map((target) => update(target, `${target.serverLabel} update failed`)), + ); + } finally { + pending.current = false; + setIsPending(false); + } + }; + return ( + + ); +} + /** * One-row status for an in-flight server update: "Downloading…" then * "Restarting…". The update is a wait, not a warning: a single pulsing dot @@ -103,9 +214,7 @@ export function ServerUpdateAction({ environmentId, (settings) => settings.continueThreadsAfterServerUpdate, ); - const updateServer = useAtomCommand(serverEnvironment.updateServer, { - reportFailure: false, - }); + const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: "update command", onCopy: ({ command }) => { @@ -140,41 +249,15 @@ export function ServerUpdateAction({ return; } } - if (pendingUpdateEnvironmentIds.has(environmentId)) { - return; - } - pendingUpdateEnvironmentIds.add(environmentId); - try { - const result = await updateServer({ - environmentId, - input: { - targetVersion, - ...(threadContinuation && continueThreadsAfterServerUpdate - ? { continueRunningThreads: true } - : {}), - }, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - toastManager.add({ - type: "error", - title: "Server update failed", - description: updateFailureMessage(squashAtomCommandFailure(result)), - }); - return; - } - toastManager.add({ - type: "success", - title: `${serverLabel} updated`, - description: isDesktopAppUpdate - ? `Desktop app relaunched on ${result.value.targetVersion}.` - : `Reconnected on t3@${result.value.targetVersion}.`, - }); - } finally { - pendingUpdateEnvironmentIds.delete(environmentId); - } + await update({ + environmentId, + serverLabel, + selfUpdate, + desktopAppUpdate, + threadContinuation, + targetVersion, + continueThreadsAfterServerUpdate, + }); }; if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx new file mode 100644 index 000000000000..e26705beb053 --- /dev/null +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -0,0 +1,160 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { useMemo, useState } from "react"; + +import type { EnvironmentPresentation } from "~/state/environments"; +import { serverEnvironment } from "~/state/server"; +import { + buildVersionMismatchDismissalKey, + dismissServerUpdateFailure, + dismissVersionMismatch, + isServerUpdateFailureDismissed, + isVersionMismatchDismissed, + resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, + supportsDesktopAppUpdate, + supportsServerUpdateThreadContinuation, +} from "~/versionSkew"; +import { + ServerUpdateAction, + ServerUpdateProgress, + ServerUpdatesAction, +} from "../ServerUpdateAction"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; +import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus"; + +/** Keep every machine's update visible while auto balance has no single update target. */ +export function useAutoBalanceUpdateBanner( + environments: readonly EnvironmentPresentation[], +): ComposerBannerStackItem | null { + const statesAtom = useMemo( + () => + Atom.make((get) => + environments.map((environment) => ({ + environment, + state: get(serverEnvironment.updateStateAtom(environment.environmentId)), + })), + ), + [environments], + ); + const states = useAtomValue(statesAtom); + const [, refreshDismissals] = useState(0); + const machines = states.flatMap(({ environment, state }) => { + const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const dismissKey = mismatch + ? buildVersionMismatchDismissalKey(environment.environmentId, mismatch) + : null; + if ( + state.status === "idle" + ? !mismatch || isVersionMismatchDismissed(dismissKey) + : isServerUpdateFailureDismissed(state) + ) + return []; + const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig); + const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig); + return [ + { + environmentId: environment.environmentId, + serverLabel: environment.label, + selfUpdate, + desktopAppUpdate, + threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), + continueThreadsAfterServerUpdate: + environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false, + targetVersion: state.status === "idle" ? mismatch!.clientVersion : state.targetVersion, + connected: environment.connection.phase === "connected", + remoteUpdate: selfUpdate !== null && (selfUpdate !== "desktop-managed" || desktopAppUpdate), + state, + dismissKey, + }, + ]; + }); + if (machines.length === 0) return null; + + const running = machines.filter((machine) => machine.state.status === "running"); + const failed = machines.filter((machine) => machine.state.status === "failed"); + const manual = machines.filter((machine) => !machine.remoteUpdate); + const targets = machines.filter( + (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running", + ); + const title = + running.length > 0 + ? `Updating ${running.length} ${running.length === 1 ? "machine" : "machines"}` + : failed.length > 0 + ? `Could not update ${failed.length} ${failed.length === 1 ? "machine" : "machines"}` + : `Update available for ${machines.length} ${machines.length === 1 ? "machine" : "machines"}`; + return { + id: "auto-balance-server-updates", + variant: failed.length > 0 ? "error" : "default", + priority: running.length > 0 ? "urgent" : "notice", + icon: ( + 0 ? "running" : failed.length > 0 ? "failed" : "idle"} + /> + ), + title: ( + + + {title} + + +
+ {machines.map((machine) => ( +
+
{machine.serverLabel}
+ {machine.state.status !== "idle" ? ( + + ) : !machine.remoteUpdate ? ( + <> +
Manual update required
+ + + ) : ( +
+ {machine.connected + ? `Ready to update to ${machine.targetVersion}` + : "Reconnect this machine to update"} +
+ )} +
+ ))} +
+
+
+ ), + description: + manual.length > 0 + ? `${manual.length} ${manual.length === 1 ? "needs" : "need"} a manual update` + : undefined, + actions: + running.length === 0 && targets.length > 0 ? ( + 0 + ? "Retry" + : targets.length === machines.length + ? "Update all" + : `Update ${targets.length} ${targets.length === 1 ? "machine" : "machines"}` + } + /> + ) : undefined, + ...(running.length > 0 + ? {} + : { + dismissLabel: "Dismiss update notice", + onDismiss: () => { + for (const machine of machines) { + dismissServerUpdateFailure(machine.state); + dismissVersionMismatch(machine.dismissKey); + } + refreshDismissals((tick) => tick + 1); + }, + }), + }; +} From 2d8b088d453017ba20498836aef44223816eb255 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 22:39:09 +0000 Subject: [PATCH 2/4] fix(web): refresh dismissed machine update notices --- .../chat/useAutoBalanceUpdateBanner.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx index e26705beb053..78d7f249dc02 100644 --- a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { Atom } from "effect/unstable/reactivity"; import { useMemo, useState } from "react"; @@ -39,7 +40,9 @@ export function useAutoBalanceUpdateBanner( [environments], ); const states = useAtomValue(statesAtom); - const [, refreshDismissals] = useState(0); + const [dismissedNotices, setDismissedNotices] = useState>( + () => new Set(), + ); const machines = states.flatMap(({ environment, state }) => { const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const dismissKey = mismatch @@ -47,8 +50,10 @@ export function useAutoBalanceUpdateBanner( : null; if ( state.status === "idle" - ? !mismatch || isVersionMismatchDismissed(dismissKey) - : isServerUpdateFailureDismissed(state) + ? !mismatch || + (dismissKey !== null && dismissedNotices.has(dismissKey)) || + isVersionMismatchDismissed(dismissKey) + : dismissedNotices.has(state) || isServerUpdateFailureDismissed(state) ) return []; const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig); @@ -153,7 +158,14 @@ export function useAutoBalanceUpdateBanner( dismissServerUpdateFailure(machine.state); dismissVersionMismatch(machine.dismissKey); } - refreshDismissals((tick) => tick + 1); + setDismissedNotices((current) => { + const next = new Set(current); + for (const machine of machines) { + if (machine.dismissKey) next.add(machine.dismissKey); + if (machine.state.status === "failed") next.add(machine.state); + } + return next; + }); }, }), }; From 8a0968e371072a61d979570ccc4a35df55b792b4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 22:52:05 +0000 Subject: [PATCH 3/4] fix(web): show later machine update notices after dismissal --- apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx index 78d7f249dc02..270d9a0419b1 100644 --- a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -90,7 +90,7 @@ export function useAutoBalanceUpdateBanner( ? `Could not update ${failed.length} ${failed.length === 1 ? "machine" : "machines"}` : `Update available for ${machines.length} ${machines.length === 1 ? "machine" : "machines"}`; return { - id: "auto-balance-server-updates", + id: `auto-balance-server-updates-${dismissedNotices.size}`, variant: failed.length > 0 ? "error" : "default", priority: running.length > 0 ? "urgent" : "notice", icon: ( From 78f5b59e27399ef17971d30338163812517eca19 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 8 Sep 2026 03:05:56 +0000 Subject: [PATCH 4/4] refactor(web): simplify auto balance update handling --- .../web/src/components/ServerUpdateAction.tsx | 31 +++------- .../chat/useAutoBalanceUpdateBanner.tsx | 56 ++++++++----------- 2 files changed, 29 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 92ae193cfa53..76617a6c1296 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -44,6 +44,10 @@ export interface ServerUpdateTarget { readonly continueThreadsAfterServerUpdate?: boolean; } +type UpdateButtonProps = Pick, "variant" | "size"> & { + readonly label?: string; +}; + function useServerUpdate() { const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { @@ -62,12 +66,7 @@ function useServerUpdate() { }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) return; - toastManager.add({ - type: "error", - title: failureTitle, - description: updateFailureMessage(squashAtomCommandFailure(result)), - }); - return; + throw squashAtomCommandFailure(result); } toastManager.add({ type: "success", @@ -95,11 +94,8 @@ export function ServerUpdatesAction({ label = "Update all", variant = "outline", size = "xs", -}: { +}: UpdateButtonProps & { readonly targets: ReadonlyArray; - readonly label?: string; - readonly variant?: ComponentProps["variant"]; - readonly size?: ComponentProps["size"]; }) { const update = useServerUpdate(); const pending = useRef(false); @@ -195,20 +191,7 @@ export function ServerUpdateAction({ label = "Update", variant = "outline", size = "xs", -}: { - readonly environmentId: EnvironmentId; - readonly serverLabel: string; - readonly selfUpdate: ServerSelfUpdateCapability | null; - /** The desktop app supervising this server accepts remote update - requests (capabilities.desktopAppUpdate). */ - readonly desktopAppUpdate?: boolean; - /** The server can durably continue running provider turns after updating. */ - readonly threadContinuation?: boolean; - readonly targetVersion: string; - readonly label?: string; - readonly variant?: ComponentProps["variant"]; - readonly size?: ComponentProps["size"]; -}) { +}: Omit & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx index 270d9a0419b1..0d4584533980 100644 --- a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -77,27 +77,21 @@ export function useAutoBalanceUpdateBanner( }); if (machines.length === 0) return null; - const running = machines.filter((machine) => machine.state.status === "running"); - const failed = machines.filter((machine) => machine.state.status === "failed"); - const manual = machines.filter((machine) => !machine.remoteUpdate); + const running = machines.filter((machine) => machine.state.status === "running").length; + const failed = machines.filter((machine) => machine.state.status === "failed").length; + const manual = machines.filter((machine) => !machine.remoteUpdate).length; const targets = machines.filter( (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running", ); - const title = - running.length > 0 - ? `Updating ${running.length} ${running.length === 1 ? "machine" : "machines"}` - : failed.length > 0 - ? `Could not update ${failed.length} ${failed.length === 1 ? "machine" : "machines"}` - : `Update available for ${machines.length} ${machines.length === 1 ? "machine" : "machines"}`; + const count = running || failed || machines.length; + const status = running ? "running" : failed ? "failed" : "idle"; + const prefix = running ? "Updating" : failed ? "Could not update" : "Update available for"; + const title = `${prefix} ${count} ${count === 1 ? "machine" : "machines"}`; return { id: `auto-balance-server-updates-${dismissedNotices.size}`, - variant: failed.length > 0 ? "error" : "default", - priority: running.length > 0 ? "urgent" : "notice", - icon: ( - 0 ? "running" : failed.length > 0 ? "failed" : "idle"} - /> - ), + variant: failed ? "error" : "default", + priority: running ? "urgent" : "notice", + icon: , title: ( ), description: - manual.length > 0 - ? `${manual.length} ${manual.length === 1 ? "needs" : "need"} a manual update` - : undefined, + manual > 0 ? `${manual} ${manual === 1 ? "needs" : "need"} a manual update` : undefined, actions: - running.length === 0 && targets.length > 0 ? ( + running === 0 && targets.length > 0 ? ( 0 + failed > 0 ? "Retry" : targets.length === machines.length ? "Update all" @@ -149,23 +141,19 @@ export function useAutoBalanceUpdateBanner( } /> ) : undefined, - ...(running.length > 0 + dismissLabel: "Dismiss update notice", + ...(running ? {} : { - dismissLabel: "Dismiss update notice", onDismiss: () => { - for (const machine of machines) { - dismissServerUpdateFailure(machine.state); - dismissVersionMismatch(machine.dismissKey); + const next = new Set(dismissedNotices); + for (const { state, dismissKey } of machines) { + dismissServerUpdateFailure(state); + dismissVersionMismatch(dismissKey); + if (dismissKey) next.add(dismissKey); + if (state.status === "failed") next.add(state); } - setDismissedNotices((current) => { - const next = new Set(current); - for (const machine of machines) { - if (machine.dismissKey) next.add(machine.dismissKey); - if (machine.state.status === "failed") next.add(machine.state); - } - return next; - }); + setDismissedNotices(next); }, }), };