diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98b5dcf84ed..052e82718a5 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); } 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..dc09d7ca877 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils"; 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(); + 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..369a3cdf431 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.toast.test.tsx @@ -0,0 +1,121 @@ +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(), +})); + +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; +} + +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(); + }); + + it("opens the downloaded version's release notes", async () => { + const openExternal = vi.fn().mockResolvedValue(true); + + showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); + 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("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(); + }); + + 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 }, downloadedState()); + 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..004a76a81cd --- /dev/null +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -0,0 +1,55 @@ +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import { ArrowRightIcon } from "lucide-react"; + +import { + getDesktopUpdateDownloadedVersion, + 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, + state: DesktopUpdateState, +): void { + const releaseUrl = getDesktopUpdateReleaseUrl(getDesktopUpdateDownloadedVersion(state)); + 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..fb95dc11a4c 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); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result);