| author | |
| committer | |
| log | 15193df9fb915837f7215909a7d741789db0a273 |
| tree | 9e7c3e1d5d0e8e1fe0a57cf2a8ae341578e927d0 |
| parent | b7c002f918b94b8ce13c44bddcd29b8443e4af41 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 471 insertions(+), 74 deletions(-)
apps/web/src/components/ChatView.tsx+34-18| ... | @@ -440,6 +440,7 @@ import { | ... | @@ -440,6 +440,7 @@ import { |
| 440 | } from "./ui/alert-dialog"; | 440 | } from "./ui/alert-dialog"; |
| 441 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 441 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 442 | import { ServerUpdateAction } from "./ServerUpdateAction"; | 442 | import { ServerUpdateAction } from "./ServerUpdateAction"; |
| 443 | import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; | ||
| 443 | import { | 444 | import { |
| 444 | ComposerServerUpdateIcon, | 445 | ComposerServerUpdateIcon, |
| 445 | ComposerServerUpdateStatus, | 446 | ComposerServerUpdateStatus, |
| ... | @@ -2317,6 +2318,35 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2317,6 +2318,35 @@ export default function ChatView(props: ChatViewProps) { |
| 2317 | advertisedFileAttachmentBytes === null | 2318 | advertisedFileAttachmentBytes === null |
| 2318 | ? null | 2319 | ? null |
| 2319 | : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); | 2320 | : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); |
| 2321 | const envLocked = Boolean( | ||
| 2322 | activeThread && | ||
| 2323 | (activeThread.messages.length > 0 || | ||
| 2324 | (activeThread.session !== null && activeThread.session.status !== "stopped")), | ||
| 2325 | ); | ||
| 2326 | |||
| 2327 | const loadBalancingSettings = useClientSettings(); | ||
| 2328 | const automaticEnvironment = Boolean( | ||
| 2329 | clientSettingsHydrated && | ||
| 2330 | draftId && | ||
| 2331 | !envLocked && | ||
| 2332 | hasMultipleEnvironments && | ||
| 2333 | loadBalancingSettings.loadBalancingEnabled && | ||
| 2334 | draftThread?.environmentSelection !== "manual" && | ||
| 2335 | (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && | ||
| 2336 | (!draftThread?.branch || draftThread.environmentSelection === "auto") && | ||
| 2337 | !draftThread?.worktreePath, | ||
| 2338 | ); | ||
| 2339 | const autoUpdateEnvironments = useMemo( | ||
| 2340 | () => | ||
| 2341 | automaticEnvironment | ||
| 2342 | ? logicalProjectEnvironments.flatMap(({ environmentId }) => { | ||
| 2343 | const environment = environmentById.get(environmentId); | ||
| 2344 | return environment ? [environment] : []; | ||
| 2345 | }) | ||
| 2346 | : [], | ||
| 2347 | [automaticEnvironment, logicalProjectEnvironments, environmentById], | ||
| 2348 | ); | ||
| 2349 | const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); | ||
| 2320 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); | 2350 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 2321 | const versionMismatchDismissKey = | 2351 | const versionMismatchDismissKey = |
| 2322 | versionMismatch && activeThread | 2352 | versionMismatch && activeThread |
| ... | @@ -2420,6 +2450,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2420,6 +2450,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2420 | } | 2450 | } |
| 2421 | } | 2451 | } |
| 2422 | if ( | 2452 | if ( |
| 2453 | !automaticEnvironment && | ||
| 2423 | serverUpdateEnvironmentId && | 2454 | serverUpdateEnvironmentId && |
| 2424 | !reconnectingThroughVersionSkew && | 2455 | !reconnectingThroughVersionSkew && |
| 2425 | (serverUpdateState.status === "idle" | 2456 | (serverUpdateState.status === "idle" |
| ... | @@ -2498,8 +2529,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2498,8 +2529,11 @@ export default function ChatView(props: ChatViewProps) { |
| 2498 | }), | 2529 | }), |
| 2499 | }); | 2530 | }); |
| 2500 | } | 2531 | } |
| 2532 | if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); | ||
| 2501 | return items; | 2533 | return items; |
| 2502 | }, [ | 2534 | }, [ |
| 2535 | automaticEnvironment, | ||
| 2536 | autoBalanceUpdateBanner, | ||
| 2503 | activeEnvironmentUnavailableState, | 2537 | activeEnvironmentUnavailableState, |
| 2504 | reconnectWarningGraceElapsed, | 2538 | reconnectWarningGraceElapsed, |
| 2505 | handleReconnectActiveEnvironment, | 2539 | handleReconnectActiveEnvironment, |
| ... | @@ -3233,24 +3267,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3233,24 +3267,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3233 | } | 3267 | } |
| 3234 | }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); | 3268 | }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); |
| 3235 | 3269 | ||
| 3236 | const envLocked = Boolean( | ||
| 3237 | activeThread && | ||
| 3238 | (activeThread.messages.length > 0 || | ||
| 3239 | (activeThread.session !== null && activeThread.session.status !== "stopped")), | ||
| 3240 | ); | ||
| 3241 | |||
| 3242 | const loadBalancingSettings = useClientSettings(); | ||
| 3243 | const automaticEnvironment = Boolean( | ||
| 3244 | clientSettingsHydrated && | ||
| 3245 | draftId && | ||
| 3246 | !envLocked && | ||
| 3247 | hasMultipleEnvironments && | ||
| 3248 | loadBalancingSettings.loadBalancingEnabled && | ||
| 3249 | draftThread?.environmentSelection !== "manual" && | ||
| 3250 | (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && | ||
| 3251 | (!draftThread?.branch || draftThread.environmentSelection === "auto") && | ||
| 3252 | !draftThread?.worktreePath, | ||
| 3253 | ); | ||
| 3254 | const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; | 3270 | const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; |
| 3255 | const loadBalancingCandidates = useMemo( | 3271 | const loadBalancingCandidates = useMemo( |
| 3256 | () => | 3272 | () => |
apps/web/src/components/ServerUpdateAction.test.tsx+158-3| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import type { ReactElement } from "react"; | 1 | import { act, type ReactElement } from "react"; |
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import * as Cause from "effect/Cause"; | 5 | import * as Cause from "effect/Cause"; |
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | 6 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 6 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 7 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | 8 | ||
| 8 | const testState = vi.hoisted(() => ({ | 9 | const testState = vi.hoisted(() => ({ |
| 9 | updateServer: vi.fn(), | 10 | updateServer: vi.fn(), |
| ... | @@ -30,7 +31,18 @@ vi.mock("./ui/toast", () => ({ | ... | @@ -30,7 +31,18 @@ vi.mock("./ui/toast", () => ({ |
| 30 | toastManager: { add: testState.toast }, | 31 | toastManager: { add: testState.toast }, |
| 31 | })); | 32 | })); |
| 32 | 33 | ||
| 33 | import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; | 34 | import { |
| 35 | readConfirmDialogState, | ||
| 36 | registerConfirmDialogHost, | ||
| 37 | resetConfirmDialogForTests, | ||
| 38 | respondToConfirmDialog, | ||
| 39 | } from "~/confirmDialog"; | ||
| 40 | import { | ||
| 41 | ServerUpdateAction, | ||
| 42 | ServerUpdateProgress, | ||
| 43 | ServerUpdatesAction, | ||
| 44 | type ServerUpdateTarget, | ||
| 45 | } from "./ServerUpdateAction"; | ||
| 34 | 46 | ||
| 35 | type ActionElement = ReactElement<{ | 47 | type ActionElement = ReactElement<{ |
| 36 | readonly onClick?: () => void; | 48 | readonly onClick?: () => void; |
| ... | @@ -194,6 +206,149 @@ describe("ServerUpdateAction", () => { | ... | @@ -194,6 +206,149 @@ describe("ServerUpdateAction", () => { |
| 194 | }); | 206 | }); |
| 195 | }); | 207 | }); |
| 196 | 208 | ||
| 209 | describe("ServerUpdatesAction", () => { | ||
| 210 | let renderer: ReactTestRenderer | undefined; | ||
| 211 | const targets: ReadonlyArray<ServerUpdateTarget> = [ | ||
| 212 | { | ||
| 213 | environmentId: "batch-a" as EnvironmentId, | ||
| 214 | serverLabel: "Laptop", | ||
| 215 | selfUpdate: "boot-service", | ||
| 216 | targetVersion: "0.0.31", | ||
| 217 | threadContinuation: true, | ||
| 218 | continueThreadsAfterServerUpdate: true, | ||
| 219 | }, | ||
| 220 | { | ||
| 221 | environmentId: "batch-b" as EnvironmentId, | ||
| 222 | serverLabel: "Office", | ||
| 223 | selfUpdate: "respawn", | ||
| 224 | targetVersion: "0.0.31", | ||
| 225 | threadContinuation: true, | ||
| 226 | continueThreadsAfterServerUpdate: false, | ||
| 227 | }, | ||
| 228 | { | ||
| 229 | environmentId: "batch-c" as EnvironmentId, | ||
| 230 | serverLabel: "Manual", | ||
| 231 | selfUpdate: null, | ||
| 232 | targetVersion: "0.0.31", | ||
| 233 | }, | ||
| 234 | ]; | ||
| 235 | const success = AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }); | ||
| 236 | |||
| 237 | async function mount(batch = targets) { | ||
| 238 | await act(async () => { | ||
| 239 | renderer = create(<ServerUpdatesAction targets={batch} />); | ||
| 240 | }); | ||
| 241 | return renderer!.root.findByType("button"); | ||
| 242 | } | ||
| 243 | |||
| 244 | beforeEach(() => { | ||
| 245 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 246 | testState.updateServer.mockReset(); | ||
| 247 | testState.toast.mockReset(); | ||
| 248 | resetConfirmDialogForTests(); | ||
| 249 | }); | ||
| 250 | afterEach(async () => { | ||
| 251 | await act(async () => { | ||
| 252 | renderer?.unmount(); | ||
| 253 | }); | ||
| 254 | renderer = undefined; | ||
| 255 | resetConfirmDialogForTests(); | ||
| 256 | vi.unstubAllGlobals(); | ||
| 257 | }); | ||
| 258 | |||
| 259 | it("updates both supported machines with their own continuation preference and skips the manual machine", async () => { | ||
| 260 | testState.updateServer.mockResolvedValue(success); | ||
| 261 | const button = await mount(); | ||
| 262 | await act(async () => { | ||
| 263 | button.props.onClick(); | ||
| 264 | }); | ||
| 265 | |||
| 266 | expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([ | ||
| 267 | { | ||
| 268 | environmentId: "batch-a", | ||
| 269 | input: { targetVersion: "0.0.31", continueRunningThreads: true }, | ||
| 270 | }, | ||
| 271 | { environmentId: "batch-b", input: { targetVersion: "0.0.31" } }, | ||
| 272 | ]); | ||
| 273 | expect(testState.toast.mock.calls.map(([toast]) => toast.title)).toEqual([ | ||
| 274 | "Laptop updated", | ||
| 275 | "Office updated", | ||
| 276 | ]); | ||
| 277 | }); | ||
| 278 | |||
| 279 | it("names a failed machine while letting the other machine complete", async () => { | ||
| 280 | testState.updateServer | ||
| 281 | .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed")))) | ||
| 282 | .mockResolvedValueOnce(success); | ||
| 283 | const button = await mount(); | ||
| 284 | await act(async () => { | ||
| 285 | button.props.onClick(); | ||
| 286 | }); | ||
| 287 | |||
| 288 | expect(testState.updateServer).toHaveBeenCalledTimes(2); | ||
| 289 | expect(testState.toast).toHaveBeenCalledWith({ | ||
| 290 | type: "error", | ||
| 291 | title: "Laptop update failed", | ||
| 292 | description: "Download failed", | ||
| 293 | }); | ||
| 294 | expect(testState.toast).toHaveBeenCalledWith( | ||
| 295 | expect.objectContaining({ type: "success", title: "Office updated" }), | ||
| 296 | ); | ||
| 297 | expect(button.props.disabled).toBe(false); | ||
| 298 | }); | ||
| 299 | |||
| 300 | it("starts each machine once when double-clicked and disables the action until both finish", async () => { | ||
| 301 | const completions: Array<() => void> = []; | ||
| 302 | testState.updateServer.mockImplementation( | ||
| 303 | () => | ||
| 304 | new Promise((resolve) => { | ||
| 305 | completions.push(() => resolve(success)); | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | const button = await mount(); | ||
| 309 | await act(async () => { | ||
| 310 | button.props.onClick(); | ||
| 311 | button.props.onClick(); | ||
| 312 | }); | ||
| 313 | expect(testState.updateServer).toHaveBeenCalledTimes(2); | ||
| 314 | expect(button.props.disabled).toBe(true); | ||
| 315 | await act(async () => { | ||
| 316 | completions[0]!(); | ||
| 317 | }); | ||
| 318 | expect(button.props.disabled).toBe(true); | ||
| 319 | await act(async () => { | ||
| 320 | completions[1]!(); | ||
| 321 | }); | ||
| 322 | expect(button.props.disabled).toBe(false); | ||
| 323 | expect(testState.toast).toHaveBeenCalledTimes(2); | ||
| 324 | }); | ||
| 325 | |||
| 326 | it("asks once for desktop machines and cancels the entire batch", async () => { | ||
| 327 | registerConfirmDialogHost(); | ||
| 328 | const button = await mount( | ||
| 329 | targets.map((target, index) => | ||
| 330 | index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target, | ||
| 331 | ), | ||
| 332 | ); | ||
| 333 | await act(async () => { | ||
| 334 | button.props.onClick(); | ||
| 335 | }); | ||
| 336 | const confirmation = readConfirmDialogState(); | ||
| 337 | expect(confirmation).toEqual( | ||
| 338 | expect.objectContaining({ | ||
| 339 | status: "confirming", | ||
| 340 | message: expect.stringContaining("Laptop, Office"), | ||
| 341 | }), | ||
| 342 | ); | ||
| 343 | expect(testState.updateServer).not.toHaveBeenCalled(); | ||
| 344 | await act(async () => { | ||
| 345 | respondToConfirmDialog(false); | ||
| 346 | }); | ||
| 347 | expect(testState.updateServer).not.toHaveBeenCalled(); | ||
| 348 | expect(button.props.disabled).toBe(false); | ||
| 349 | }); | ||
| 350 | }); | ||
| 351 | |||
| 197 | describe("ServerUpdateProgress", () => { | 352 | describe("ServerUpdateProgress", () => { |
| 198 | it("shows one calm status row for the restart wait", () => { | 353 | it("shows one calm status row for the restart wait", () => { |
| 199 | const markup = renderToStaticMarkup( | 354 | const markup = renderToStaticMarkup( |
apps/web/src/components/ServerUpdateAction.tsx+119-53| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | isAtomCommandInterrupted, | 4 | isAtomCommandInterrupted, |
| 5 | squashAtomCommandFailure, | 5 | squashAtomCommandFailure, |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import type { ComponentProps } from "react"; | 7 | import { type ComponentProps, useRef, useState } from "react"; |
| 8 | 8 | ||
| 9 | import { requestConfirmDialog } from "~/confirmDialog"; | 9 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 10 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 10 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| ... | @@ -34,6 +34,113 @@ function updateFailureMessage(error: unknown): string { | ... | @@ -34,6 +34,113 @@ function updateFailureMessage(error: unknown): string { |
| 34 | return error instanceof Error ? error.message : "Server update failed."; | 34 | return error instanceof Error ? error.message : "Server update failed."; |
| 35 | } | 35 | } |
| 36 | 36 | ||
| 37 | export interface ServerUpdateTarget { | ||
| 38 | readonly environmentId: EnvironmentId; | ||
| 39 | readonly serverLabel: string; | ||
| 40 | readonly selfUpdate: ServerSelfUpdateCapability | null; | ||
| 41 | readonly desktopAppUpdate?: boolean; | ||
| 42 | readonly threadContinuation?: boolean; | ||
| 43 | readonly targetVersion: string; | ||
| 44 | readonly continueThreadsAfterServerUpdate?: boolean; | ||
| 45 | } | ||
| 46 | |||
| 47 | type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"> & { | ||
| 48 | readonly label?: string; | ||
| 49 | }; | ||
| 50 | |||
| 51 | function useServerUpdate() { | ||
| 52 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); | ||
| 53 | return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { | ||
| 54 | const { environmentId, serverLabel, selfUpdate, targetVersion } = target; | ||
| 55 | if (pendingUpdateEnvironmentIds.has(environmentId)) return; | ||
| 56 | pendingUpdateEnvironmentIds.add(environmentId); | ||
| 57 | try { | ||
| 58 | const result = await updateServer({ | ||
| 59 | environmentId, | ||
| 60 | input: { | ||
| 61 | targetVersion, | ||
| 62 | ...(target.threadContinuation && target.continueThreadsAfterServerUpdate | ||
| 63 | ? { continueRunningThreads: true } | ||
| 64 | : {}), | ||
| 65 | }, | ||
| 66 | }); | ||
| 67 | if (result._tag === "Failure") { | ||
| 68 | if (isAtomCommandInterrupted(result)) return; | ||
| 69 | throw squashAtomCommandFailure(result); | ||
| 70 | } | ||
| 71 | toastManager.add({ | ||
| 72 | type: "success", | ||
| 73 | title: `${serverLabel} updated`, | ||
| 74 | description: | ||
| 75 | selfUpdate === "desktop-managed" | ||
| 76 | ? `Desktop app relaunched on ${result.value.targetVersion}.` | ||
| 77 | : `Reconnected on t3@${result.value.targetVersion}.`, | ||
| 78 | }); | ||
| 79 | } catch (error) { | ||
| 80 | toastManager.add({ | ||
| 81 | type: "error", | ||
| 82 | title: failureTitle, | ||
| 83 | description: updateFailureMessage(error), | ||
| 84 | }); | ||
| 85 | } finally { | ||
| 86 | pendingUpdateEnvironmentIds.delete(environmentId); | ||
| 87 | } | ||
| 88 | }; | ||
| 89 | } | ||
| 90 | |||
| 91 | /** Updates eligible machines independently; manual paths remain in the machine list. */ | ||
| 92 | export function ServerUpdatesAction({ | ||
| 93 | targets, | ||
| 94 | label = "Update all", | ||
| 95 | variant = "outline", | ||
| 96 | size = "xs", | ||
| 97 | }: UpdateButtonProps & { | ||
| 98 | readonly targets: ReadonlyArray<ServerUpdateTarget>; | ||
| 99 | }) { | ||
| 100 | const update = useServerUpdate(); | ||
| 101 | const pending = useRef(false); | ||
| 102 | const [isPending, setIsPending] = useState(false); | ||
| 103 | const eligible = targets.filter( | ||
| 104 | (target) => | ||
| 105 | target.selfUpdate !== null && | ||
| 106 | (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), | ||
| 107 | ); | ||
| 108 | const handleUpdate = async () => { | ||
| 109 | if (pending.current) return; | ||
| 110 | pending.current = true; | ||
| 111 | setIsPending(true); | ||
| 112 | try { | ||
| 113 | const available = eligible.filter( | ||
| 114 | (target) => !pendingUpdateEnvironmentIds.has(target.environmentId), | ||
| 115 | ); | ||
| 116 | const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed"); | ||
| 117 | if (desktopTargets.length > 0) { | ||
| 118 | const confirmed = | ||
| 119 | (await requestConfirmDialog( | ||
| 120 | `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, | ||
| 121 | )) ?? true; | ||
| 122 | if (!confirmed) return; | ||
| 123 | } | ||
| 124 | await Promise.all( | ||
| 125 | available.map((target) => update(target, `${target.serverLabel} update failed`)), | ||
| 126 | ); | ||
| 127 | } finally { | ||
| 128 | pending.current = false; | ||
| 129 | setIsPending(false); | ||
| 130 | } | ||
| 131 | }; | ||
| 132 | return ( | ||
| 133 | <Button | ||
| 134 | size={size} | ||
| 135 | variant={variant} | ||
| 136 | disabled={isPending || eligible.length === 0} | ||
| 137 | onClick={() => void handleUpdate()} | ||
| 138 | > | ||
| 139 | {label} | ||
| 140 | </Button> | ||
| 141 | ); | ||
| 142 | } | ||
| 143 | |||
| 37 | /** | 144 | /** |
| 38 | * One-row status for an in-flight server update: "Downloading…" then | 145 | * One-row status for an in-flight server update: "Downloading…" then |
| 39 | * "Restarting…". The update is a wait, not a warning: a single pulsing dot | 146 | * "Restarting…". The update is a wait, not a warning: a single pulsing dot |
| ... | @@ -84,28 +191,13 @@ export function ServerUpdateAction({ | ... | @@ -84,28 +191,13 @@ export function ServerUpdateAction({ |
| 84 | label = "Update", | 191 | label = "Update", |
| 85 | variant = "outline", | 192 | variant = "outline", |
| 86 | size = "xs", | 193 | size = "xs", |
| 87 | }: { | 194 | }: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) { |
| 88 | readonly environmentId: EnvironmentId; | ||
| 89 | readonly serverLabel: string; | ||
| 90 | readonly selfUpdate: ServerSelfUpdateCapability | null; | ||
| 91 | /** The desktop app supervising this server accepts remote update | ||
| 92 | requests (capabilities.desktopAppUpdate). */ | ||
| 93 | readonly desktopAppUpdate?: boolean; | ||
| 94 | /** The server can durably continue running provider turns after updating. */ | ||
| 95 | readonly threadContinuation?: boolean; | ||
| 96 | readonly targetVersion: string; | ||
| 97 | readonly label?: string; | ||
| 98 | readonly variant?: ComponentProps<typeof Button>["variant"]; | ||
| 99 | readonly size?: ComponentProps<typeof Button>["size"]; | ||
| 100 | }) { | ||
| 101 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; | 195 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; |
| 102 | const continueThreadsAfterServerUpdate = useEnvironmentSettings( | 196 | const continueThreadsAfterServerUpdate = useEnvironmentSettings( |
| 103 | environmentId, | 197 | environmentId, |
| 104 | (settings) => settings.continueThreadsAfterServerUpdate, | 198 | (settings) => settings.continueThreadsAfterServerUpdate, |
| 105 | ); | 199 | ); |
| 106 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { | 200 | const update = useServerUpdate(); |
| 107 | reportFailure: false, | ||
| 108 | }); | ||
| 109 | const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ | 201 | const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ |
| 110 | target: "update command", | 202 | target: "update command", |
| 111 | onCopy: ({ command }) => { | 203 | onCopy: ({ command }) => { |
| ... | @@ -140,41 +232,15 @@ export function ServerUpdateAction({ | ... | @@ -140,41 +232,15 @@ export function ServerUpdateAction({ |
| 140 | return; | 232 | return; |
| 141 | } | 233 | } |
| 142 | } | 234 | } |
| 143 | if (pendingUpdateEnvironmentIds.has(environmentId)) { | 235 | await update({ |
| 144 | return; | 236 | environmentId, |
| 145 | } | 237 | serverLabel, |
| 146 | pendingUpdateEnvironmentIds.add(environmentId); | 238 | selfUpdate, |
| 147 | try { | 239 | desktopAppUpdate, |
| 148 | const result = await updateServer({ | 240 | threadContinuation, |
| 149 | environmentId, | 241 | targetVersion, |
| 150 | input: { | 242 | continueThreadsAfterServerUpdate, |
| 151 | targetVersion, | 243 | }); |
| 152 | ...(threadContinuation && continueThreadsAfterServerUpdate | ||
| 153 | ? { continueRunningThreads: true } | ||
| 154 | : {}), | ||
| 155 | }, | ||
| 156 | }); | ||
| 157 | if (result._tag === "Failure") { | ||
| 158 | if (isAtomCommandInterrupted(result)) { | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | toastManager.add({ | ||
| 162 | type: "error", | ||
| 163 | title: "Server update failed", | ||
| 164 | description: updateFailureMessage(squashAtomCommandFailure(result)), | ||
| 165 | }); | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | toastManager.add({ | ||
| 169 | type: "success", | ||
| 170 | title: `${serverLabel} updated`, | ||
| 171 | description: isDesktopAppUpdate | ||
| 172 | ? `Desktop app relaunched on ${result.value.targetVersion}.` | ||
| 173 | : `Reconnected on t3@${result.value.targetVersion}.`, | ||
| 174 | }); | ||
| 175 | } finally { | ||
| 176 | pendingUpdateEnvironmentIds.delete(environmentId); | ||
| 177 | } | ||
| 178 | }; | 244 | }; |
| 179 | 245 | ||
| 180 | if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { | 246 | if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { |
apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx created+160| ... | @@ -0,0 +1,160 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | import { useMemo, useState } from "react"; | ||
| 5 | |||
| 6 | import type { EnvironmentPresentation } from "~/state/environments"; | ||
| 7 | import { serverEnvironment } from "~/state/server"; | ||
| 8 | import { | ||
| 9 | buildVersionMismatchDismissalKey, | ||
| 10 | dismissServerUpdateFailure, | ||
| 11 | dismissVersionMismatch, | ||
| 12 | isServerUpdateFailureDismissed, | ||
| 13 | isVersionMismatchDismissed, | ||
| 14 | resolveServerConfigVersionMismatch, | ||
| 15 | resolveServerSelfUpdateCapability, | ||
| 16 | supportsDesktopAppUpdate, | ||
| 17 | supportsServerUpdateThreadContinuation, | ||
| 18 | } from "~/versionSkew"; | ||
| 19 | import { | ||
| 20 | ServerUpdateAction, | ||
| 21 | ServerUpdateProgress, | ||
| 22 | ServerUpdatesAction, | ||
| 23 | } from "../ServerUpdateAction"; | ||
| 24 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 25 | import type { ComposerBannerStackItem } from "./ComposerBannerStack"; | ||
| 26 | import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus"; | ||
| 27 | |||
| 28 | /** Keep every machine's update visible while auto balance has no single update target. */ | ||
| 29 | export function useAutoBalanceUpdateBanner( | ||
| 30 | environments: readonly EnvironmentPresentation[], | ||
| 31 | ): ComposerBannerStackItem | null { | ||
| 32 | const statesAtom = useMemo( | ||
| 33 | () => | ||
| 34 | Atom.make((get) => | ||
| 35 | environments.map((environment) => ({ | ||
| 36 | environment, | ||
| 37 | state: get(serverEnvironment.updateStateAtom(environment.environmentId)), | ||
| 38 | })), | ||
| 39 | ), | ||
| 40 | [environments], | ||
| 41 | ); | ||
| 42 | const states = useAtomValue(statesAtom); | ||
| 43 | const [dismissedNotices, setDismissedNotices] = useState<ReadonlySet<string | ServerUpdateState>>( | ||
| 44 | () => new Set(), | ||
| 45 | ); | ||
| 46 | const machines = states.flatMap(({ environment, state }) => { | ||
| 47 | const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig); | ||
| 48 | const dismissKey = mismatch | ||
| 49 | ? buildVersionMismatchDismissalKey(environment.environmentId, mismatch) | ||
| 50 | : null; | ||
| 51 | if ( | ||
| 52 | state.status === "idle" | ||
| 53 | ? !mismatch || | ||
| 54 | (dismissKey !== null && dismissedNotices.has(dismissKey)) || | ||
| 55 | isVersionMismatchDismissed(dismissKey) | ||
| 56 | : dismissedNotices.has(state) || isServerUpdateFailureDismissed(state) | ||
| 57 | ) | ||
| 58 | return []; | ||
| 59 | const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig); | ||
| 60 | const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig); | ||
| 61 | return [ | ||
| 62 | { | ||
| 63 | environmentId: environment.environmentId, | ||
| 64 | serverLabel: environment.label, | ||
| 65 | selfUpdate, | ||
| 66 | desktopAppUpdate, | ||
| 67 | threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), | ||
| 68 | continueThreadsAfterServerUpdate: | ||
| 69 | environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false, | ||
| 70 | targetVersion: state.status === "idle" ? mismatch!.clientVersion : state.targetVersion, | ||
| 71 | connected: environment.connection.phase === "connected", | ||
| 72 | remoteUpdate: selfUpdate !== null && (selfUpdate !== "desktop-managed" || desktopAppUpdate), | ||
| 73 | state, | ||
| 74 | dismissKey, | ||
| 75 | }, | ||
| 76 | ]; | ||
| 77 | }); | ||
| 78 | if (machines.length === 0) return null; | ||
| 79 | |||
| 80 | const running = machines.filter((machine) => machine.state.status === "running").length; | ||
| 81 | const failed = machines.filter((machine) => machine.state.status === "failed").length; | ||
| 82 | const manual = machines.filter((machine) => !machine.remoteUpdate).length; | ||
| 83 | const targets = machines.filter( | ||
| 84 | (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running", | ||
| 85 | ); | ||
| 86 | const count = running || failed || machines.length; | ||
| 87 | const status = running ? "running" : failed ? "failed" : "idle"; | ||
| 88 | const prefix = running ? "Updating" : failed ? "Could not update" : "Update available for"; | ||
| 89 | const title = `${prefix} ${count} ${count === 1 ? "machine" : "machines"}`; | ||
| 90 | return { | ||
| 91 | id: `auto-balance-server-updates-${dismissedNotices.size}`, | ||
| 92 | variant: failed ? "error" : "default", | ||
| 93 | priority: running ? "urgent" : "notice", | ||
| 94 | icon: <ComposerServerUpdateIcon status={status} />, | ||
| 95 | title: ( | ||
| 96 | <Popover> | ||
| 97 | <PopoverTrigger | ||
| 98 | className="block max-w-full truncate rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 99 | aria-label={`${title}. View machines`} | ||
| 100 | > | ||
| 101 | {title} | ||
| 102 | </PopoverTrigger> | ||
| 103 | <PopoverPopup side="top" align="start" className="w-80 max-w-[calc(100vw-2rem)]"> | ||
| 104 | <div className="space-y-3 text-xs"> | ||
| 105 | {machines.map((machine) => ( | ||
| 106 | <div key={machine.environmentId} className="space-y-1"> | ||
| 107 | <div className="font-medium">{machine.serverLabel}</div> | ||
| 108 | {machine.state.status !== "idle" ? ( | ||
| 109 | <ServerUpdateProgress state={machine.state} /> | ||
| 110 | ) : !machine.remoteUpdate ? ( | ||
| 111 | <> | ||
| 112 | <div className="text-muted-foreground">Manual update required</div> | ||
| 113 | <ServerUpdateAction {...machine} /> | ||
| 114 | </> | ||
| 115 | ) : ( | ||
| 116 | <div className="text-muted-foreground"> | ||
| 117 | {machine.connected | ||
| 118 | ? `Ready to update to ${machine.targetVersion}` | ||
| 119 | : "Reconnect this machine to update"} | ||
| 120 | </div> | ||
| 121 | )} | ||
| 122 | </div> | ||
| 123 | ))} | ||
| 124 | </div> | ||
| 125 | </PopoverPopup> | ||
| 126 | </Popover> | ||
| 127 | ), | ||
| 128 | description: | ||
| 129 | manual > 0 ? `${manual} ${manual === 1 ? "needs" : "need"} a manual update` : undefined, | ||
| 130 | actions: | ||
| 131 | running === 0 && targets.length > 0 ? ( | ||
| 132 | <ServerUpdatesAction | ||
| 133 | targets={targets} | ||
| 134 | variant="ghost" | ||
| 135 | label={ | ||
| 136 | failed > 0 | ||
| 137 | ? "Retry" | ||
| 138 | : targets.length === machines.length | ||
| 139 | ? "Update all" | ||
| 140 | : `Update ${targets.length} ${targets.length === 1 ? "machine" : "machines"}` | ||
| 141 | } | ||
| 142 | /> | ||
| 143 | ) : undefined, | ||
| 144 | dismissLabel: "Dismiss update notice", | ||
| 145 | ...(running | ||
| 146 | ? {} | ||
| 147 | : { | ||
| 148 | onDismiss: () => { | ||
| 149 | const next = new Set(dismissedNotices); | ||
| 150 | for (const { state, dismissKey } of machines) { | ||
| 151 | dismissServerUpdateFailure(state); | ||
| 152 | dismissVersionMismatch(dismissKey); | ||
| 153 | if (dismissKey) next.add(dismissKey); | ||
| 154 | if (state.status === "failed") next.add(state); | ||
| 155 | } | ||
| 156 | setDismissedNotices(next); | ||
| 157 | }, | ||
| 158 | }), | ||
| 159 | }; | ||
| 160 | } | ||