From c6933426a32ca3b21b37667842ed5aeed12ecef4 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Sun, 15 Mar 2026 21:48:57 +0530 Subject: [PATCH 1/7] feat(desktop): add manual check-for-updates from web settings UI Add checkForUpdate IPC channel so the web renderer can trigger an update check on demand. Surface a Check for Updates button in Settings > About (desktop only) with contextual labels and error handling. - Add DesktopUpdateCheckResult type and DesktopBridge.checkForUpdate() - Wire UPDATE_CHECK_CHANNEL in preload and main process IPC handler - Add canCheckForUpdate/getCheckForUpdateButtonLabel logic helpers - Add DesktopUpdateCheckSection component in settings About section - Guard IPC handler when updater is not configured (local/dev builds) - Add 15 unit tests for new logic functions Closes #1107 --- apps/desktop/src/main.ts | 17 +++ apps/desktop/src/preload.ts | 2 + .../components/desktopUpdate.logic.test.ts | 101 ++++++++++++++++ .../web/src/components/desktopUpdate.logic.ts | 17 +++ apps/web/src/routes/_chat.settings.tsx | 114 ++++++++++++++++-- packages/contracts/src/ipc.ts | 6 + 6 files changed, 248 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4606849295..866356f67b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import type { DesktopTheme, DesktopUpdateActionResult, + DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; @@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const STATE_DIR = process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); const DESKTOP_SCHEME = "t3"; @@ -1211,6 +1213,21 @@ function registerIpcHandlers(): void { state: updateState, } satisfies DesktopUpdateActionResult; }); + + ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); + ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { + if (!updaterConfigured) { + return { + checked: false, + state: updateState, + } satisfies DesktopUpdateCheckResult; + } + await checkForUpdates("web-ui"); + return { + checked: true, + state: updateState, + } satisfies DesktopUpdateCheckResult; + }); } function getIconOption(): { icon: string } | Record { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8e..29f23cde6b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; @@ -32,6 +33,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), + checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), onUpdateState: (listener) => { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 984eebd6b1..ac06c8924a 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { + canCheckForUpdate, getArm64IntelBuildWarningDescription, + getCheckForUpdateButtonLabel, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, @@ -207,3 +209,102 @@ describe("desktop update UI helpers", () => { expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); }); }); + +describe("canCheckForUpdate", () => { + it("returns false for null state", () => { + expect(canCheckForUpdate(null)).toBe(false); + }); + + it("returns false when updates are disabled", () => { + expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false); + }); + + it("returns false while checking", () => { + expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false); + }); + + it("returns false while downloading", () => { + expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe( + false, + ); + }); + + it("returns true when idle", () => { + expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); + }); + + it("returns true when up-to-date", () => { + expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true); + }); + + it("returns true when an update is available", () => { + expect( + canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }), + ).toBe(true); + }); + + it("returns true on error so the user can retry", () => { + expect( + canCheckForUpdate({ + ...baseState, + status: "error", + errorContext: "check", + message: "network", + }), + ).toBe(true); + }); +}); + +describe("getCheckForUpdateButtonLabel", () => { + it("returns the default label for null state", () => { + expect(getCheckForUpdateButtonLabel(null)).toBe("Check for Updates"); + }); + + it("returns 'Checking…' while checking", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "checking" })).toBe("Checking…"); + }); + + it("returns 'Up to Date' when up-to-date", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date"); + }); + + it("returns the available version when an update is available", () => { + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "available", + availableVersion: "1.2.0", + }), + ).toBe("Update Available: 1.2.0"); + }); + + it("returns 'Downloading…' while downloading", () => { + expect( + getCheckForUpdateButtonLabel({ ...baseState, status: "downloading", downloadPercent: 30 }), + ).toBe("Downloading…"); + }); + + it("returns 'Update Ready to Install' when downloaded", () => { + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "downloaded", + downloadedVersion: "1.2.0", + }), + ).toBe("Update Ready to Install"); + }); + + it("returns the default label for idle and error states", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "idle" })).toBe( + "Check for Updates", + ); + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "error", + errorContext: "check", + message: "fail", + }), + ).toBe("Check for Updates"); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index faf30883cc..6d6ff01549 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -94,3 +94,20 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu if (!state || state.status !== "error") return false; return state.errorContext === "download" || state.errorContext === "install"; } + +export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { + if (!state || !state.enabled) return false; + return ( + state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled" + ); +} + +export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): string { + if (!state) return "Check for Updates"; + if (state.status === "checking") return "Checking…"; + if (state.status === "up-to-date") return "Up to Date"; + if (state.status === "available") return `Update Available: ${state.availableVersion ?? ""}`; + if (state.status === "downloading") return "Downloading…"; + if (state.status === "downloaded") return "Update Ready to Install"; + return "Check for Updates"; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa1..f3bcc87939 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; -import { type ProviderKind } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; +import { type ProviderKind, type DesktopUpdateState } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; @@ -20,6 +20,7 @@ import { } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; +import { canCheckForUpdate, getCheckForUpdateButtonLabel } from "../components/desktopUpdate.logic"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -92,6 +93,97 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } } +function DesktopUpdateCheckSection() { + const [updateState, setUpdateState] = useState(null); + const [checkError, setCheckError] = useState(null); + + useEffect(() => { + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.getUpdateState !== "function" || + typeof bridge.onUpdateState !== "function" + ) { + return; + } + + let disposed = false; + let receivedSubscriptionUpdate = false; + const unsubscribe = bridge.onUpdateState((nextState) => { + if (disposed) return; + receivedSubscriptionUpdate = true; + setUpdateState(nextState); + }); + + void bridge + .getUpdateState() + .then((nextState) => { + if (disposed || receivedSubscriptionUpdate) return; + setUpdateState(nextState); + }) + .catch(() => undefined); + + return () => { + disposed = true; + unsubscribe(); + }; + }, []); + + const handleCheckForUpdate = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.checkForUpdate !== "function") return; + setCheckError(null); + + void bridge + .checkForUpdate() + .then((result) => { + setUpdateState(result.state); + if (!result.checked) { + setCheckError( + result.state.message ?? "Automatic updates are not available in this build.", + ); + } + }) + .catch((error: unknown) => { + setCheckError(error instanceof Error ? error.message : "Update check failed."); + }); + }, []); + + const buttonLabel = getCheckForUpdateButtonLabel(updateState); + const buttonDisabled = !canCheckForUpdate(updateState); + + return ( +
+
+
+

Updates

+

+ {updateState?.checkedAt + ? `Last checked: ${new Date(updateState.checkedAt).toLocaleString()}` + : "Check for available updates."} +

+
+ +
+ + {checkError ?

{checkError}

: null} + + {updateState?.status === "error" && updateState.errorContext === "check" ? ( +

+ {updateState.message ?? "Could not check for updates."} +

+ ) : null} +
+ ); +} + function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); @@ -674,14 +766,18 @@ function SettingsRouteView() {

-
-
-

Version

-

- Current version of the application. -

+
+
+
+

Version

+

+ Current version of the application. +

+
+ {APP_VERSION}
- {APP_VERSION} + + {isElectron ? : null}
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..32b6f109ba 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -94,6 +94,11 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface DesktopUpdateCheckResult { + checked: boolean; + state: DesktopUpdateState; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -106,6 +111,7 @@ export interface DesktopBridge { openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; + checkForUpdate: () => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; From f57619ddc4d15afc296fa1a918a5ea042f9bdb93 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 17 Mar 2026 00:31:36 +0530 Subject: [PATCH 2/7] refactor: use useQuery for update state instead of manual useEffect --- apps/web/src/routes/_chat.settings.tsx | 54 ++++++++------------------ 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index f3bcc87939..ec64e0a8c7 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; -import { type ProviderKind, type DesktopUpdateState } from "@t3tools/contracts"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; @@ -94,40 +94,20 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } function DesktopUpdateCheckSection() { - const [updateState, setUpdateState] = useState(null); + const queryClient = useQueryClient(); + const updateStateQuery = useQuery({ + queryKey: ["desktop", "updateState"], + queryFn: async () => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.getUpdateState !== "function") return null; + return bridge.getUpdateState(); + }, + staleTime: Infinity, + enabled: isElectron, + }); const [checkError, setCheckError] = useState(null); - useEffect(() => { - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.getUpdateState !== "function" || - typeof bridge.onUpdateState !== "function" - ) { - return; - } - - let disposed = false; - let receivedSubscriptionUpdate = false; - const unsubscribe = bridge.onUpdateState((nextState) => { - if (disposed) return; - receivedSubscriptionUpdate = true; - setUpdateState(nextState); - }); - - void bridge - .getUpdateState() - .then((nextState) => { - if (disposed || receivedSubscriptionUpdate) return; - setUpdateState(nextState); - }) - .catch(() => undefined); - - return () => { - disposed = true; - unsubscribe(); - }; - }, []); + const updateState = updateStateQuery.data ?? null; const handleCheckForUpdate = useCallback(() => { const bridge = window.desktopBridge; @@ -137,7 +117,7 @@ function DesktopUpdateCheckSection() { void bridge .checkForUpdate() .then((result) => { - setUpdateState(result.state); + queryClient.setQueryData(["desktop", "updateState"], result.state); if (!result.checked) { setCheckError( result.state.message ?? "Automatic updates are not available in this build.", @@ -147,7 +127,7 @@ function DesktopUpdateCheckSection() { .catch((error: unknown) => { setCheckError(error instanceof Error ? error.message : "Update check failed."); }); - }, []); + }, [queryClient]); const buttonLabel = getCheckForUpdateButtonLabel(updateState); const buttonDisabled = !canCheckForUpdate(updateState); From e8409668ca7bc68c8c9e06ed7f141c6f16a7728e Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 17 Mar 2026 01:11:39 +0530 Subject: [PATCH 3/7] refactor: use queryOptions pattern with real-time sync and multi-purpose button --- .../components/desktopUpdate.logic.test.ts | 8 +- .../web/src/components/desktopUpdate.logic.ts | 4 +- apps/web/src/lib/desktopUpdateReactQuery.ts | 18 +++++ apps/web/src/routes/_chat.settings.tsx | 77 +++++++++++++------ 4 files changed, 79 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/lib/desktopUpdateReactQuery.ts diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index ac06c8924a..a37601d81c 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -268,14 +268,14 @@ describe("getCheckForUpdateButtonLabel", () => { expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date"); }); - it("returns the available version when an update is available", () => { + it("returns 'Download Update' when an update is available", () => { expect( getCheckForUpdateButtonLabel({ ...baseState, status: "available", availableVersion: "1.2.0", }), - ).toBe("Update Available: 1.2.0"); + ).toBe("Download Update"); }); it("returns 'Downloading…' while downloading", () => { @@ -284,14 +284,14 @@ describe("getCheckForUpdateButtonLabel", () => { ).toBe("Downloading…"); }); - it("returns 'Update Ready to Install' when downloaded", () => { + it("returns 'Install Update' when downloaded", () => { expect( getCheckForUpdateButtonLabel({ ...baseState, status: "downloaded", downloadedVersion: "1.2.0", }), - ).toBe("Update Ready to Install"); + ).toBe("Install Update"); }); it("returns the default label for idle and error states", () => { diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 6d6ff01549..cc6ae95d65 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -106,8 +106,8 @@ export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): if (!state) return "Check for Updates"; if (state.status === "checking") return "Checking…"; if (state.status === "up-to-date") return "Up to Date"; - if (state.status === "available") return `Update Available: ${state.availableVersion ?? ""}`; + if (state.status === "available") return "Download Update"; if (state.status === "downloading") return "Downloading…"; - if (state.status === "downloaded") return "Update Ready to Install"; + if (state.status === "downloaded") return "Install Update"; return "Check for Updates"; } diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts new file mode 100644 index 0000000000..ba1db17987 --- /dev/null +++ b/apps/web/src/lib/desktopUpdateReactQuery.ts @@ -0,0 +1,18 @@ +import { queryOptions } from "@tanstack/react-query"; + +export const desktopUpdateQueryKeys = { + all: ["desktop", "update"] as const, + state: () => ["desktop", "update", "state"] as const, +}; + +export function desktopUpdateStateQueryOptions() { + return queryOptions({ + queryKey: desktopUpdateQueryKeys.state(), + queryFn: async () => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.getUpdateState !== "function") return null; + return bridge.getUpdateState(); + }, + staleTime: Infinity, + }); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index ec64e0a8c7..2ae963752d 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; @@ -20,7 +20,12 @@ import { } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; -import { canCheckForUpdate, getCheckForUpdateButtonLabel } from "../components/desktopUpdate.logic"; +import { + canCheckForUpdate, + getCheckForUpdateButtonLabel, + resolveDesktopUpdateButtonAction, +} from "../components/desktopUpdate.logic"; +import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -95,29 +100,62 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { function DesktopUpdateCheckSection() { const queryClient = useQueryClient(); - const updateStateQuery = useQuery({ - queryKey: ["desktop", "updateState"], - queryFn: async () => { - const bridge = window.desktopBridge; - if (!bridge || typeof bridge.getUpdateState !== "function") return null; - return bridge.getUpdateState(); - }, - staleTime: Infinity, - enabled: isElectron, - }); + const updateStateQuery = useQuery(desktopUpdateStateQueryOptions()); const [checkError, setCheckError] = useState(null); const updateState = updateStateQuery.data ?? null; - const handleCheckForUpdate = useCallback(() => { + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.onUpdateState !== "function") return; + + const opts = desktopUpdateStateQueryOptions(); + const unsubscribe = bridge.onUpdateState((nextState) => { + queryClient.setQueryData(opts.queryKey, nextState); + }); + + return () => { + unsubscribe(); + }; + }, [queryClient]); + + const handleButtonClick = useCallback(() => { const bridge = window.desktopBridge; - if (!bridge || typeof bridge.checkForUpdate !== "function") return; + if (!bridge) return; setCheckError(null); + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + const opts = desktopUpdateStateQueryOptions(); + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + queryClient.setQueryData(opts.queryKey, result.state); + }) + .catch((error: unknown) => { + setCheckError(error instanceof Error ? error.message : "Download failed."); + }); + return; + } + + if (action === "install") { + void bridge + .installUpdate() + .then((result) => { + queryClient.setQueryData(opts.queryKey, result.state); + }) + .catch((error: unknown) => { + setCheckError(error instanceof Error ? error.message : "Install failed."); + }); + return; + } + + if (typeof bridge.checkForUpdate !== "function") return; void bridge .checkForUpdate() .then((result) => { - queryClient.setQueryData(["desktop", "updateState"], result.state); + queryClient.setQueryData(opts.queryKey, result.state); if (!result.checked) { setCheckError( result.state.message ?? "Automatic updates are not available in this build.", @@ -127,7 +165,7 @@ function DesktopUpdateCheckSection() { .catch((error: unknown) => { setCheckError(error instanceof Error ? error.message : "Update check failed."); }); - }, [queryClient]); + }, [queryClient, updateState]); const buttonLabel = getCheckForUpdateButtonLabel(updateState); const buttonDisabled = !canCheckForUpdate(updateState); @@ -143,12 +181,7 @@ function DesktopUpdateCheckSection() { : "Check for available updates."}

- From 945774b295220be5cf49d77578d9ff6fc8b7b1ff Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 17 Mar 2026 22:03:27 +0530 Subject: [PATCH 4/7] feat(web): add tooltip to update button in settings --- apps/web/src/routes/_chat.settings.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 2ae963752d..26bb5e9216 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -23,9 +23,11 @@ import { APP_VERSION } from "../branding"; import { canCheckForUpdate, getCheckForUpdateButtonLabel, + getDesktopUpdateButtonTooltip, resolveDesktopUpdateButtonAction, } from "../components/desktopUpdate.logic"; import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -169,6 +171,7 @@ function DesktopUpdateCheckSection() { const buttonLabel = getCheckForUpdateButtonLabel(updateState); const buttonDisabled = !canCheckForUpdate(updateState); + const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; return (
@@ -181,9 +184,21 @@ function DesktopUpdateCheckSection() { : "Check for available updates."}

- + + + {buttonLabel} + + } + /> + {buttonTooltip ? {buttonTooltip} : null} + {checkError ?

{checkError}

: null} From b2ce524d93949cec7f593a28da44b87f2ba22300 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Sun, 22 Mar 2026 03:09:17 +0530 Subject: [PATCH 5/7] feat(desktop-update): improve update visibility and add restart confirmation - Replace generic rocket icon with a downward arrow (`ArrowDownToLineIcon`) in the sidebar for better visual clarity. - Update sidebar icon colors to use a green/blue semantic palette instead of warning colors, and fix CSS class collisions that caused the icon to incorrectly render as white. - Add a confirmation dialog before triggering `installUpdate()` in both the sidebar and settings panel to warn users that the app will restart and tasks will be interrupted. Addresses UX feedback in #1204 --- apps/web/src/components/Sidebar.tsx | 15 +++++++++++---- apps/web/src/routes/_chat.settings.tsx | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9ff741897c..e26ae43533 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,10 +1,10 @@ import { + ArrowDownToLineIcon, ArrowLeftIcon, ChevronRightIcon, FolderIcon, GitPullRequestIcon, PlusIcon, - RocketIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -1062,7 +1062,9 @@ export default function Sidebar() { ? "text-sky-400" : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" - : "text-amber-500 animate-pulse"; + : desktopUpdateState?.status === "available" + ? "text-emerald-500" + : "text-muted-foreground"; const newThreadShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "chat.newLocal") ?? @@ -1106,6 +1108,11 @@ export default function Sidebar() { } if (desktopUpdateButtonAction === "install") { + const version = desktopUpdateState.downloadedVersion ?? desktopUpdateState.availableVersion; + const confirmed = window.confirm( + `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`, + ); + if (!confirmed) return; void bridge .installUpdate() .then((result) => { @@ -1185,10 +1192,10 @@ export default function Sidebar() { aria-label={desktopUpdateTooltip} aria-disabled={desktopUpdateButtonDisabled || undefined} disabled={desktopUpdateButtonDisabled} - className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} + className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} onClick={handleDesktopUpdateButtonClick} > - + } /> diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 568300015d..c3d99eaf84 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -104,6 +104,11 @@ function DesktopUpdateCheckSection() { } if (action === "install") { + const version = updateState?.downloadedVersion ?? updateState?.availableVersion; + const confirmed = window.confirm( + `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`, + ); + if (!confirmed) return; void bridge .installUpdate() .then((result) => { From a146f06d5fefb645be8e7ac4a98713e724e3c256 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 24 Mar 2026 23:07:23 +0530 Subject: [PATCH 6/7] fix(settings): remove redundant getCheckForUpdateButtonLabel, use action/status maps for button label with tooltip from getDesktopUpdateButtonTooltip --- .../components/desktopUpdate.logic.test.ts | 56 ++----------------- .../web/src/components/desktopUpdate.logic.ts | 10 ---- apps/web/src/routes/_chat.settings.tsx | 15 +++-- 3 files changed, 16 insertions(+), 65 deletions(-) diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index a37601d81c..cbb6f56fdd 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -4,7 +4,6 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con import { canCheckForUpdate, getArm64IntelBuildWarningDescription, - getCheckForUpdateButtonLabel, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, @@ -255,56 +254,11 @@ describe("canCheckForUpdate", () => { }); }); -describe("getCheckForUpdateButtonLabel", () => { - it("returns the default label for null state", () => { - expect(getCheckForUpdateButtonLabel(null)).toBe("Check for Updates"); - }); - - it("returns 'Checking…' while checking", () => { - expect(getCheckForUpdateButtonLabel({ ...baseState, status: "checking" })).toBe("Checking…"); - }); - - it("returns 'Up to Date' when up-to-date", () => { - expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date"); - }); - - it("returns 'Download Update' when an update is available", () => { - expect( - getCheckForUpdateButtonLabel({ - ...baseState, - status: "available", - availableVersion: "1.2.0", - }), - ).toBe("Download Update"); - }); - - it("returns 'Downloading…' while downloading", () => { - expect( - getCheckForUpdateButtonLabel({ ...baseState, status: "downloading", downloadPercent: 30 }), - ).toBe("Downloading…"); - }); - - it("returns 'Install Update' when downloaded", () => { - expect( - getCheckForUpdateButtonLabel({ - ...baseState, - status: "downloaded", - downloadedVersion: "1.2.0", - }), - ).toBe("Install Update"); - }); - - it("returns the default label for idle and error states", () => { - expect(getCheckForUpdateButtonLabel({ ...baseState, status: "idle" })).toBe( - "Check for Updates", +describe("getDesktopUpdateButtonTooltip", () => { + it("returns 'Up to date' for non-actionable states", () => { + expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date"); + expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe( + "Up to date", ); - expect( - getCheckForUpdateButtonLabel({ - ...baseState, - status: "error", - errorContext: "check", - message: "fail", - }), - ).toBe("Check for Updates"); }); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index cc6ae95d65..d4fb0b5044 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -101,13 +101,3 @@ export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled" ); } - -export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): string { - if (!state) return "Check for Updates"; - if (state.status === "checking") return "Checking…"; - if (state.status === "up-to-date") return "Up to Date"; - if (state.status === "available") return "Download Update"; - if (state.status === "downloading") return "Downloading…"; - if (state.status === "downloaded") return "Install Update"; - return "Check for Updates"; -} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 6d47497fb5..99e2ecabbb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -27,7 +27,6 @@ import { SidebarTrigger } from "../components/ui/sidebar"; import { Switch } from "../components/ui/switch"; import { canCheckForUpdate, - getCheckForUpdateButtonLabel, getDesktopUpdateButtonTooltip, resolveDesktopUpdateButtonAction, } from "../components/desktopUpdate.logic"; @@ -139,9 +138,18 @@ function DesktopUpdateCheckSection() { }); }, [queryClient, updateState]); - const buttonLabel = getCheckForUpdateButtonLabel(updateState); - const buttonDisabled = !canCheckForUpdate(updateState); + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; + const buttonDisabled = !canCheckForUpdate(updateState); + + const actionLabel: Record = { download: "Download", install: "Install" }; + const statusLabel: Record = { + checking: "Checking…", + downloading: "Downloading…", + "up-to-date": "Up to Date", + }; + const buttonLabel = + actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; return ( {isElectron ? : null} - From b216503fd7dcc263a028d7042b86207d88906ef8 Mon Sep 17 00:00:00 2001 From: Ariaj Sarkar Date: Tue, 24 Mar 2026 23:38:37 +0530 Subject: [PATCH 7/7] fix: tooltip fallback returns 'Up to date' for idle/up-to-date states instead of misleading 'Update available' --- apps/web/src/components/desktopUpdate.logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index d4fb0b5044..df9139908c 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -76,7 +76,7 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string } return state.message ?? "Update failed"; } - return "Update available"; + return "Up to date"; } export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null {