From 663f609efacda365823047eccc1c0dd6b97070f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Mon, 3 Aug 2026 00:02:56 -0300 Subject: [PATCH 1/7] fix(web): update client when version skew leaves the app behind Detect which side of a client/server version mismatch is outdated so the banner offers Update client via the desktop updater instead of downgrading a newer server. Co-authored-by: Cursor --- apps/web/src/components/ChatView.tsx | 26 ++- .../web/src/components/ClientUpdateAction.tsx | 191 ++++++++++++++++++ .../settings/ConnectionsSettings.tsx | 30 ++- apps/web/src/versionSkew.test.ts | 28 ++- apps/web/src/versionSkew.ts | 40 +++- docs/user/updating.md | 28 ++- 6 files changed, 310 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/components/ClientUpdateAction.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..351bd4383f5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -296,9 +296,11 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { ClientUpdateAction } from "./ClientUpdateAction"; import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, + clientUpdateGuidance, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, @@ -1913,7 +1915,10 @@ function ChatViewContent(props: ChatViewProps) { // "versions differ". A failed update never folds: its error and retry // action must stay visible. const reconnectingThroughVersionSkew = - serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; + serverUpdateState.status === "idle" && + environmentReconnecting && + versionMismatch !== null && + versionMismatch.outdatedSide !== "client"; // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. @@ -1972,8 +1977,9 @@ function ChatViewContent(props: ChatViewProps) { (serverUpdateState.status !== "idle" || (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) ) { - const updateInProgress = serverUpdateState.status === "running"; - const updateFailed = serverUpdateState.status === "failed"; + const clientOutdated = versionMismatch?.outdatedSide === "client"; + const updateInProgress = !clientOutdated && serverUpdateState.status === "running"; + const updateFailed = !clientOutdated && serverUpdateState.status === "failed"; items.push({ id: `server-version:${serverUpdateEnvironmentId}`, variant: updateFailed ? "error" : updateInProgress ? "default" : "warning", @@ -1999,15 +2005,17 @@ function ChatViewContent(props: ChatViewProps) { <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} {versionMismatch.serverVersion}.{" "} - {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} + {clientOutdated + ? clientUpdateGuidance() + : serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} ) : null, - // The desktop-managed guidance is already the description; the action - // slot would only repeat it. + // Client-behind: Update client via the desktop updater. Server-behind + + // desktop-managed: guidance is already the description. actions: - updateInProgress || - !versionMismatch || - versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + updateInProgress || !versionMismatch ? undefined : clientOutdated ? ( + + ) : versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( { + const bridge = window.desktopBridge; + if (!bridge) return; + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }); + return; + } + + if (action === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage( + updateState ?? { availableVersion: null, downloadedVersion: null }, + navigator.platform, + ), + ); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); + }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }); + return; + } + + if (typeof bridge.checkForUpdate !== "function") return; + void bridge + .checkForUpdate() + .then((result) => { + if (!result.checked) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: + result.state.message ?? "Automatic updates are not available in this build.", + }), + ); + return; + } + const nextAction = resolveDesktopUpdateButtonAction(result.state); + if (nextAction === "download") { + return bridge.downloadUpdate().then((downloadResult) => { + if (downloadResult.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(downloadResult)) return; + const actionError = getDesktopUpdateActionError(downloadResult); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + }); + } + if (nextAction === "none" && result.state.status === "up-to-date") { + toastManager.add({ + type: "info", + title: "No newer desktop update found", + description: + "This build may not have a published update yet. Install a newer T3 Code desktop build to match the server.", + }); + } + }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: error instanceof Error ? error.message : "Update check failed.", + }), + ); + }); + }, [action, updateState]); + + if (!isElectron) { + return ( + + Update or reload this client to match the server. + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 640412e4a6d..243d2e60f94 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -109,6 +109,7 @@ import { import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { useUiStateStore } from "~/uiStateStore"; import { + clientUpdateGuidance, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, } from "~/versionSkew"; @@ -135,6 +136,7 @@ import { import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "~/state/server"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ClientUpdateAction } from "../ClientUpdateAction"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; @@ -1431,7 +1433,7 @@ function SavedBackendListRow({ {metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

) : null} - {serverUpdateState.status !== "idle" ? ( + {versionMismatch?.outdatedSide !== "client" && serverUpdateState.status !== "idle" ? (
Version drift: client {versionMismatch.clientVersion}, server{" "} {versionMismatch.serverVersion}. + {versionMismatch.outdatedSide === "client" ? ` ${clientUpdateGuidance()}` : null}

) : null} {environment.connection.error && !resumingServerUpdate ? ( @@ -1461,8 +1464,10 @@ function SavedBackendListRow({ ) : null}
- {versionMismatch && - (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? ( + {versionMismatch?.outdatedSide === "client" ? ( + + ) : versionMismatch && + (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? ( Client {primaryVersionMismatch.clientVersion}, server{" "} - {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects - fail. + {primaryVersionMismatch.serverVersion}.{" "} + {primaryVersionMismatch.outdatedSide === "client" + ? clientUpdateGuidance() + : "Sync them if RPC calls or reconnects fail."} ) : null } control={ - primaryVersionMismatch && - primaryEnvironmentId !== null && - primaryServerUpdateState.status !== "running" ? ( + primaryVersionMismatch?.outdatedSide === "client" ? ( + + ) : primaryVersionMismatch && + primaryEnvironmentId !== null && + primaryServerUpdateState.status !== "running" ? ( { }); it("returns a mismatch when the server version differs from the client", () => { + const outdatedSide = resolveVersionOutdatedSide(APP_VERSION, "9.9.9"); expect(resolveVersionMismatch("9.9.9")).toEqual({ clientVersion: APP_VERSION, serverVersion: "9.9.9", - hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.", + outdatedSide, + hint: + outdatedSide === "client" + ? "Version mismatch. Update the client to the same T3 Code version as the server." + : outdatedSide === "server" + ? "Version mismatch. Update the server to the same T3 Code version as the client." + : "Version mismatch. Try syncing the client and server to the same T3 Code version.", }); }); + it("marks the client outdated when the server nightly is newer", () => { + expect( + resolveVersionOutdatedSide("0.0.32-nightly.20260802.980", "0.0.32-nightly.20260803.985"), + ).toBe("client"); + }); + + it("marks the server outdated when the client nightly is newer", () => { + expect( + resolveVersionOutdatedSide("0.0.32-nightly.20260803.985", "0.0.32-nightly.20260802.980"), + ).toBe("server"); + }); + it("reads the server version from config descriptors", () => { expect( resolveServerConfigVersionMismatch({ @@ -70,11 +91,11 @@ describe("versionSkew", () => { ).toBe(false); }); - it("appends a hint to connection errors when versions differ", () => { + it("appends a direction-aware hint to connection errors when versions differ", () => { const mismatch = resolveVersionMismatch("9.9.9"); expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe( - "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.", + `Socket closed. Hint: ${mismatch?.hint}`, ); }); @@ -106,5 +127,6 @@ describe("versionSkew", () => { expect(serverUpdateGuidance(null, "Local server")).toBe( "Relaunch the Local server with the copied command to sync them.", ); + expect(clientUpdateGuidance()).toBe("Update this client so they stay in sync."); }); }); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 6cf2a474269..f78e99b58bb 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,12 +1,17 @@ import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import * as Schema from "effect/Schema"; import { APP_VERSION } from "./branding"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; +/** Which side of a version mismatch is behind, or unknown when ordering is inconclusive. */ +export type VersionOutdatedSide = "client" | "server" | "unknown"; + export interface VersionMismatch { readonly clientVersion: string; readonly serverVersion: string; + readonly outdatedSide: VersionOutdatedSide; readonly hint: string; } @@ -23,6 +28,31 @@ function normalizeVersion(version: string | null | undefined): string | null { return trimmed && trimmed.length > 0 ? trimmed : null; } +export function resolveVersionOutdatedSide( + clientVersion: string, + serverVersion: string, +): VersionOutdatedSide { + const comparison = compareSemverVersions(clientVersion, serverVersion); + if (comparison < 0) { + return "client"; + } + if (comparison > 0) { + return "server"; + } + return "unknown"; +} + +function versionMismatchHint(outdatedSide: VersionOutdatedSide): string { + switch (outdatedSide) { + case "client": + return "Version mismatch. Update the client to the same T3 Code version as the server."; + case "server": + return "Version mismatch. Update the server to the same T3 Code version as the client."; + default: + return "Version mismatch. Try syncing the client and server to the same T3 Code version."; + } +} + export function resolveVersionMismatch( serverVersion: string | null | undefined, ): VersionMismatch | null { @@ -36,10 +66,13 @@ export function resolveVersionMismatch( return null; } + const outdatedSide = resolveVersionOutdatedSide(normalizedClientVersion, normalizedServerVersion); + return { clientVersion: normalizedClientVersion, serverVersion: normalizedServerVersion, - hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.", + outdatedSide, + hint: versionMismatchHint(outdatedSide), }; } @@ -79,6 +112,11 @@ export function serverUpdateGuidance( } } +/** One sentence telling the user to update this client when it is behind the server. */ +export function clientUpdateGuidance(): string { + return "Update this client so they stay in sync."; +} + export function buildVersionMismatchDismissalKey( environmentId: EnvironmentId, mismatch: Pick, diff --git a/docs/user/updating.md b/docs/user/updating.md index a0cc0e5d1e0..f9e58f97f05 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -1,7 +1,8 @@ # Keeping T3 Code in Sync The T3 Code web or desktop app and the server it connects to work best when they use the same -version. If they do not match, T3 Code shows a warning with the right update option for that server. +version. If they do not match, T3 Code shows a warning with the right update option for whichever +side is behind. ## Where to Find the Update @@ -11,25 +12,30 @@ You may see the warning in either of these places: - **Settings** → **Connections**, beside the affected connection Dismissing the conversation warning only hides that reminder for those two versions. It does not -update the server, and the version difference remains visible in Connections. +update either side, and the version difference remains visible in Connections. ## Before You Update -Let active agent work and terminal commands finish first. Updating restarts the server, so the -connection will disappear briefly and work that is still running may be interrupted. +If the warning asks you to update the **server**, let active agent work and terminal commands finish +first. Updating restarts the server, so the connection will disappear briefly and work that is still +running may be interrupted. + +If the warning asks you to update this **client**, finish local work you care about before +restarting the desktop app. The update does not remove saved threads, settings, or project files. ## Choose the Action You See -| Action | What to do | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Update server** | Available for the T3 Code Linux background service. Select the button and leave T3 Code open while it prepares, tests, restarts, and reconnects. | -| **Update the desktop app** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. | -| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. | +| Action | What to do | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Update client** | Shown when this app is older than the connected server. On the desktop app, select the button to check for, download, and install the newer client build. | +| **Update server** | Shown when the connected server is older than this client. Available for the T3 Code Linux background service. Leave T3 Code open while it prepares, tests, restarts, and reconnects. | +| **Update the desktop app** | Shown for a server managed by a desktop app on another machine. Open T3 Code there and install the app update. Reopen it if needed. | +| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. | -The available action depends on how that server was started. T3 Code does not update connected -servers silently in the background. +The available action depends on which side is behind and how that server was started. T3 Code does +not update connected servers silently in the background. If the requested version includes a database update, remote installation stops before restart and asks you to run the exact `npx t3@ service update` command on the server machine. This is From 85d9dea814d51da399dfbd7ac2f4e539733fd1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Mon, 3 Aug 2026 00:13:56 -0300 Subject: [PATCH 2/7] fix(web): wait for settled desktop update state before downloading Address Bugbot findings on ClientUpdateAction: continue after check via subscribed update state instead of the async check return value, and show guidance when desktop auto-updates are disabled. Co-authored-by: Cursor --- .../web/src/components/ClientUpdateAction.tsx | 162 +++++++++++------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/ClientUpdateAction.tsx b/apps/web/src/components/ClientUpdateAction.tsx index 2fefd8a25b6..f9df290841b 100644 --- a/apps/web/src/components/ClientUpdateAction.tsx +++ b/apps/web/src/components/ClientUpdateAction.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { isElectron } from "../env"; import { useDesktopUpdateState } from "../state/desktopUpdate"; @@ -14,17 +14,59 @@ import { Button } from "./ui/button"; import { Spinner } from "./ui/spinner"; import { stackedThreadToast, toastManager } from "./ui/toast"; +function downloadDesktopUpdate(): void { + const bridge = window.desktopBridge; + if (!bridge) return; + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }); +} + /** * Call-to-action when this client is behind the connected server. On desktop, * drives the Electron updater (check → download → install). Elsewhere, only * guidance text is shown — there is no server-install path for this case. + * + * After `checkForUpdate`, desktop state is applied asynchronously via updater + * events. We wait for that settled state (not the immediate return value) + * before downloading or reporting "up to date". */ export function ClientUpdateAction({ label = "Update client" }: { readonly label?: string }) { const updateState = useDesktopUpdateState(); + const [awaitingCheckResult, setAwaitingCheckResult] = useState(false); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; - const checking = updateState?.status === "checking"; + const checking = updateState?.status === "checking" || awaitingCheckResult; const downloading = updateState?.status === "downloading"; + const updatesDisabled = + updateState !== null && (!updateState.enabled || updateState.status === "disabled"); const buttonDisabled = checking || downloading || @@ -45,41 +87,52 @@ export function ClientUpdateAction({ label = "Update client" }: { readonly label ? "Checking…" : label; + useEffect(() => { + if (!awaitingCheckResult || !updateState) { + return; + } + // Desktop applies update-available / up-to-date asynchronously after + // checkForUpdate resolves; stay pending until status leaves "checking". + if (updateState.status === "checking") { + return; + } + + setAwaitingCheckResult(false); + + const nextAction = resolveDesktopUpdateButtonAction(updateState); + if (nextAction === "download") { + downloadDesktopUpdate(); + return; + } + if (nextAction === "install") { + return; + } + if (updateState.status === "up-to-date") { + toastManager.add({ + type: "info", + title: "No newer desktop update found", + description: + "This build may not have a published update yet. Install a newer T3 Code desktop build to match the server.", + }); + return; + } + if (updateState.status === "error") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: updateState.message ?? "Update check failed.", + }), + ); + } + }, [awaitingCheckResult, updateState]); + const handleClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge) return; if (action === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }); + downloadDesktopUpdate(); return; } @@ -118,10 +171,12 @@ export function ClientUpdateAction({ label = "Update client" }: { readonly label } if (typeof bridge.checkForUpdate !== "function") return; + setAwaitingCheckResult(true); void bridge .checkForUpdate() .then((result) => { if (!result.checked) { + setAwaitingCheckResult(false); toastManager.add( stackedThreadToast({ type: "error", @@ -130,40 +185,12 @@ export function ClientUpdateAction({ label = "Update client" }: { readonly label result.state.message ?? "Automatic updates are not available in this build.", }), ); - return; - } - const nextAction = resolveDesktopUpdateButtonAction(result.state); - if (nextAction === "download") { - return bridge.downloadUpdate().then((downloadResult) => { - if (downloadResult.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); - } - if (!shouldToastDesktopUpdateActionResult(downloadResult)) return; - const actionError = getDesktopUpdateActionError(downloadResult); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }); - } - if (nextAction === "none" && result.state.status === "up-to-date") { - toastManager.add({ - type: "info", - title: "No newer desktop update found", - description: - "This build may not have a published update yet. Install a newer T3 Code desktop build to match the server.", - }); } + // Do not download from result.state here — updater events may still be + // in flight. The effect above continues once desktop update state settles. }) .catch((error: unknown) => { + setAwaitingCheckResult(false); toastManager.add( stackedThreadToast({ type: "error", @@ -182,6 +209,15 @@ export function ClientUpdateAction({ label = "Update client" }: { readonly label ); } + if (updatesDisabled) { + return ( + + Automatic updates are unavailable in this build. Install a newer T3 Code desktop build to + match the server. + + ); + } + return (