From 07f3a7f895a440f7a34457bbbe461206d31b7bbf Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:23:27 +0200 Subject: [PATCH 1/2] fix(desktop): link release notes from the update downloaded toast Adds an inline "Read more" link to the "Update downloaded" notification, right after the install instruction, opening the GitHub release for the exact downloaded version through the existing external-link bridge. Closes #4722. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/Sidebar.tsx | 7 +- .../components/desktopUpdate.logic.test.ts | 18 +++++ .../web/src/components/desktopUpdate.logic.ts | 9 +++ .../components/desktopUpdate.toast.test.tsx | 79 +++++++++++++++++++ .../src/components/desktopUpdate.toast.tsx | 52 ++++++++++++ .../components/sidebar/SidebarUpdatePill.tsx | 7 +- 6 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/desktopUpdate.toast.test.tsx create mode 100644 apps/web/src/components/desktopUpdate.toast.tsx diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98b5dcf84ed..912172ab909 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -133,6 +133,7 @@ import { shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; +import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { @@ -3509,11 +3510,7 @@ export default function Sidebar() { .downloadUpdate() .then((result) => { if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); + showDesktopUpdateDownloadedToast(bridge, result.state.downloadedVersion); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index b07ae99c058..8d24b34a433 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -7,6 +7,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { + it("builds the stable release URL for a downloaded version", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + + it("builds the nightly release URL without dropping its version suffix", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", + ); + }); + + it("omits the release URL when the updater does not report a version", () => { + expect(getDesktopUpdateReleaseUrl(null)).toBeNull(); + expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); + }); + it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 11c34777a41..f0c8438042d 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,6 +3,15 @@ import { isWindowsPlatform } from "../lib/utils"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; +const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; + +/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */ +export function getDesktopUpdateReleaseUrl(version: string | null): string | null { + const normalizedVersion = version?.trim(); + if (!normalizedVersion) return null; + return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; +} + export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { diff --git a/apps/web/src/components/desktopUpdate.toast.test.tsx b/apps/web/src/components/desktopUpdate.toast.test.tsx new file mode 100644 index 00000000000..8797e3c53e2 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.toast.test.tsx @@ -0,0 +1,79 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + addToast: vi.fn(), +})); + +vi.mock("./ui/toast", () => ({ + toastManager: { add: testState.addToast }, +})); + +import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; + +type ClickableElement = ReactElement<{ readonly onClick?: () => void }>; + +/** Walks the rendered description, invoking function components, to find the link button. */ +function findReleaseNotesLink(node: ReactNode): ClickableElement | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findReleaseNotesLink(child); + if (found) return found; + } + return null; + } + if (!isValidElement(node)) return null; + const element = node as ReactElement<{ readonly children?: ReactNode }>; + if (element.type === "button") return element as ClickableElement; + if (typeof element.type === "function") { + const render = element.type as (props: unknown) => ReactNode; + return findReleaseNotesLink(render(element.props)); + } + return findReleaseNotesLink(element.props.children); +} + +function getDescription(): ReactNode { + const toast = testState.addToast.mock.calls[0]?.[0] as { description?: ReactNode } | undefined; + return toast?.description ?? null; +} + +describe("showDesktopUpdateDownloadedToast", () => { + beforeEach(() => { + testState.addToast.mockReset(); + }); + + it("opens the downloaded version's release notes", async () => { + const openExternal = vi.fn().mockResolvedValue(true); + + showDesktopUpdateDownloadedToast({ openExternal }, "0.0.30"); + const link = findReleaseNotesLink(getDescription()); + link?.props.onClick?.(); + await vi.waitFor(() => { + expect(openExternal).toHaveBeenCalledWith( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + expect(testState.addToast).toHaveBeenCalledTimes(1); + }); + + it("omits the link when the updater does not report a version", () => { + showDesktopUpdateDownloadedToast({ openExternal: vi.fn() }, null); + + expect(findReleaseNotesLink(getDescription())).toBeNull(); + }); + + it.each([ + ["returns false", vi.fn().mockResolvedValue(false)], + ["rejects", vi.fn().mockRejectedValue(new Error("open failed"))], + ])("shows an error when opening release notes %s", async (_description, openExternal) => { + showDesktopUpdateDownloadedToast({ openExternal }, "0.0.30"); + findReleaseNotesLink(getDescription())?.props.onClick?.(); + + await vi.waitFor(() => { + expect(testState.addToast).toHaveBeenLastCalledWith({ + type: "error", + title: "Unable to open release notes", + }); + }); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.toast.tsx b/apps/web/src/components/desktopUpdate.toast.tsx new file mode 100644 index 00000000000..d756feb88d6 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -0,0 +1,52 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { ArrowRightIcon } from "lucide-react"; + +import { getDesktopUpdateReleaseUrl } from "./desktopUpdate.logic"; +import { toastManager } from "./ui/toast"; + +type DesktopUpdateShell = Pick; + +function ReleaseNotesLink({ + shell, + releaseUrl, +}: { + shell: DesktopUpdateShell; + releaseUrl: string; +}) { + return ( + + ); +} + +export function showDesktopUpdateDownloadedToast( + shell: DesktopUpdateShell, + downloadedVersion: string | null, +): void { + const releaseUrl = getDesktopUpdateReleaseUrl(downloadedVersion); + toastManager.add({ + type: "success", + title: "Update downloaded", + description: ( + <> + Restart the app from the update button to install it. + {releaseUrl ? : null} + + ), + }); +} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 0603a9da085..4b308750370 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -14,6 +14,7 @@ import { shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; +import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -80,11 +81,7 @@ export function SidebarUpdatePill() { .downloadUpdate() .then((result) => { if (result.completed) { - toastManager.add({ - type: "success", - title: "Update downloaded", - description: "Restart the app from the update button to install it.", - }); + showDesktopUpdateDownloadedToast(bridge, result.state.downloadedVersion); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); From 43a67c0bd1faf30cb3cb86d89dd8d37c74465517 Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:52:03 +0200 Subject: [PATCH 2/2] fix(desktop): keep the release link when the downloaded version lands late The main process fills downloadedVersion from the updater's update-downloaded event, which runs on its own fiber, so the download RPC can resolve before that write lands. Resolve the version from the whole updater state and fall back to the version the download was started for. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/Sidebar.tsx | 2 +- .../web/src/components/desktopUpdate.logic.ts | 9 ++++ .../components/desktopUpdate.toast.test.tsx | 50 +++++++++++++++++-- .../src/components/desktopUpdate.toast.tsx | 11 ++-- .../components/sidebar/SidebarUpdatePill.tsx | 2 +- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 912172ab909..052e82718a5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3510,7 +3510,7 @@ export default function Sidebar() { .downloadUpdate() .then((result) => { if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state.downloadedVersion); + showDesktopUpdateDownloadedToast(bridge, result.state); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index f0c8438042d..dc09d7ca877 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -5,6 +5,15 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none"; const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; +/** + * The main process fills `downloadedVersion` from the updater's `update-downloaded` + * event, which is dispatched on its own fiber. A download RPC can therefore resolve + * before that write lands, so fall back to the version the download was started for. + */ +export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null { + return state.downloadedVersion ?? state.availableVersion; +} + /** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */ export function getDesktopUpdateReleaseUrl(version: string | null): string | null { const normalizedVersion = version?.trim(); diff --git a/apps/web/src/components/desktopUpdate.toast.test.tsx b/apps/web/src/components/desktopUpdate.toast.test.tsx index 8797e3c53e2..369a3cdf431 100644 --- a/apps/web/src/components/desktopUpdate.toast.test.tsx +++ b/apps/web/src/components/desktopUpdate.toast.test.tsx @@ -1,5 +1,6 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { DesktopUpdateState } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ addToast: vi.fn(), @@ -37,6 +38,27 @@ function getDescription(): ReactNode { return toast?.description ?? null; } +function downloadedState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "downloaded", + channel: "latest", + currentVersion: "0.0.29", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: "0.0.30", + downloadedVersion: "0.0.30", + releaseNotes: [], + downloadPercent: 100, + checkedAt: null, + message: null, + errorContext: null, + canRetry: true, + ...overrides, + }; +} + describe("showDesktopUpdateDownloadedToast", () => { beforeEach(() => { testState.addToast.mockReset(); @@ -45,7 +67,7 @@ describe("showDesktopUpdateDownloadedToast", () => { it("opens the downloaded version's release notes", async () => { const openExternal = vi.fn().mockResolvedValue(true); - showDesktopUpdateDownloadedToast({ openExternal }, "0.0.30"); + showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); const link = findReleaseNotesLink(getDescription()); link?.props.onClick?.(); await vi.waitFor(() => { @@ -56,8 +78,28 @@ describe("showDesktopUpdateDownloadedToast", () => { expect(testState.addToast).toHaveBeenCalledTimes(1); }); - it("omits the link when the updater does not report a version", () => { - showDesktopUpdateDownloadedToast({ openExternal: vi.fn() }, null); + it("falls back to the version the download was started for", async () => { + const openExternal = vi.fn().mockResolvedValue(true); + + // The `update-downloaded` event can land after the download RPC resolves. + showDesktopUpdateDownloadedToast( + { openExternal }, + downloadedState({ downloadedVersion: null }), + ); + findReleaseNotesLink(getDescription())?.props.onClick?.(); + + await vi.waitFor(() => { + expect(openExternal).toHaveBeenCalledWith( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + }); + + it("omits the link when the updater reports no version at all", () => { + showDesktopUpdateDownloadedToast( + { openExternal: vi.fn() }, + downloadedState({ availableVersion: null, downloadedVersion: null }), + ); expect(findReleaseNotesLink(getDescription())).toBeNull(); }); @@ -66,7 +108,7 @@ describe("showDesktopUpdateDownloadedToast", () => { ["returns false", vi.fn().mockResolvedValue(false)], ["rejects", vi.fn().mockRejectedValue(new Error("open failed"))], ])("shows an error when opening release notes %s", async (_description, openExternal) => { - showDesktopUpdateDownloadedToast({ openExternal }, "0.0.30"); + showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); findReleaseNotesLink(getDescription())?.props.onClick?.(); await vi.waitFor(() => { diff --git a/apps/web/src/components/desktopUpdate.toast.tsx b/apps/web/src/components/desktopUpdate.toast.tsx index d756feb88d6..004a76a81cd 100644 --- a/apps/web/src/components/desktopUpdate.toast.tsx +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -1,7 +1,10 @@ -import type { DesktopBridge } from "@t3tools/contracts"; +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; import { ArrowRightIcon } from "lucide-react"; -import { getDesktopUpdateReleaseUrl } from "./desktopUpdate.logic"; +import { + getDesktopUpdateDownloadedVersion, + getDesktopUpdateReleaseUrl, +} from "./desktopUpdate.logic"; import { toastManager } from "./ui/toast"; type DesktopUpdateShell = Pick; @@ -36,9 +39,9 @@ function ReleaseNotesLink({ export function showDesktopUpdateDownloadedToast( shell: DesktopUpdateShell, - downloadedVersion: string | null, + state: DesktopUpdateState, ): void { - const releaseUrl = getDesktopUpdateReleaseUrl(downloadedVersion); + const releaseUrl = getDesktopUpdateReleaseUrl(getDesktopUpdateDownloadedVersion(state)); toastManager.add({ type: "success", title: "Update downloaded", diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 4b308750370..fb95dc11a4c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -81,7 +81,7 @@ export function SidebarUpdatePill() { .downloadUpdate() .then((result) => { if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state.downloadedVersion); + showDesktopUpdateDownloadedToast(bridge, result.state); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result);