| 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 | 440 | } from "./ui/alert-dialog"; |
| 441 | 441 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 442 | 442 | import { ServerUpdateAction } from "./ServerUpdateAction"; |
| 443 | import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; | |
| 443 | 444 | import { |
| 444 | 445 | ComposerServerUpdateIcon, |
| 445 | 446 | ComposerServerUpdateStatus, |
| ... | ... | @@ -2317,6 +2318,35 @@ export default function ChatView(props: ChatViewProps) { |
| 2317 | 2318 | advertisedFileAttachmentBytes === null |
| 2318 | 2319 | ? null |
| 2319 | 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 | 2350 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 2321 | 2351 | const versionMismatchDismissKey = |
| 2322 | 2352 | versionMismatch && activeThread |
| ... | ... | @@ -2420,6 +2450,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2420 | 2450 | } |
| 2421 | 2451 | } |
| 2422 | 2452 | if ( |
| 2453 | !automaticEnvironment && | |
| 2423 | 2454 | serverUpdateEnvironmentId && |
| 2424 | 2455 | !reconnectingThroughVersionSkew && |
| 2425 | 2456 | (serverUpdateState.status === "idle" |
| ... | ... | @@ -2498,8 +2529,11 @@ export default function ChatView(props: ChatViewProps) { |
| 2498 | 2529 | }), |
| 2499 | 2530 | }); |
| 2500 | 2531 | } |
| 2532 | if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); | |
| 2501 | 2533 | return items; |
| 2502 | 2534 | }, [ |
| 2535 | automaticEnvironment, | |
| 2536 | autoBalanceUpdateBanner, | |
| 2503 | 2537 | activeEnvironmentUnavailableState, |
| 2504 | 2538 | reconnectWarningGraceElapsed, |
| 2505 | 2539 | handleReconnectActiveEnvironment, |
| ... | ... | @@ -3233,24 +3267,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3233 | 3267 | } |
| 3234 | 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 | 3270 | const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; |
| 3255 | 3271 | const loadBalancingCandidates = useMemo( |
| 3256 | 3272 | () => |
apps/web/src/components/ServerUpdateAction.test.tsx+158-3| ... | ... | @@ -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 | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | 5 | import * as Cause from "effect/Cause"; |
| 5 | 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 | 9 | const testState = vi.hoisted(() => ({ |
| 9 | 10 | updateServer: vi.fn(), |
| ... | ... | @@ -30,7 +31,18 @@ vi.mock("./ui/toast", () => ({ |
| 30 | 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 | 47 | type ActionElement = ReactElement<{ |
| 36 | 48 | readonly onClick?: () => void; |
| ... | ... | @@ -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 | 352 | describe("ServerUpdateProgress", () => { |
| 198 | 353 | it("shows one calm status row for the restart wait", () => { |
| 199 | 354 | const markup = renderToStaticMarkup( |
apps/web/src/components/ServerUpdateAction.tsx+119-53| ... | ... | @@ -4,7 +4,7 @@ import { |
| 4 | 4 | isAtomCommandInterrupted, |
| 5 | 5 | squashAtomCommandFailure, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import type { ComponentProps } from "react"; | |
| 7 | import { type ComponentProps, useRef, useState } from "react"; | |
| 8 | 8 | |
| 9 | 9 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 10 | 10 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| ... | ... | @@ -34,6 +34,113 @@ function updateFailureMessage(error: unknown): string { |
| 34 | 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 | 145 | * One-row status for an in-flight server update: "Downloading…" then |
| 39 | 146 | * "Restarting…". The update is a wait, not a warning: a single pulsing dot |
| ... | ... | @@ -84,28 +191,13 @@ export function ServerUpdateAction({ |
| 84 | 191 | label = "Update", |
| 85 | 192 | variant = "outline", |
| 86 | 193 | size = "xs", |
| 87 | }: { | |
| 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 | }) { | |
| 194 | }: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) { | |
| 101 | 195 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; |
| 102 | 196 | const continueThreadsAfterServerUpdate = useEnvironmentSettings( |
| 103 | 197 | environmentId, |
| 104 | 198 | (settings) => settings.continueThreadsAfterServerUpdate, |
| 105 | 199 | ); |
| 106 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { | |
| 107 | reportFailure: false, | |
| 108 | }); | |
| 200 | const update = useServerUpdate(); | |
| 109 | 201 | const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ |
| 110 | 202 | target: "update command", |
| 111 | 203 | onCopy: ({ command }) => { |
| ... | ... | @@ -140,41 +232,15 @@ export function ServerUpdateAction({ |
| 140 | 232 | return; |
| 141 | 233 | } |
| 142 | 234 | } |
| 143 | if (pendingUpdateEnvironmentIds.has(environmentId)) { | |
| 144 | return; | |
| 145 | } | |
| 146 | pendingUpdateEnvironmentIds.add(environmentId); | |
| 147 | try { | |
| 148 | const result = await updateServer({ | |
| 149 | environmentId, | |
| 150 | input: { | |
| 151 | targetVersion, | |
| 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 | } | |
| 235 | await update({ | |
| 236 | environmentId, | |
| 237 | serverLabel, | |
| 238 | selfUpdate, | |
| 239 | desktopAppUpdate, | |
| 240 | threadContinuation, | |
| 241 | targetVersion, | |
| 242 | continueThreadsAfterServerUpdate, | |
| 243 | }); | |
| 178 | 244 | }; |
| 179 | 245 | |
| 180 | 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 | } |