From 6cca87f80443b5844db05810215b688974f1aeb0 Mon Sep 17 00:00:00 2001 From: Shoaib Ansari Date: Sat, 2 May 2026 09:22:55 +0530 Subject: [PATCH 1/2] feat(desktop): show toast with release notes link when app updates to a new version When the desktop app restarts with a new version, a toast now appears announcing the version with a link to GitHub release notes. - Add shouldShowNewVersionToast / acknowledgeCurrentVersion logic with localStorage-based version tracking. - Add NewVersionToastCoordinator component following the existing coordinator pattern. - Mount coordinator in __root.tsx alongside other toast coordinators. - Add 9 tests covering first-launch, version-match, and version-change scenarios. Closes #2309 --- .../components/NewVersionToastCoordinator.tsx | 56 +++++++++++++ .../components/desktopUpdate.logic.test.ts | 78 ++++++++++++++++++- .../web/src/components/desktopUpdate.logic.ts | 49 ++++++++++++ apps/web/src/routes/__root.tsx | 2 + 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/NewVersionToastCoordinator.tsx diff --git a/apps/web/src/components/NewVersionToastCoordinator.tsx b/apps/web/src/components/NewVersionToastCoordinator.tsx new file mode 100644 index 0000000000..a6ddda12c1 --- /dev/null +++ b/apps/web/src/components/NewVersionToastCoordinator.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useDesktopUpdateState } from "../lib/desktopUpdateReactQuery"; +import { toastManager } from "./ui/toast"; +import { + acknowledgeCurrentVersion, + getNewVersionReleaseNotesUrl, + shouldShowNewVersionToast, +} from "./desktopUpdate.logic"; + +export function NewVersionToastCoordinator() { + const { data: updateState } = useDesktopUpdateState(); + const toastIdRef = useRef | null>(null); + + useEffect(() => { + if (!shouldShowNewVersionToast(updateState)) return; + + const version = updateState!.currentVersion; + acknowledgeCurrentVersion(updateState); + + if (toastIdRef.current) { + toastManager.close(toastIdRef.current); + } + + toastIdRef.current = toastManager.add({ + type: "success", + title: `Updated to v${version}`, + description: "View what's new in this release", + timeout: 0, + actionProps: { + children: "View release notes", + onClick: async () => { + const bridge = window.desktopBridge; + const url = getNewVersionReleaseNotesUrl(version); + if (bridge?.openExternal) { + await bridge.openExternal(url); + } else { + window.open(url, "_blank", "noopener,noreferrer"); + } + }, + }, + }); + }, [updateState]); + + useEffect(() => { + return () => { + if (toastIdRef.current) { + toastManager.close(toastIdRef.current); + toastIdRef.current = null; + } + }; + }, []); + + return null; +} diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 454ecdfe0e..8f1cf60f1d 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -1,17 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { + acknowledgeCurrentVersion, canCheckForUpdate, getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getNewVersionReleaseNotesUrl, isDesktopUpdateButtonDisabled, + readLastAcknowledgedVersion, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, + shouldShowNewVersionToast, } from "./desktopUpdate.logic"; const baseState: DesktopUpdateState = { @@ -290,3 +294,75 @@ describe("getDesktopUpdateButtonTooltip", () => { ); }); }); + +describe("new version toast", () => { + beforeEach(() => { + const store: Record = {}; + vi.stubGlobal("localStorage", { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("constructs the correct release notes URL", () => { + expect(getNewVersionReleaseNotesUrl("1.2.3")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v1.2.3", + ); + }); + + it("does not show toast when update state is null", () => { + expect(shouldShowNewVersionToast(null)).toBe(false); + expect(shouldShowNewVersionToast(undefined)).toBe(false); + }); + + it("does not show toast when updates are disabled", () => { + expect(shouldShowNewVersionToast({ ...baseState, enabled: false })).toBe(false); + }); + + it("does not show toast on first launch (no acknowledged version stored)", () => { + expect(readLastAcknowledgedVersion()).toBeNull(); + expect(shouldShowNewVersionToast(baseState)).toBe(false); + expect(readLastAcknowledgedVersion()).toBe("1.0.0"); + }); + + it("shows toast when version changes from acknowledged version", () => { + acknowledgeCurrentVersion(baseState); + expect(readLastAcknowledgedVersion()).toBe("1.0.0"); + + const updatedState: DesktopUpdateState = { ...baseState, currentVersion: "1.1.0" }; + expect(shouldShowNewVersionToast(updatedState)).toBe(true); + }); + + it("does not show toast when version matches acknowledged version", () => { + acknowledgeCurrentVersion(baseState); + expect(shouldShowNewVersionToast(baseState)).toBe(false); + }); + + it("acknowledgeCurrentVersion persists the current version", () => { + const state: DesktopUpdateState = { ...baseState, currentVersion: "2.0.0" }; + acknowledgeCurrentVersion(state); + expect(readLastAcknowledgedVersion()).toBe("2.0.0"); + }); + + it("acknowledgeCurrentVersion does nothing for disabled updates", () => { + acknowledgeCurrentVersion({ ...baseState, enabled: false }); + expect(readLastAcknowledgedVersion()).toBeNull(); + }); + + it("does not show toast when acknowledged version matches after first launch", () => { + expect(shouldShowNewVersionToast(baseState)).toBe(false); + expect(shouldShowNewVersionToast(baseState)).toBe(false); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 38983c810b..9dd203890a 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -1,5 +1,54 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +const LAST_ACKNOWLEDGED_VERSION_KEY = "t3code:lastAcknowledgedDesktopVersion"; + +export const GITHUB_RELEASES_URL = "https://github.com/pingdotgg/t3code/releases/tag"; + +export function getNewVersionReleaseNotesUrl(version: string): string { + return `${GITHUB_RELEASES_URL}/v${version}`; +} + +export function shouldShowNewVersionToast( + updateState: DesktopUpdateState | null | undefined, +): boolean { + if (!updateState?.enabled) return false; + const currentVersion = updateState.currentVersion; + if (!currentVersion) return false; + + const lastAcknowledged = readLastAcknowledgedVersion(); + if (lastAcknowledged === null) { + persistLastAcknowledgedVersion(currentVersion); + return false; + } + return currentVersion !== lastAcknowledged; +} + +export function acknowledgeCurrentVersion( + updateState: DesktopUpdateState | null | undefined, +): void { + if (!updateState?.enabled) return; + const currentVersion = updateState.currentVersion; + if (currentVersion) { + persistLastAcknowledgedVersion(currentVersion); + } +} + +export function readLastAcknowledgedVersion(): string | null { + try { + return localStorage.getItem(LAST_ACKNOWLEDGED_VERSION_KEY); + } catch { + return null; + } +} + +function persistLastAcknowledgedVersion(version: string): void { + try { + localStorage.setItem(LAST_ACKNOWLEDGED_VERSION_KEY, version); + } catch { + // localStorage may be unavailable (incognito, SSR, etc.) + } +} + export type DesktopUpdateButtonAction = "download" | "install" | "none"; export function resolveDesktopUpdateButtonAction( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 87e8667901..673defa963 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { NewVersionToastCoordinator } from "../components/NewVersionToastCoordinator"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -102,6 +103,7 @@ function RootRouteView() { + From 84b0444e456b4fd97f4eae5f466e4164a4ec33e7 Mon Sep 17 00:00:00 2001 From: Shoaib Ansari Date: Sat, 2 May 2026 09:41:20 +0530 Subject: [PATCH 2/2] fix: make shouldShowNewVersionToast a pure predicate (no localStorage side effect) Move first-launch version seeding from the predicate to the coordinator component, restoring command-query separation. --- .../src/components/NewVersionToastCoordinator.tsx | 15 ++++++++++++--- .../src/components/desktopUpdate.logic.test.ts | 5 +++-- apps/web/src/components/desktopUpdate.logic.ts | 5 +---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/NewVersionToastCoordinator.tsx b/apps/web/src/components/NewVersionToastCoordinator.tsx index a6ddda12c1..dd4800fa0e 100644 --- a/apps/web/src/components/NewVersionToastCoordinator.tsx +++ b/apps/web/src/components/NewVersionToastCoordinator.tsx @@ -6,6 +6,7 @@ import { toastManager } from "./ui/toast"; import { acknowledgeCurrentVersion, getNewVersionReleaseNotesUrl, + readLastAcknowledgedVersion, shouldShowNewVersionToast, } from "./desktopUpdate.logic"; @@ -14,9 +15,17 @@ export function NewVersionToastCoordinator() { const toastIdRef = useRef | null>(null); useEffect(() => { + if (!updateState?.enabled) return; + const currentVersion = updateState.currentVersion; + if (!currentVersion) return; + + if (readLastAcknowledgedVersion() === null) { + acknowledgeCurrentVersion(updateState); + return; + } + if (!shouldShowNewVersionToast(updateState)) return; - const version = updateState!.currentVersion; acknowledgeCurrentVersion(updateState); if (toastIdRef.current) { @@ -25,14 +34,14 @@ export function NewVersionToastCoordinator() { toastIdRef.current = toastManager.add({ type: "success", - title: `Updated to v${version}`, + title: `Updated to v${currentVersion}`, description: "View what's new in this release", timeout: 0, actionProps: { children: "View release notes", onClick: async () => { const bridge = window.desktopBridge; - const url = getNewVersionReleaseNotesUrl(version); + const url = getNewVersionReleaseNotesUrl(currentVersion); if (bridge?.openExternal) { await bridge.openExternal(url); } else { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 8f1cf60f1d..91534cd92f 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -331,10 +331,10 @@ describe("new version toast", () => { expect(shouldShowNewVersionToast({ ...baseState, enabled: false })).toBe(false); }); - it("does not show toast on first launch (no acknowledged version stored)", () => { + it("does not show toast when no acknowledged version is stored and has no side effects", () => { expect(readLastAcknowledgedVersion()).toBeNull(); expect(shouldShowNewVersionToast(baseState)).toBe(false); - expect(readLastAcknowledgedVersion()).toBe("1.0.0"); + expect(readLastAcknowledgedVersion()).toBeNull(); }); it("shows toast when version changes from acknowledged version", () => { @@ -362,6 +362,7 @@ describe("new version toast", () => { }); it("does not show toast when acknowledged version matches after first launch", () => { + acknowledgeCurrentVersion(baseState); expect(shouldShowNewVersionToast(baseState)).toBe(false); expect(shouldShowNewVersionToast(baseState)).toBe(false); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 9dd203890a..b8d32f294b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -16,10 +16,7 @@ export function shouldShowNewVersionToast( if (!currentVersion) return false; const lastAcknowledged = readLastAcknowledgedVersion(); - if (lastAcknowledged === null) { - persistLastAcknowledgedVersion(currentVersion); - return false; - } + if (lastAcknowledged === null) return false; return currentVersion !== lastAcknowledged; }