| author | |
| committer | |
| log | d83929ad0bb0d4aec800f12c332de57e873cc7e5 |
| tree | b44bfd4db0d6b4a4826a012567806bce9ccdc691 |
| parent | f498b3514b0b82e39e66c549468b2b889d5e520d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>11 files changed, 917 insertions(+), 111 deletions(-)
apps/web/src/components/ProviderUpdatesAction.tsx+43-30| ... | @@ -15,19 +15,18 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; | ... | @@ -15,19 +15,18 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 15 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 15 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 16 | 16 | ||
| 17 | /** | 17 | /** |
| 18 | * Updates every outdated provider on every connected machine at once, then | 18 | * Every outdated provider with a one-click update on every connected machine, |
| 19 | * reports the results in one toast. Each server queues updates that share an | 19 | * plus a function that updates them all and reports the results in one toast. |
| 20 | * installer, so sending them all together is safe. The server still checks | 20 | * Each server queues updates that share an installer, so sending them all |
| 21 | * permissions: a session that cannot operate a machine gets a failure line for | 21 | * together is safe. The server still checks permissions: a session that |
| 22 | * it. Renders nothing when no machine has a one-click update. | 22 | * cannot operate a machine gets a failure line for it. The returned promise |
| 23 | * settles when every update has finished. | ||
| 23 | */ | 24 | */ |
| 24 | export function ProviderUpdatesAction() { | 25 | export function useProviderUpdates() { |
| 25 | const { environments } = useEnvironments(); | 26 | const { environments } = useEnvironments(); |
| 26 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | 27 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { |
| 27 | reportFailure: false, | 28 | reportFailure: false, |
| 28 | }); | 29 | }); |
| 29 | const pending = useRef(false); | ||
| 30 | const [isPending, setIsPending] = useState(false); | ||
| 31 | const machines = useMemo( | 30 | const machines = useMemo( |
| 32 | () => | 31 | () => |
| 33 | environments.flatMap((environment) => { | 32 | environments.flatMap((environment) => { |
| ... | @@ -44,6 +43,41 @@ export function ProviderUpdatesAction() { | ... | @@ -44,6 +43,41 @@ export function ProviderUpdatesAction() { |
| 44 | }), | 43 | }), |
| 45 | [environments], | 44 | [environments], |
| 46 | ); | 45 | ); |
| 46 | |||
| 47 | const updateAll = async () => { | ||
| 48 | const runs = await Promise.all( | ||
| 49 | machines.flatMap(({ environmentId, label, candidates }) => | ||
| 50 | candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({ | ||
| 51 | machineLabel: label, | ||
| 52 | driver: candidate.driver, | ||
| 53 | instanceId: candidate.instanceId, | ||
| 54 | result: await updateProvider({ | ||
| 55 | environmentId, | ||
| 56 | input: { provider: candidate.driver, instanceId: candidate.instanceId }, | ||
| 57 | }), | ||
| 58 | })), | ||
| 59 | ), | ||
| 60 | ); | ||
| 61 | const view = getProviderUpdateRunToastView(runs); | ||
| 62 | if (view) { | ||
| 63 | toastManager.add( | ||
| 64 | stackedThreadToast({ | ||
| 65 | ...view, | ||
| 66 | description: <span className="whitespace-pre-line">{view.description}</span>, | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | }; | ||
| 71 | |||
| 72 | return { machines, updateAll }; | ||
| 73 | } | ||
| 74 | |||
| 75 | /** Provider settings button for {@link useProviderUpdates}. Renders nothing | ||
| 76 | when no machine has a one-click update. */ | ||
| 77 | export function ProviderUpdatesAction() { | ||
| 78 | const { machines, updateAll } = useProviderUpdates(); | ||
| 79 | const pending = useRef(false); | ||
| 80 | const [isPending, setIsPending] = useState(false); | ||
| 47 | // Candidates leave the list as soon as their servers report them queued, so | 81 | // Candidates leave the list as soon as their servers report them queued, so |
| 48 | // keep the button while the run is in flight. | 82 | // keep the button while the run is in flight. |
| 49 | if (machines.length === 0 && !isPending) { | 83 | if (machines.length === 0 && !isPending) { |
| ... | @@ -55,28 +89,7 @@ export function ProviderUpdatesAction() { | ... | @@ -55,28 +89,7 @@ export function ProviderUpdatesAction() { |
| 55 | pending.current = true; | 89 | pending.current = true; |
| 56 | setIsPending(true); | 90 | setIsPending(true); |
| 57 | try { | 91 | try { |
| 58 | const runs = await Promise.all( | 92 | await updateAll(); |
| 59 | machines.flatMap(({ environmentId, label, candidates }) => | ||
| 60 | candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({ | ||
| 61 | machineLabel: label, | ||
| 62 | driver: candidate.driver, | ||
| 63 | instanceId: candidate.instanceId, | ||
| 64 | result: await updateProvider({ | ||
| 65 | environmentId, | ||
| 66 | input: { provider: candidate.driver, instanceId: candidate.instanceId }, | ||
| 67 | }), | ||
| 68 | })), | ||
| 69 | ), | ||
| 70 | ); | ||
| 71 | const view = getProviderUpdateRunToastView(runs); | ||
| 72 | if (view) { | ||
| 73 | toastManager.add( | ||
| 74 | stackedThreadToast({ | ||
| 75 | ...view, | ||
| 76 | description: <span className="whitespace-pre-line">{view.description}</span>, | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | } finally { | 93 | } finally { |
| 81 | pending.current = false; | 94 | pending.current = false; |
| 82 | setIsPending(false); | 95 | setIsPending(false); |
apps/web/src/components/ServerUpdateAction.tsx+6-2| ... | @@ -65,7 +65,9 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" | ... | @@ -65,7 +65,9 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" |
| 65 | readonly appearance?: "button" | "icon"; | 65 | readonly appearance?: "button" | "icon"; |
| 66 | }; | 66 | }; |
| 67 | 67 | ||
| 68 | function useServerUpdate() { | 68 | /** Updates one server and waits until it is back on the target version. |
| 69 | Reports its own success or failure toast; never rejects. */ | ||
| 70 | export function useServerUpdate() { | ||
| 69 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); | 71 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); |
| 70 | return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { | 72 | return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { |
| 71 | const { environmentId, serverLabel, selfUpdate, targetVersion } = target; | 73 | const { environmentId, serverLabel, selfUpdate, targetVersion } = target; |
| ... | @@ -164,7 +166,9 @@ export function ServerUpdatesAction({ | ... | @@ -164,7 +166,9 @@ export function ServerUpdatesAction({ |
| 164 | ); | 166 | ); |
| 165 | } | 167 | } |
| 166 | 168 | ||
| 167 | function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean { | 169 | export function canUpdateServer( |
| 170 | result: AsyncResult.AsyncResult<AuthSessionState, unknown>, | ||
| 171 | ): boolean { | ||
| 168 | if (result._tag !== "Success" || !result.value.authenticated) return false; | 172 | if (result._tag !== "Success" || !result.value.authenticated) return false; |
| 169 | return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope); | 173 | return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope); |
| 170 | } | 174 | } |
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+6-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; | 2 | import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; |
| 3 | import type { AnimationEventHandler } from "react"; | 3 | import type { AnimationEventHandler } from "react"; |
| 4 | 4 | ||
| 5 | const DOWNLOAD_PROGRESS_RADIUS = 14; | 5 | const DOWNLOAD_PROGRESS_RADIUS = 14; |
| ... | @@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState = | ... | @@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState = |
| 10 | | "checking" | 10 | | "checking" |
| 11 | | "available" | 11 | | "available" |
| 12 | | "downloading" | 12 | | "downloading" |
| 13 | | "downloaded"; | 13 | | "downloaded" |
| 14 | // Other machines or providers have updates. Matches the server update icon | ||
| 15 | // in Connections settings; the download arrow stays for this app's own file. | ||
| 16 | | "everything"; | ||
| 14 | 17 | ||
| 15 | function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { | 18 | function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { |
| 16 | if (percent === null || !Number.isFinite(percent)) return 0; | 19 | if (percent === null || !Number.isFinite(percent)) return 0; |
| ... | @@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({ | ... | @@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({ |
| 115 | return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />; | 118 | return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />; |
| 116 | } | 119 | } |
| 117 | if (status === "downloaded") return <DesktopUpdateDownloadedIcon />; | 120 | if (status === "downloaded") return <DesktopUpdateDownloadedIcon />; |
| 121 | if (status === "everything") return <CircleArrowUpIcon className="size-4" />; | ||
| 118 | 122 | ||
| 119 | return ( | 123 | return ( |
| 120 | <RefreshIcon | 124 | <RefreshIcon |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+169-75| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | 1 | import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; |
| 2 | import { TriangleAlertIcon } from "lucide-react"; | 2 | import { TriangleAlertIcon } from "lucide-react"; |
| 3 | import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react"; | 3 | import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react"; |
| 4 | import { flushSync } from "react-dom"; | 4 | import { flushSync } from "react-dom"; |
| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | getArm64IntelBuildWarningDescription, | 13 | getArm64IntelBuildWarningDescription, |
| 14 | getDesktopUpdateActionError, | 14 | getDesktopUpdateActionError, |
| 15 | getDesktopUpdateButtonTooltip, | 15 | getDesktopUpdateButtonTooltip, |
| 16 | getDesktopUpdateDownloadedVersion, | ||
| 16 | getDesktopUpdateInstallConfirmationMessage, | 17 | getDesktopUpdateInstallConfirmationMessage, |
| 17 | isDesktopUpdateButtonDisabled, | 18 | isDesktopUpdateButtonDisabled, |
| 18 | resolveDesktopUpdateButtonAction, | 19 | resolveDesktopUpdateButtonAction, |
| ... | @@ -26,10 +27,13 @@ import { SidebarMenuItem } from "../ui/sidebar"; | ... | @@ -26,10 +27,13 @@ import { SidebarMenuItem } from "../ui/sidebar"; |
| 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 27 | import { | 28 | import { |
| 28 | DesktopUpdateStatusIcon, | 29 | DesktopUpdateStatusIcon, |
| 30 | type DesktopUpdateStatusIconState, | ||
| 29 | shouldContinueDesktopUpdateCheckAnimation, | 31 | shouldContinueDesktopUpdateCheckAnimation, |
| 30 | shouldShowDesktopUpdateCheckIcon, | 32 | shouldShowDesktopUpdateCheckIcon, |
| 31 | } from "./DesktopUpdateStatusIcon"; | 33 | } from "./DesktopUpdateStatusIcon"; |
| 32 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; | 34 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; |
| 35 | import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; | ||
| 36 | import { useUpdateEverything } from "./useUpdateEverything"; | ||
| 33 | 37 | ||
| 34 | type SidebarUpdatePopoverChangeDetails = Parameters< | 38 | type SidebarUpdatePopoverChangeDetails = Parameters< |
| 35 | NonNullable<ComponentProps<typeof Popover>["onOpenChange"]> | 39 | NonNullable<ComponentProps<typeof Popover>["onOpenChange"]> |
| ... | @@ -63,23 +67,28 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab( | ... | @@ -63,23 +67,28 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab( |
| 63 | 67 | ||
| 64 | function resolveSidebarUpdatePresentation({ | 68 | function resolveSidebarUpdatePresentation({ |
| 65 | action, | 69 | action, |
| 70 | showsEverything, | ||
| 66 | isDownloading, | 71 | isDownloading, |
| 67 | showCheckIcon, | 72 | showCheckIcon, |
| 68 | }: { | 73 | }: { |
| 69 | readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>; | 74 | readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>; |
| 75 | /** Remote work is waiting or running, and this app is not downloading. */ | ||
| 76 | readonly showsEverything: boolean; | ||
| 70 | readonly isDownloading: boolean; | 77 | readonly isDownloading: boolean; |
| 71 | readonly showCheckIcon: boolean; | 78 | readonly showCheckIcon: boolean; |
| 72 | }) { | 79 | }) { |
| 73 | const showUpdateDetails = action !== "none" || isDownloading; | 80 | const showUpdateDetails = action !== "none" || showsEverything || isDownloading; |
| 74 | const iconStatus = showCheckIcon | 81 | const iconStatus: DesktopUpdateStatusIconState = showCheckIcon |
| 75 | ? "checking" | 82 | ? "checking" |
| 76 | : action === "install" | 83 | : isDownloading |
| 77 | ? "downloaded" | 84 | ? "downloading" |
| 78 | : isDownloading | 85 | : showsEverything |
| 79 | ? "downloading" | 86 | ? "everything" |
| 80 | : action === "download" | 87 | : action === "install" |
| 81 | ? "available" | 88 | ? "downloaded" |
| 82 | : "idle"; | 89 | : action === "download" |
| 90 | ? "available" | ||
| 91 | : "idle"; | ||
| 83 | 92 | ||
| 84 | return { | 93 | return { |
| 85 | iconStatus, | 94 | iconStatus, |
| ... | @@ -108,13 +117,89 @@ function SidebarUpdateArchitectureWarningContent() { | ... | @@ -108,13 +117,89 @@ function SidebarUpdateArchitectureWarningContent() { |
| 108 | ); | 117 | ); |
| 109 | } | 118 | } |
| 110 | 119 | ||
| 120 | /** Downloads this app's update. Resolves true when it is ready to install; | ||
| 121 | failures toast and resolve false. */ | ||
| 122 | async function downloadLocalUpdate( | ||
| 123 | bridge: DesktopBridge, | ||
| 124 | { notifyDownloaded }: { readonly notifyDownloaded: boolean }, | ||
| 125 | ): Promise<boolean> { | ||
| 126 | try { | ||
| 127 | const result = await bridge.downloadUpdate(); | ||
| 128 | if (result.completed && notifyDownloaded) { | ||
| 129 | showDesktopUpdateDownloadedToast(bridge, result.state); | ||
| 130 | } | ||
| 131 | const actionError = shouldToastDesktopUpdateActionResult(result) | ||
| 132 | ? getDesktopUpdateActionError(result) | ||
| 133 | : null; | ||
| 134 | if (actionError) { | ||
| 135 | toastManager.add( | ||
| 136 | stackedThreadToast({ | ||
| 137 | type: "error", | ||
| 138 | title: "Could not download update", | ||
| 139 | description: actionError, | ||
| 140 | }), | ||
| 141 | ); | ||
| 142 | } | ||
| 143 | return result.completed; | ||
| 144 | } catch (error) { | ||
| 145 | toastManager.add( | ||
| 146 | stackedThreadToast({ | ||
| 147 | type: "error", | ||
| 148 | title: "Could not start update download", | ||
| 149 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 150 | }), | ||
| 151 | ); | ||
| 152 | return false; | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | /** Installs this app's downloaded update, which relaunches the app. | ||
| 157 | Failures toast. A refused install (the download is not marked ready yet) | ||
| 158 | leaves the restart to the user. */ | ||
| 159 | async function installLocalUpdate(bridge: DesktopBridge): Promise<void> { | ||
| 160 | try { | ||
| 161 | const result = await bridge.installUpdate(); | ||
| 162 | if (!result.accepted) { | ||
| 163 | showDesktopUpdateDownloadedToast(bridge, result.state); | ||
| 164 | return; | ||
| 165 | } | ||
| 166 | const actionError = shouldToastDesktopUpdateActionResult(result) | ||
| 167 | ? getDesktopUpdateActionError(result) | ||
| 168 | : null; | ||
| 169 | if (!actionError) return; | ||
| 170 | toastManager.add( | ||
| 171 | stackedThreadToast({ | ||
| 172 | type: "error", | ||
| 173 | title: "Could not install update", | ||
| 174 | description: actionError, | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | } catch (error) { | ||
| 178 | toastManager.add( | ||
| 179 | stackedThreadToast({ | ||
| 180 | type: "error", | ||
| 181 | title: "Could not install update", | ||
| 182 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 183 | }), | ||
| 184 | ); | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | /** | ||
| 189 | * The sidebar update button. With only a local app update it downloads, then | ||
| 190 | * installs. When other machines' servers or any providers are behind, one | ||
| 191 | * click updates everything: providers, then servers, then this app last. On | ||
| 192 | * web there is no local app, so the button shows only for remote updates. | ||
| 193 | */ | ||
| 111 | export function SidebarUpdatePill() { | 194 | export function SidebarUpdatePill() { |
| 112 | return isElectron ? <SidebarUpdateControl /> : null; | 195 | return <SidebarUpdateControl />; |
| 113 | } | 196 | } |
| 114 | 197 | ||
| 115 | function SidebarUpdateControl() { | 198 | function SidebarUpdateControl() { |
| 116 | const state = useDesktopUpdateState(); | 199 | const state = useDesktopUpdateState(); |
| 117 | const [isActionPending, setIsActionPending] = useState(false); | 200 | const [isActionPending, setIsActionPending] = useState(false); |
| 201 | const [isUpdatingEverything, setIsUpdatingEverything] = useState(false); | ||
| 202 | const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); | ||
| 118 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); | 203 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); |
| 119 | const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); | 204 | const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); |
| 120 | const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); | 205 | const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); |
| ... | @@ -133,6 +218,13 @@ function SidebarUpdateControl() { | ... | @@ -133,6 +218,13 @@ function SidebarUpdateControl() { |
| 133 | 218 | ||
| 134 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; | 219 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; |
| 135 | const isDownloading = state?.status === "downloading"; | 220 | const isDownloading = state?.status === "downloading"; |
| 221 | // Remote servers follow this app to the version it is about to install. | ||
| 222 | const localUpdateVersion = | ||
| 223 | state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; | ||
| 224 | const everything = useUpdateEverything(localUpdateVersion); | ||
| 225 | const runsEverything = everything.hasUpdates && !isDownloading; | ||
| 226 | // Providers leave the list once queued, so the run keeps its own state. | ||
| 227 | const isRunningEverything = isUpdatingEverything && !isDownloading; | ||
| 136 | const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ | 228 | const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ |
| 137 | isAnimationLatched: isCheckAnimationLatched, | 229 | isAnimationLatched: isCheckAnimationLatched, |
| 138 | isChecking: state?.status === "checking", | 230 | isChecking: state?.status === "checking", |
| ... | @@ -140,21 +232,28 @@ function SidebarUpdateControl() { | ... | @@ -140,21 +232,28 @@ function SidebarUpdateControl() { |
| 140 | }); | 232 | }); |
| 141 | const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ | 233 | const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ |
| 142 | action, | 234 | action, |
| 235 | showsEverything: runsEverything || isRunningEverything, | ||
| 143 | isDownloading, | 236 | isDownloading, |
| 144 | showCheckIcon, | 237 | showCheckIcon, |
| 145 | }); | 238 | }); |
| 146 | const tooltip = showUpdateDetails | 239 | const tooltip = isRunningEverything |
| 147 | ? state | 240 | ? "Updating…" |
| 148 | ? getDesktopUpdateButtonTooltip(state) | 241 | : runsEverything |
| 149 | : "Update available" | 242 | ? everything.summary |
| 150 | : showCheckIcon | 243 | : showUpdateDetails |
| 151 | ? "Checking for updates…" | 244 | ? state |
| 152 | : "Check for updates"; | 245 | ? getDesktopUpdateButtonTooltip(state) |
| 246 | : "Update available" | ||
| 247 | : showCheckIcon | ||
| 248 | ? "Checking for updates…" | ||
| 249 | : "Check for updates"; | ||
| 153 | const disabled = showCheckIcon | 250 | const disabled = showCheckIcon |
| 154 | ? true | 251 | ? true |
| 155 | : showUpdateDetails | 252 | : runsEverything |
| 156 | ? isDesktopUpdateButtonDisabled(state) | 253 | ? false |
| 157 | : !canCheckForUpdate(state); | 254 | : showUpdateDetails |
| 255 | ? isDesktopUpdateButtonDisabled(state) | ||
| 256 | : !canCheckForUpdate(state); | ||
| 158 | const isInteractionDisabled = disabled || isActionPending; | 257 | const isInteractionDisabled = disabled || isActionPending; |
| 159 | const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover( | 258 | const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover( |
| 160 | showUpdateDetails, | 259 | showUpdateDetails, |
| ... | @@ -175,39 +274,20 @@ function SidebarUpdateControl() { | ... | @@ -175,39 +274,20 @@ function SidebarUpdateControl() { |
| 175 | 274 | ||
| 176 | const handleAction = useCallback(async () => { | 275 | const handleAction = useCallback(async () => { |
| 177 | const bridge = window.desktopBridge; | 276 | const bridge = window.desktopBridge; |
| 178 | if (!bridge || !state) return; | ||
| 179 | if (isInteractionDisabled) return; | 277 | if (isInteractionDisabled) return; |
| 180 | 278 | ||
| 279 | if (runsEverything) { | ||
| 280 | setIsConfirmingEverything(true); | ||
| 281 | return; | ||
| 282 | } | ||
| 283 | |||
| 284 | if (!bridge || !state) return; | ||
| 181 | setIsActionPending(true); | 285 | setIsActionPending(true); |
| 182 | 286 | ||
| 183 | if (action === "download") { | 287 | if (action === "download") { |
| 184 | void bridge | 288 | void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() => |
| 185 | .downloadUpdate() | 289 | setIsActionPending(false), |
| 186 | .then((result) => { | 290 | ); |
| 187 | if (result.completed) { | ||
| 188 | showDesktopUpdateDownloadedToast(bridge, result.state); | ||
| 189 | } | ||
| 190 | if (!shouldToastDesktopUpdateActionResult(result)) return; | ||
| 191 | const actionError = getDesktopUpdateActionError(result); | ||
| 192 | if (!actionError) return; | ||
| 193 | toastManager.add( | ||
| 194 | stackedThreadToast({ | ||
| 195 | type: "error", | ||
| 196 | title: "Could not download update", | ||
| 197 | description: actionError, | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | }) | ||
| 201 | .catch((error) => { | ||
| 202 | toastManager.add( | ||
| 203 | stackedThreadToast({ | ||
| 204 | type: "error", | ||
| 205 | title: "Could not start update download", | ||
| 206 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 207 | }), | ||
| 208 | ); | ||
| 209 | }) | ||
| 210 | .finally(() => setIsActionPending(false)); | ||
| 211 | return; | 291 | return; |
| 212 | } | 292 | } |
| 213 | 293 | ||
| ... | @@ -232,30 +312,7 @@ function SidebarUpdateControl() { | ... | @@ -232,30 +312,7 @@ function SidebarUpdateControl() { |
| 232 | setIsActionPending(false); | 312 | setIsActionPending(false); |
| 233 | return; | 313 | return; |
| 234 | } | 314 | } |
| 235 | void bridge | 315 | void installLocalUpdate(bridge).finally(() => setIsActionPending(false)); |
| 236 | .installUpdate() | ||
| 237 | .then((result) => { | ||
| 238 | if (!shouldToastDesktopUpdateActionResult(result)) return; | ||
| 239 | const actionError = getDesktopUpdateActionError(result); | ||
| 240 | if (!actionError) return; | ||
| 241 | toastManager.add( | ||
| 242 | stackedThreadToast({ | ||
| 243 | type: "error", | ||
| 244 | title: "Could not install update", | ||
| 245 | description: actionError, | ||
| 246 | }), | ||
| 247 | ); | ||
| 248 | }) | ||
| 249 | .catch((error) => { | ||
| 250 | toastManager.add( | ||
| 251 | stackedThreadToast({ | ||
| 252 | type: "error", | ||
| 253 | title: "Could not install update", | ||
| 254 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | }) | ||
| 258 | .finally(() => setIsActionPending(false)); | ||
| 259 | return; | 316 | return; |
| 260 | } | 317 | } |
| 261 | 318 | ||
| ... | @@ -286,7 +343,33 @@ function SidebarUpdateControl() { | ... | @@ -286,7 +343,33 @@ function SidebarUpdateControl() { |
| 286 | ); | 343 | ); |
| 287 | }) | 344 | }) |
| 288 | .finally(() => setIsActionPending(false)); | 345 | .finally(() => setIsActionPending(false)); |
| 289 | }, [action, isInteractionDisabled, prefersReducedMotion, state]); | 346 | }, [action, isInteractionDisabled, prefersReducedMotion, runsEverything, state]); |
| 347 | |||
| 348 | const runEverything = useCallback(async () => { | ||
| 349 | const bridge = window.desktopBridge; | ||
| 350 | setIsConfirmingEverything(false); | ||
| 351 | setIsActionPending(true); | ||
| 352 | setIsUpdatingEverything(true); | ||
| 353 | try { | ||
| 354 | await everything.run({ | ||
| 355 | ...(bridge && action === "download" | ||
| 356 | ? { downloadLocal: () => downloadLocalUpdate(bridge, { notifyDownloaded: false }) } | ||
| 357 | : {}), | ||
| 358 | ...(bridge && action !== "none" ? { installLocal: () => installLocalUpdate(bridge) } : {}), | ||
| 359 | }); | ||
| 360 | } catch (error) { | ||
| 361 | toastManager.add( | ||
| 362 | stackedThreadToast({ | ||
| 363 | type: "error", | ||
| 364 | title: "Could not update everything", | ||
| 365 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 366 | }), | ||
| 367 | ); | ||
| 368 | } finally { | ||
| 369 | setIsUpdatingEverything(false); | ||
| 370 | setIsActionPending(false); | ||
| 371 | } | ||
| 372 | }, [action, everything]); | ||
| 290 | 373 | ||
| 291 | const handleCheckAnimationIteration = useCallback(() => { | 374 | const handleCheckAnimationIteration = useCallback(() => { |
| 292 | setIsCheckAnimationLatched( | 375 | setIsCheckAnimationLatched( |
| ... | @@ -297,6 +380,9 @@ function SidebarUpdateControl() { | ... | @@ -297,6 +380,9 @@ function SidebarUpdateControl() { |
| 297 | ); | 380 | ); |
| 298 | }, [prefersReducedMotion, state?.status]); | 381 | }, [prefersReducedMotion, state?.status]); |
| 299 | 382 | ||
| 383 | // Web has no local app to check, so it only shows the button for remote work. | ||
| 384 | if (!isElectron && !runsEverything && !isUpdatingEverything) return null; | ||
| 385 | |||
| 300 | const updateButton = ( | 386 | const updateButton = ( |
| 301 | <button | 387 | <button |
| 302 | type="button" | 388 | type="button" |
| ... | @@ -314,7 +400,7 @@ function SidebarUpdateControl() { | ... | @@ -314,7 +400,7 @@ function SidebarUpdateControl() { |
| 314 | "text-(--sidebar-icon-color)", | 400 | "text-(--sidebar-icon-color)", |
| 315 | !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", | 401 | !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", |
| 316 | ), | 402 | ), |
| 317 | disabled && !showUpdateIconState && "opacity-60", | 403 | ((disabled && !showUpdateIconState) || isRunningEverything) && "opacity-60", |
| 318 | )} | 404 | )} |
| 319 | onClick={handleAction} | 405 | onClick={handleAction} |
| 320 | onBlur={() => { | 406 | onBlur={() => { |
| ... | @@ -414,6 +500,14 @@ function SidebarUpdateControl() { | ... | @@ -414,6 +500,14 @@ function SidebarUpdateControl() { |
| 414 | </PopoverPopup> | 500 | </PopoverPopup> |
| 415 | ) : null} | 501 | ) : null} |
| 416 | </Popover> | 502 | </Popover> |
| 503 | <UpdateEverythingDialog | ||
| 504 | open={isConfirmingEverything} | ||
| 505 | onOpenChange={setIsConfirmingEverything} | ||
| 506 | summary={everything.summary} | ||
| 507 | lines={everything.lines} | ||
| 508 | restarts={everything.restarts} | ||
| 509 | onConfirm={() => void runEverything()} | ||
| 510 | /> | ||
| 417 | </SidebarMenuItem> | 511 | </SidebarMenuItem> |
| 418 | ); | 512 | ); |
| 419 | } | 513 | } |
apps/web/src/components/sidebar/UpdateEverythingDialog.tsx created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { Atom } from "effect/reactivity"; | ||
| 4 | |||
| 5 | import { environmentThreadShells } from "~/state/threads"; | ||
| 6 | import { | ||
| 7 | AlertDialog, | ||
| 8 | AlertDialogClose, | ||
| 9 | AlertDialogDescription, | ||
| 10 | AlertDialogFooter, | ||
| 11 | AlertDialogHeader, | ||
| 12 | AlertDialogPopup, | ||
| 13 | AlertDialogTitle, | ||
| 14 | } from "../ui/alert-dialog"; | ||
| 15 | import { Button } from "../ui/button"; | ||
| 16 | import type { RestartingMachine } from "./updateEverything.logic"; | ||
| 17 | |||
| 18 | /** A thread an agent is working on now. A restart stops these; queued threads wait. */ | ||
| 19 | const isRunning = (status: string) => | ||
| 20 | status === "preparing" || status === "starting" || status === "running"; | ||
| 21 | |||
| 22 | /** Running threads across the environments in a newline-joined key. */ | ||
| 23 | const runningThreadCountAtom = Atom.family((environmentIds: string) => | ||
| 24 | Atom.make((get) => | ||
| 25 | environmentIds | ||
| 26 | .split("\n") | ||
| 27 | .filter((id) => id !== "") | ||
| 28 | .reduce( | ||
| 29 | (count, id) => | ||
| 30 | count + | ||
| 31 | get(environmentThreadShells.environmentThreadsAtom(EnvironmentId.make(id))).filter( | ||
| 32 | (thread) => isRunning(thread.status), | ||
| 33 | ).length, | ||
| 34 | 0, | ||
| 35 | ), | ||
| 36 | ).pipe(Atom.withLabel(`update-everything-running-threads:${environmentIds}`)), | ||
| 37 | ); | ||
| 38 | |||
| 39 | interface UpdateEverythingDialogProps { | ||
| 40 | readonly open: boolean; | ||
| 41 | readonly onOpenChange: (open: boolean) => void; | ||
| 42 | readonly summary: string; | ||
| 43 | readonly lines: ReadonlyArray<string>; | ||
| 44 | readonly restarts: ReadonlyArray<RestartingMachine>; | ||
| 45 | readonly onConfirm: () => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | /** | ||
| 49 | * Confirms an "update everything" run. It counts the running threads that the | ||
| 50 | * restarts stop, and how many can resume afterwards because "Continue threads | ||
| 51 | * after restarts" is on for their server. Recovery still skips some, such as a | ||
| 52 | * thread waiting on the user, so the text says "can resume", not "will". | ||
| 53 | */ | ||
| 54 | export function UpdateEverythingDialog({ | ||
| 55 | open, | ||
| 56 | onOpenChange, | ||
| 57 | ...content | ||
| 58 | }: UpdateEverythingDialogProps) { | ||
| 59 | return ( | ||
| 60 | <AlertDialog open={open} onOpenChange={onOpenChange}> | ||
| 61 | <AlertDialogPopup> | ||
| 62 | {/* The popup unmounts when closed, so the thread count is read only while open. */} | ||
| 63 | <UpdateEverythingDialogContent {...content} /> | ||
| 64 | </AlertDialogPopup> | ||
| 65 | </AlertDialog> | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | function UpdateEverythingDialogContent({ | ||
| 70 | summary, | ||
| 71 | lines, | ||
| 72 | restarts, | ||
| 73 | onConfirm, | ||
| 74 | }: Omit<UpdateEverythingDialogProps, "open" | "onOpenChange">) { | ||
| 75 | const stopped = useAtomValue( | ||
| 76 | runningThreadCountAtom( | ||
| 77 | restarts | ||
| 78 | .filter((machine) => !machine.continuesThreads) | ||
| 79 | .map((machine) => machine.environmentId) | ||
| 80 | .join("\n"), | ||
| 81 | ), | ||
| 82 | ); | ||
| 83 | const continuing = useAtomValue( | ||
| 84 | runningThreadCountAtom( | ||
| 85 | restarts | ||
| 86 | .filter((machine) => machine.continuesThreads) | ||
| 87 | .map((machine) => machine.environmentId) | ||
| 88 | .join("\n"), | ||
| 89 | ), | ||
| 90 | ); | ||
| 91 | const running = stopped + continuing; | ||
| 92 | const threads = `${running} running ${running === 1 ? "thread" : "threads"}`; | ||
| 93 | |||
| 94 | return ( | ||
| 95 | <> | ||
| 96 | <AlertDialogHeader> | ||
| 97 | <AlertDialogTitle>{summary}?</AlertDialogTitle> | ||
| 98 | <AlertDialogDescription className="whitespace-pre-line"> | ||
| 99 | {lines.join("\n")} | ||
| 100 | </AlertDialogDescription> | ||
| 101 | {running > 0 ? ( | ||
| 102 | <p className="pt-2 text-sm"> | ||
| 103 | {stopped === 0 | ||
| 104 | ? `${threads} will be interrupted and can resume after the restart.` | ||
| 105 | : continuing === 0 | ||
| 106 | ? `${threads} will be interrupted.` | ||
| 107 | : `${threads} will be interrupted. ${continuing} can resume after the restart.`} | ||
| 108 | </p> | ||
| 109 | ) : null} | ||
| 110 | </AlertDialogHeader> | ||
| 111 | <AlertDialogFooter> | ||
| 112 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | ||
| 113 | <Button onClick={onConfirm}>Update</Button> | ||
| 114 | </AlertDialogFooter> | ||
| 115 | </> | ||
| 116 | ); | ||
| 117 | } | ||
apps/web/src/components/sidebar/updateEverything.logic.test.ts created+206| ... | @@ -0,0 +1,206 @@ | ||
| 1 | import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | collectRestartingMachines, | ||
| 6 | collectServerUpdateTargets, | ||
| 7 | describeUpdateEverything, | ||
| 8 | runUpdateEverything, | ||
| 9 | type UpdateEverythingMachine, | ||
| 10 | } from "./updateEverything.logic"; | ||
| 11 | |||
| 12 | function machine( | ||
| 13 | id: string, | ||
| 14 | serverVersion: string, | ||
| 15 | selfUpdate: ServerSelfUpdateCapability | undefined, | ||
| 16 | overrides: Partial<UpdateEverythingMachine> = {}, | ||
| 17 | ): UpdateEverythingMachine { | ||
| 18 | return { | ||
| 19 | environmentId: EnvironmentId.make(id), | ||
| 20 | label: id, | ||
| 21 | serverConfig: { | ||
| 22 | environment: { | ||
| 23 | environmentId: EnvironmentId.make(id), | ||
| 24 | label: id, | ||
| 25 | platform: { os: "linux", arch: "x64" }, | ||
| 26 | serverVersion, | ||
| 27 | capabilities: { | ||
| 28 | repositoryIdentity: true, | ||
| 29 | ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), | ||
| 30 | ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true } : {}), | ||
| 31 | }, | ||
| 32 | }, | ||
| 33 | settings: { continueThreadsAfterServerUpdate: false }, | ||
| 34 | }, | ||
| 35 | connected: true, | ||
| 36 | hostedByThisApp: false, | ||
| 37 | canMaintain: true, | ||
| 38 | updateState: { status: "idle" }, | ||
| 39 | ...overrides, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | describe("collectServerUpdateTargets", () => { | ||
| 44 | it("updates only remote servers that can move to the target on their channel", () => { | ||
| 45 | const targets = collectServerUpdateTargets( | ||
| 46 | [ | ||
| 47 | machine("this-app", "0.0.40", "desktop-managed", { hostedByThisApp: true }), | ||
| 48 | machine("service", "0.0.40", "boot-service"), | ||
| 49 | machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"), | ||
| 50 | machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"), | ||
| 51 | machine("offline", "0.0.40", "boot-service", { connected: false }), | ||
| 52 | machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }), | ||
| 53 | machine("current", "0.0.50", "boot-service"), | ||
| 54 | machine("manual", "0.0.40", undefined), | ||
| 55 | ], | ||
| 56 | "0.0.50", | ||
| 57 | ); | ||
| 58 | |||
| 59 | expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ | ||
| 60 | ["service", "0.0.50"], | ||
| 61 | ["other-desktop", "0.0.50"], | ||
| 62 | ]); | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("collectRestartingMachines", () => { | ||
| 67 | it("lists updated servers, and this app's servers only when it installs", () => { | ||
| 68 | const withContinuation = ( | ||
| 69 | id: string, | ||
| 70 | overrides: Partial<UpdateEverythingMachine> = {}, | ||
| 71 | enabled = false, | ||
| 72 | ) => { | ||
| 73 | const base = machine(id, "0.0.40", "boot-service", overrides); | ||
| 74 | return { | ||
| 75 | ...base, | ||
| 76 | serverConfig: { | ||
| 77 | environment: { | ||
| 78 | ...base.serverConfig!.environment, | ||
| 79 | capabilities: { | ||
| 80 | ...base.serverConfig!.environment.capabilities, | ||
| 81 | threadRestartContinuation: true, | ||
| 82 | }, | ||
| 83 | }, | ||
| 84 | settings: { continueThreadsAfterServerUpdate: enabled }, | ||
| 85 | }, | ||
| 86 | }; | ||
| 87 | }; | ||
| 88 | const machines = [ | ||
| 89 | withContinuation("this-app", { hostedByThisApp: true }, true), | ||
| 90 | withContinuation("alvin", {}, true), | ||
| 91 | withContinuation("opted-out", {}, false), | ||
| 92 | machine("old", "0.0.40", "boot-service"), | ||
| 93 | withContinuation("providers-only"), | ||
| 94 | ]; | ||
| 95 | const servers = ["alvin", "opted-out", "old"].map((id) => ({ | ||
| 96 | environmentId: EnvironmentId.make(id), | ||
| 97 | })); | ||
| 98 | |||
| 99 | expect(collectRestartingMachines(machines, servers, true)).toEqual([ | ||
| 100 | { environmentId: "this-app", continuesThreads: true }, | ||
| 101 | { environmentId: "alvin", continuesThreads: true }, | ||
| 102 | { environmentId: "opted-out", continuesThreads: false }, | ||
| 103 | { environmentId: "old", continuesThreads: false }, | ||
| 104 | ]); | ||
| 105 | expect( | ||
| 106 | collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId), | ||
| 107 | ).toEqual(["alvin", "opted-out", "old"]); | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 111 | describe("runUpdateEverything", () => { | ||
| 112 | it("restarts servers after providers finish and installs this app last", async () => { | ||
| 113 | const calls: string[] = []; | ||
| 114 | let finishProviders = () => {}; | ||
| 115 | const run = runUpdateEverything({ | ||
| 116 | downloadLocal: async () => { | ||
| 117 | calls.push("download"); | ||
| 118 | return true; | ||
| 119 | }, | ||
| 120 | updateProviders: () => | ||
| 121 | new Promise<void>((resolve) => { | ||
| 122 | calls.push("providers"); | ||
| 123 | finishProviders = resolve; | ||
| 124 | }), | ||
| 125 | updateServers: async () => { | ||
| 126 | calls.push("servers"); | ||
| 127 | }, | ||
| 128 | installLocal: async () => { | ||
| 129 | calls.push("install"); | ||
| 130 | }, | ||
| 131 | }); | ||
| 132 | |||
| 133 | await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 134 | expect(calls).toEqual(["download", "providers"]); | ||
| 135 | finishProviders(); | ||
| 136 | await run; | ||
| 137 | expect(calls).toEqual(["download", "providers", "servers", "install"]); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("still updates remote machines when the local download fails", async () => { | ||
| 141 | const calls: string[] = []; | ||
| 142 | await runUpdateEverything({ | ||
| 143 | downloadLocal: async () => false, | ||
| 144 | updateProviders: async () => { | ||
| 145 | calls.push("providers"); | ||
| 146 | }, | ||
| 147 | updateServers: async () => { | ||
| 148 | calls.push("servers"); | ||
| 149 | }, | ||
| 150 | installLocal: async () => { | ||
| 151 | calls.push("install"); | ||
| 152 | }, | ||
| 153 | }); | ||
| 154 | |||
| 155 | expect(calls).toEqual(["providers", "servers"]); | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | |||
| 159 | describe("describeUpdateEverything", () => { | ||
| 160 | it("summarizes the run and lists each machine once", () => { | ||
| 161 | const alvin = EnvironmentId.make("alvin"); | ||
| 162 | expect( | ||
| 163 | describeUpdateEverything({ | ||
| 164 | localVersion: "0.0.50", | ||
| 165 | servers: [ | ||
| 166 | { | ||
| 167 | environmentId: alvin, | ||
| 168 | serverLabel: "alvin", | ||
| 169 | selfUpdate: "boot-service", | ||
| 170 | targetVersion: "0.0.50", | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | environmentId: EnvironmentId.make("studio"), | ||
| 174 | serverLabel: "studio", | ||
| 175 | selfUpdate: "desktop-managed", | ||
| 176 | targetVersion: "0.0.50", | ||
| 177 | }, | ||
| 178 | ], | ||
| 179 | providerMachines: [ | ||
| 180 | { environmentId: alvin, label: "alvin", providers: ["Codex", "Claude"] }, | ||
| 181 | { environmentId: EnvironmentId.make("box"), label: "box", providers: ["Cursor"] }, | ||
| 182 | ], | ||
| 183 | }), | ||
| 184 | ).toEqual({ | ||
| 185 | summary: "Update this app, 2 servers, and 3 providers", | ||
| 186 | lines: [ | ||
| 187 | "This app: 0.0.50, restarts last", | ||
| 188 | "alvin: server to 0.0.50, Codex, Claude", | ||
| 189 | "studio: desktop app update", | ||
| 190 | "box: Cursor", | ||
| 191 | ], | ||
| 192 | }); | ||
| 193 | }); | ||
| 194 | |||
| 195 | it("reads naturally with one kind of update", () => { | ||
| 196 | expect( | ||
| 197 | describeUpdateEverything({ | ||
| 198 | localVersion: null, | ||
| 199 | servers: [], | ||
| 200 | providerMachines: [ | ||
| 201 | { environmentId: EnvironmentId.make("alvin"), label: "alvin", providers: ["Codex"] }, | ||
| 202 | ], | ||
| 203 | }).summary, | ||
| 204 | ).toBe("Update 1 provider"); | ||
| 205 | }); | ||
| 206 | }); | ||
apps/web/src/components/sidebar/updateEverything.logic.ts created+212| ... | @@ -0,0 +1,212 @@ | ||
| 1 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; | ||
| 2 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | ||
| 3 | import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | resolveServerSelfUpdateCapability, | ||
| 7 | resolveVersionMismatch, | ||
| 8 | supportsDesktopAppUpdate, | ||
| 9 | supportsServerUpdateThreadContinuation, | ||
| 10 | } from "~/versionSkew"; | ||
| 11 | import type { ServerUpdateTarget } from "../ServerUpdateAction"; | ||
| 12 | |||
| 13 | export interface UpdateEverythingMachine { | ||
| 14 | readonly environmentId: EnvironmentId; | ||
| 15 | readonly label: string; | ||
| 16 | readonly serverConfig: { | ||
| 17 | readonly environment: ServerConfig["environment"]; | ||
| 18 | readonly settings: Pick<ServerConfig["settings"], "continueThreadsAfterServerUpdate">; | ||
| 19 | } | null; | ||
| 20 | readonly connected: boolean; | ||
| 21 | /** Served by the desktop app this client runs in (the primary backend or a | ||
| 22 | local WSL backend). Its server updates when the app installs. */ | ||
| 23 | readonly hostedByThisApp: boolean; | ||
| 24 | /** The session holds the scope a server update needs. */ | ||
| 25 | readonly canMaintain: boolean; | ||
| 26 | readonly updateState: ServerUpdateState; | ||
| 27 | } | ||
| 28 | |||
| 29 | /** A server that restarts during a run. */ | ||
| 30 | export interface RestartingMachine { | ||
| 31 | readonly environmentId: EnvironmentId; | ||
| 32 | /** The server resumes interrupted threads after it restarts. */ | ||
| 33 | readonly continuesThreads: boolean; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** | ||
| 37 | * The servers a run restarts: each server it updates, and the servers this app | ||
| 38 | * hosts when this app installs. Provider updates restart nothing. | ||
| 39 | */ | ||
| 40 | export function collectRestartingMachines( | ||
| 41 | machines: ReadonlyArray<UpdateEverythingMachine>, | ||
| 42 | servers: ReadonlyArray<Pick<ServerUpdateTarget, "environmentId">>, | ||
| 43 | installsLocal: boolean, | ||
| 44 | ): RestartingMachine[] { | ||
| 45 | const updated = new Set(servers.map((server) => server.environmentId)); | ||
| 46 | return machines.flatMap((machine) => { | ||
| 47 | const config = machine.serverConfig; | ||
| 48 | if ( | ||
| 49 | config === null || | ||
| 50 | !machine.connected || | ||
| 51 | !(updated.has(machine.environmentId) || (installsLocal && machine.hostedByThisApp)) | ||
| 52 | ) { | ||
| 53 | return []; | ||
| 54 | } | ||
| 55 | return [ | ||
| 56 | { | ||
| 57 | environmentId: machine.environmentId, | ||
| 58 | continuesThreads: | ||
| 59 | config.environment.capabilities.threadRestartContinuation === true && | ||
| 60 | config.settings.continueThreadsAfterServerUpdate === true, | ||
| 61 | }, | ||
| 62 | ]; | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | /** | ||
| 67 | * The servers that "update everything" moves to `targetVersion`: connected, | ||
| 68 | * permitted, able to update over RPC, and behind the target. Two kinds stay | ||
| 69 | * out. Servers this app hosts would relaunch the app mid-run, and the app's | ||
| 70 | * own install updates them anyway. A service on another release channel | ||
| 71 | * stays there; a desktop-managed server follows its own app's update feed, so | ||
| 72 | * the channel rule does not apply to it. | ||
| 73 | */ | ||
| 74 | export function collectServerUpdateTargets( | ||
| 75 | machines: ReadonlyArray<UpdateEverythingMachine>, | ||
| 76 | targetVersion: string, | ||
| 77 | ): ServerUpdateTarget[] { | ||
| 78 | return machines.flatMap((machine) => { | ||
| 79 | const config = machine.serverConfig; | ||
| 80 | if ( | ||
| 81 | config === null || | ||
| 82 | !machine.connected || | ||
| 83 | machine.hostedByThisApp || | ||
| 84 | !machine.canMaintain || | ||
| 85 | machine.updateState.status === "running" | ||
| 86 | ) { | ||
| 87 | return []; | ||
| 88 | } | ||
| 89 | const selfUpdate = resolveServerSelfUpdateCapability(config); | ||
| 90 | const desktopAppUpdate = supportsDesktopAppUpdate(config); | ||
| 91 | if (selfUpdate === null || (selfUpdate === "desktop-managed" && !desktopAppUpdate)) { | ||
| 92 | return []; | ||
| 93 | } | ||
| 94 | const serverVersion = config.environment.serverVersion; | ||
| 95 | if (resolveVersionMismatch(serverVersion, targetVersion) === null) return []; | ||
| 96 | if ( | ||
| 97 | selfUpdate !== "desktop-managed" && | ||
| 98 | cliReleaseChannelOf(serverVersion) !== cliReleaseChannelOf(targetVersion) | ||
| 99 | ) { | ||
| 100 | return []; | ||
| 101 | } | ||
| 102 | return [ | ||
| 103 | { | ||
| 104 | environmentId: machine.environmentId, | ||
| 105 | serverLabel: machine.label, | ||
| 106 | selfUpdate, | ||
| 107 | installation: config.environment.capabilities.serverInstallation, | ||
| 108 | desktopAppUpdate, | ||
| 109 | threadContinuation: supportsServerUpdateThreadContinuation(config), | ||
| 110 | continueThreadsAfterServerUpdate: config.settings.continueThreadsAfterServerUpdate ?? false, | ||
| 111 | targetVersion, | ||
| 112 | }, | ||
| 113 | ]; | ||
| 114 | }); | ||
| 115 | } | ||
| 116 | |||
| 117 | export interface UpdateEverythingSteps { | ||
| 118 | /** Downloads this app's update. Resolves true when it is ready to install. */ | ||
| 119 | readonly downloadLocal?: () => Promise<boolean>; | ||
| 120 | readonly updateProviders: () => Promise<void>; | ||
| 121 | readonly updateServers: () => Promise<void>; | ||
| 122 | /** Installs this app's update. The relaunch ends the run. */ | ||
| 123 | readonly installLocal?: () => Promise<void>; | ||
| 124 | } | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Runs one "update everything" pass. Providers | ||
| 128 | * finish before any server restarts, so no install is cut off. The local download runs alongside the | ||
| 129 | * remote work. This app installs last because its relaunch ends the run; a | ||
| 130 | * failed download skips only that install. Steps report their own results. | ||
| 131 | */ | ||
| 132 | export async function runUpdateEverything(steps: UpdateEverythingSteps): Promise<void> { | ||
| 133 | const [localReady] = await Promise.all([ | ||
| 134 | steps.downloadLocal?.() ?? Promise.resolve(true), | ||
| 135 | steps.updateProviders().then(steps.updateServers), | ||
| 136 | ]); | ||
| 137 | if (localReady) await steps.installLocal?.(); | ||
| 138 | } | ||
| 139 | |||
| 140 | export interface UpdateEverythingPlan { | ||
| 141 | /** The version this desktop app installs, or null when it is not updating. */ | ||
| 142 | readonly localVersion: string | null; | ||
| 143 | readonly servers: ReadonlyArray< | ||
| 144 | Pick<ServerUpdateTarget, "environmentId" | "serverLabel" | "selfUpdate" | "targetVersion"> | ||
| 145 | >; | ||
| 146 | readonly providerMachines: ReadonlyArray<{ | ||
| 147 | readonly environmentId: EnvironmentId; | ||
| 148 | readonly label: string; | ||
| 149 | /** Display names of the outdated providers on that machine. */ | ||
| 150 | readonly providers: ReadonlyArray<string>; | ||
| 151 | }>; | ||
| 152 | } | ||
| 153 | |||
| 154 | function countLabel(count: number, noun: string): string | null { | ||
| 155 | return count === 0 ? null : `${count} ${noun}${count === 1 ? "" : "s"}`; | ||
| 156 | } | ||
| 157 | |||
| 158 | function joinList(parts: ReadonlyArray<string>): string { | ||
| 159 | if (parts.length <= 2) return parts.join(" and "); | ||
| 160 | return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`; | ||
| 161 | } | ||
| 162 | |||
| 163 | /** | ||
| 164 | * Text for the sidebar tooltip and the confirmation. `summary` reads like | ||
| 165 | * "Update this app, 2 servers, and 3 providers". `lines` has one entry per | ||
| 166 | * machine, this app first, such as "alvin: server to 0.0.50, Codex, Claude". | ||
| 167 | */ | ||
| 168 | export function describeUpdateEverything(plan: UpdateEverythingPlan): { | ||
| 169 | readonly summary: string; | ||
| 170 | readonly lines: ReadonlyArray<string>; | ||
| 171 | } { | ||
| 172 | const providerCount = plan.providerMachines.reduce( | ||
| 173 | (count, machine) => count + machine.providers.length, | ||
| 174 | 0, | ||
| 175 | ); | ||
| 176 | const summary = `Update ${joinList( | ||
| 177 | [ | ||
| 178 | plan.localVersion ? "this app" : null, | ||
| 179 | countLabel(plan.servers.length, "server"), | ||
| 180 | countLabel(providerCount, "provider"), | ||
| 181 | ].filter((part) => part !== null), | ||
| 182 | )}`; | ||
| 183 | |||
| 184 | const machines = new Map<EnvironmentId, { label: string; items: string[] }>(); | ||
| 185 | const itemsFor = (environmentId: EnvironmentId, label: string) => { | ||
| 186 | const existing = machines.get(environmentId); | ||
| 187 | if (existing) return existing.items; | ||
| 188 | const items: string[] = []; | ||
| 189 | machines.set(environmentId, { label, items }); | ||
| 190 | return items; | ||
| 191 | }; | ||
| 192 | for (const server of plan.servers) { | ||
| 193 | // A desktop-managed server installs whatever its own app's update feed | ||
| 194 | // offers, so it has no target version to show. | ||
| 195 | itemsFor(server.environmentId, server.serverLabel).push( | ||
| 196 | server.selfUpdate === "desktop-managed" | ||
| 197 | ? "desktop app update" | ||
| 198 | : `server to ${server.targetVersion}`, | ||
| 199 | ); | ||
| 200 | } | ||
| 201 | for (const machine of plan.providerMachines) { | ||
| 202 | itemsFor(machine.environmentId, machine.label).push(...machine.providers); | ||
| 203 | } | ||
| 204 | |||
| 205 | return { | ||
| 206 | summary, | ||
| 207 | lines: [ | ||
| 208 | ...(plan.localVersion ? [`This app: ${plan.localVersion}, restarts last`] : []), | ||
| 209 | ...Array.from(machines.values(), ({ label, items }) => `${label}: ${items.join(", ")}`), | ||
| 210 | ], | ||
| 211 | }; | ||
| 212 | } | ||
apps/web/src/components/sidebar/useUpdateEverything.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | ||
| 3 | import { Atom } from "effect/reactivity"; | ||
| 4 | import { useMemo } from "react"; | ||
| 5 | |||
| 6 | import { APP_VERSION } from "~/branding"; | ||
| 7 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | ||
| 8 | import { isElectron } from "~/env"; | ||
| 9 | import { useEnvironments } from "~/state/environments"; | ||
| 10 | import { useNpmReleasePublished } from "~/state/npmRelease"; | ||
| 11 | import { serverEnvironment } from "~/state/server"; | ||
| 12 | import { environmentSession } from "~/state/session"; | ||
| 13 | import { useProviderUpdates } from "../ProviderUpdatesAction"; | ||
| 14 | import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction"; | ||
| 15 | import { | ||
| 16 | collectRestartingMachines, | ||
| 17 | collectServerUpdateTargets, | ||
| 18 | describeUpdateEverything, | ||
| 19 | runUpdateEverything, | ||
| 20 | } from "./updateEverything.logic"; | ||
| 21 | |||
| 22 | /** | ||
| 23 | * The remote half of the sidebar update button: outdated providers on every | ||
| 24 | * connected machine, and servers behind the version this client runs or is | ||
| 25 | * about to install (`localUpdateVersion`). Servers that install from npm appear | ||
| 26 | * only once npm confirms that version. `run` performs the whole pass, including | ||
| 27 | * this app's own download and install when given. | ||
| 28 | */ | ||
| 29 | export function useUpdateEverything(localUpdateVersion: string | null) { | ||
| 30 | const targetVersion = localUpdateVersion ?? APP_VERSION; | ||
| 31 | const { environments } = useEnvironments(); | ||
| 32 | const machinesAtom = useMemo( | ||
| 33 | () => | ||
| 34 | Atom.make((get) => | ||
| 35 | environments.map((environment) => ({ | ||
| 36 | environmentId: environment.environmentId, | ||
| 37 | label: environment.label, | ||
| 38 | serverConfig: environment.serverConfig, | ||
| 39 | connected: environment.connection.phase === "connected", | ||
| 40 | hostedByThisApp: | ||
| 41 | isElectron && | ||
| 42 | (environment.entry.target._tag === "PrimaryConnectionTarget" || | ||
| 43 | isDesktopLocalConnectionTarget(environment.entry.target)), | ||
| 44 | canMaintain: canUpdateServer( | ||
| 45 | get(environmentSession.sessionStateAtom(environment.environmentId)), | ||
| 46 | ), | ||
| 47 | updateState: get(serverEnvironment.updateStateAtom(environment.environmentId)), | ||
| 48 | })), | ||
| 49 | ), | ||
| 50 | [environments], | ||
| 51 | ); | ||
| 52 | const machines = useAtomValue(machinesAtom); | ||
| 53 | const behindServers = collectServerUpdateTargets(machines, targetVersion); | ||
| 54 | // A desktop-managed server installs from its own app's update feed, so only | ||
| 55 | // servers that download t3 from npm wait for npm to publish the version. | ||
| 56 | const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed"); | ||
| 57 | const published = useNpmReleasePublished(needsNpm ? targetVersion : null); | ||
| 58 | const servers = published | ||
| 59 | ? behindServers | ||
| 60 | : behindServers.filter((server) => server.selfUpdate === "desktop-managed"); | ||
| 61 | const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null); | ||
| 62 | const providers = useProviderUpdates(); | ||
| 63 | const updateServer = useServerUpdate(); | ||
| 64 | |||
| 65 | const { summary, lines } = describeUpdateEverything({ | ||
| 66 | localVersion: localUpdateVersion, | ||
| 67 | servers, | ||
| 68 | providerMachines: providers.machines.map((machine) => ({ | ||
| 69 | environmentId: machine.environmentId, | ||
| 70 | label: machine.label, | ||
| 71 | providers: machine.candidates.map( | ||
| 72 | (candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver, | ||
| 73 | ), | ||
| 74 | })), | ||
| 75 | }); | ||
| 76 | |||
| 77 | return { | ||
| 78 | hasUpdates: servers.length > 0 || providers.machines.length > 0, | ||
| 79 | /** Short label for the tooltip and the confirmation title. */ | ||
| 80 | summary, | ||
| 81 | /** One line per machine, for the confirmation. */ | ||
| 82 | lines, | ||
| 83 | /** The servers this run restarts, which stops their running threads. */ | ||
| 84 | restarts, | ||
| 85 | run: (local: { downloadLocal?: () => Promise<boolean>; installLocal?: () => Promise<void> }) => | ||
| 86 | runUpdateEverything({ | ||
| 87 | ...local, | ||
| 88 | updateProviders: providers.updateAll, | ||
| 89 | updateServers: async () => { | ||
| 90 | await Promise.all(servers.map((server) => updateServer(server))); | ||
| 91 | }, | ||
| 92 | }), | ||
| 93 | }; | ||
| 94 | } | ||
apps/web/src/state/npmRelease.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; | ||
| 4 | import { AsyncResult, Atom } from "effect/reactivity"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Whether `t3@<version>` is published on npm. A 404 is false; network and | ||
| 8 | * registry errors stay unknown. Both re-check every few minutes, so a release | ||
| 9 | * that lands later shows up without a reload. | ||
| 10 | */ | ||
| 11 | const npmReleasePublishedAtom = Atom.family((version: string) => | ||
| 12 | Atom.make( | ||
| 13 | Effect.gen(function* () { | ||
| 14 | const response = yield* HttpClient.get( | ||
| 15 | `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`, | ||
| 16 | ); | ||
| 17 | if (response.status === 404) return false; | ||
| 18 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | ||
| 19 | Effect.flatMap((ok) => ok.json), | ||
| 20 | ); | ||
| 21 | return typeof body === "object" && body !== null && "version" in body | ||
| 22 | ? body.version === version | ||
| 23 | : false; | ||
| 24 | }).pipe(Effect.provide(FetchHttpClient.layer)), | ||
| 25 | ).pipe( | ||
| 26 | Atom.withRefresh("5 minutes"), | ||
| 27 | Atom.keepAlive, | ||
| 28 | Atom.withLabel(`npm-release-published:${version}`), | ||
| 29 | ), | ||
| 30 | ); | ||
| 31 | |||
| 32 | const NO_VERSION_ATOM = Atom.make(AsyncResult.success(false)).pipe( | ||
| 33 | Atom.withLabel("npm-release-published:none"), | ||
| 34 | ); | ||
| 35 | |||
| 36 | /** True only once npm confirms the version, so callers can hide work that | ||
| 37 | would download it until then. */ | ||
| 38 | export function useNpmReleasePublished(version: string | null): boolean { | ||
| 39 | const result = useAtomValue(version ? npmReleasePublishedAtom(version) : NO_VERSION_ATOM); | ||
| 40 | return AsyncResult.getOrElse(result, () => false); | ||
| 41 | } | ||
apps/web/src/versionSkew.ts+4-2| ... | @@ -49,7 +49,8 @@ function versionCore(version: string): string { | ... | @@ -49,7 +49,8 @@ function versionCore(version: string): string { |
| 49 | 49 | ||
| 50 | /** | 50 | /** |
| 51 | * The skew a user can act on: the connected server runs an older T3 Code than | 51 | * The skew a user can act on: the connected server runs an older T3 Code than |
| 52 | * this client, so the server is the side that needs updating. | 52 | * this client (or than `clientVersion`, the version this client is about to |
| 53 | * update to), so the server is the side that needs updating. | ||
| 53 | * | 54 | * |
| 54 | * Two nightly builds compare their full versions, including the date and run. | 55 | * Two nightly builds compare their full versions, including the date and run. |
| 55 | * Other combinations compare their core `major.minor.patch` only, so a stable | 56 | * Other combinations compare their core `major.minor.patch` only, so a stable |
| ... | @@ -59,8 +60,9 @@ function versionCore(version: string): string { | ... | @@ -59,8 +60,9 @@ function versionCore(version: string): string { |
| 59 | */ | 60 | */ |
| 60 | export function resolveVersionMismatch( | 61 | export function resolveVersionMismatch( |
| 61 | serverVersion: string | null | undefined, | 62 | serverVersion: string | null | undefined, |
| 63 | clientVersion: string = APP_VERSION, | ||
| 62 | ): VersionMismatch | null { | 64 | ): VersionMismatch | null { |
| 63 | const normalizedClientVersion = normalizeVersion(APP_VERSION); | 65 | const normalizedClientVersion = normalizeVersion(clientVersion); |
| 64 | const normalizedServerVersion = normalizeVersion(serverVersion); | 66 | const normalizedServerVersion = normalizeVersion(serverVersion); |
| 65 | if (!normalizedClientVersion || !normalizedServerVersion) { | 67 | if (!normalizedClientVersion || !normalizedServerVersion) { |
| 66 | return null; | 68 | return null; |
docs/user/updating.md+19| ... | @@ -5,6 +5,25 @@ When a server is behind your web or desktop app, an update notice appears in the | ... | @@ -5,6 +5,25 @@ When a server is behind your web or desktop app, an update notice appears in the |
| 5 | conversation and **Settings → Connections**. Update the machine named in that | 5 | conversation and **Settings → Connections**. Update the machine named in that |
| 6 | notice. | 6 | notice. |
| 7 | 7 | ||
| 8 | ## Update everything at once | ||
| 9 | |||
| 10 | The update button at the bottom of the sidebar updates every connected machine | ||
| 11 | in one step. It updates outdated providers, then servers that are behind, then | ||
| 12 | the desktop app you are using, which restarts last. Servers move to the | ||
| 13 | version your desktop app is installing, or to your web app's version. A | ||
| 14 | background service stays on its current release channel. A remote desktop app | ||
| 15 | installs the latest release from its own channel. Hover the button to see what | ||
| 16 | it will update. | ||
| 17 | |||
| 18 | Server updates appear only after that version is published to npm. Remote | ||
| 19 | desktop apps do not wait for npm. Servers that need | ||
| 20 | a manual update are not included. Use the notice or **Settings → Connections** | ||
| 21 | for those. | ||
| 22 | |||
| 23 | The confirmation counts the running threads that the restarts will interrupt, | ||
| 24 | and how many can resume afterwards. See **Continue threads after restarts** | ||
| 25 | below. | ||
| 26 | |||
| 8 | ## Before you update | 27 | ## Before you update |
| 9 | 28 | ||
| 10 | Server updates restart the connection and can interrupt active agents and | 29 | Server updates restart the connection and can interrupt active agents and |