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 : ( void>(); + +function setClientUpdateCheckInFlight(next: boolean): void { + if (clientUpdateCheckInFlight === next) return; + clientUpdateCheckInFlight = next; + for (const listener of clientUpdateCheckListeners) { + listener(); + } +} + +function subscribeClientUpdateCheckInFlight(listener: () => void): () => void { + clientUpdateCheckListeners.add(listener); + return () => { + clientUpdateCheckListeners.delete(listener); + }; +} + +function getClientUpdateCheckInFlightSnapshot(): boolean { + return clientUpdateCheckInFlight; +} + +function useClientUpdateCheckInFlight(): boolean { + return useSyncExternalStore( + subscribeClientUpdateCheckInFlight, + getClientUpdateCheckInFlightSnapshot, + () => false, + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +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.", + }), + ); + }); +} + +function handleSettledCheckState(state: DesktopUpdateState): void { + const nextAction = resolveDesktopUpdateButtonAction(state); + if (nextAction === "download") { + downloadDesktopUpdate(); + return; + } + if (nextAction === "install") { + return; + } + if (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.", + }); + return; + } + if (state.status === "error") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: state.message ?? "Update check failed.", + }), + ); + } +} + +/** + * Runs check → wait for settled desktop update state → download. Lives outside + * React so unmounting ClientUpdateAction (dismiss banner / leave Connections) + * cannot cancel the continuation. + * + * Returns whether this call owned the in-flight work. + */ +async function checkThenDownloadDesktopUpdate( + baselineCheckedAt: string | null, +): Promise<"owned" | "skipped"> { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.checkForUpdate !== "function") return "skipped"; + if (clientUpdateCheckInFlight) return "skipped"; + + setClientUpdateCheckInFlight(true); + try { + const result = await bridge.checkForUpdate(); + if (!result.checked) { + // `checked: false` is not always a hard failure — desktop skips starting a + // second check while one is already in flight (or while download/install is + // active). Join the in-flight check via polling; otherwise act on current state. + if (result.state.status === "checking") { + // fall through to the settle poll below + } else { + handleSettledCheckState(result.state); + const nextAction = resolveDesktopUpdateButtonAction(result.state); + if ( + nextAction === "none" && + result.state.status !== "downloading" && + result.state.status !== "up-to-date" && + result.state.status !== "error" + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: + result.state.message ?? "Automatic updates are not available in this build.", + }), + ); + } + return "owned"; + } + } + + const deadline = Date.now() + CHECK_SETTLE_TIMEOUT_MS; + while (Date.now() < deadline) { + const state = await bridge.getUpdateState(); + const checkAdvanced = state.status === "checking" || state.checkedAt !== baselineCheckedAt; + if (!checkAdvanced || state.status === "checking") { + await sleep(CHECK_SETTLE_POLL_MS); + continue; + } + handleSettledCheckState(state); + return "owned"; + } + + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: "Timed out waiting for the desktop updater to finish checking.", + }), + ); + return "owned"; + } catch (error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: error instanceof Error ? error.message : "Update check failed.", + }), + ); + return "owned"; + } finally { + setClientUpdateCheckInFlight(false); + } +} + +/** + * 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. + */ +export function ClientUpdateAction({ label = "Update client" }: { readonly label?: string }) { + const updateState = useDesktopUpdateState(); + const checkInFlight = useClientUpdateCheckInFlight(); + + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + const checking = updateState?.status === "checking" || checkInFlight; + const downloading = updateState?.status === "downloading"; + const updatesDisabled = + updateState !== null && (!updateState.enabled || updateState.status === "disabled"); + const buttonDisabled = + checking || + downloading || + (action === "none" + ? !canCheckForUpdate(updateState) + : isDesktopUpdateButtonDisabled(updateState)); + + const buttonLabel = + action === "install" + ? "Restart to update" + : action === "download" + ? label + : downloading + ? typeof updateState?.downloadPercent === "number" + ? `Downloading (${Math.floor(updateState.downloadPercent)}%)` + : "Downloading…" + : checking + ? "Checking…" + : label; + + const handleClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge) return; + + if (action === "download") { + downloadDesktopUpdate(); + 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; + } + + void checkThenDownloadDesktopUpdate(updateState?.checkedAt ?? null); + }, [action, updateState]); + + if (!isElectron) { + return ( + + Update or reload this client to match the server. + + ); + } + + if (updatesDisabled) { + return ( + + Automatic updates are unavailable in this build. Install a newer T3 Code desktop build 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