authorgravatar for simon.doba@hotmail.deSimon Doba <simon.doba@hotmail.de> 2026-07-31 00:19:34+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 00:19:34+02:00
log41124c726001498bb09e6c1b3eaff71e6ca0b43d
tree74c0e9494d2e9dd09906fee5caf78c3dbaf7b7d9
parentb0b275227bcf495f75bab1daa39f99489420dad7
signature Signed by PGP key B5690EEEBB952194

fix(desktop): link release notes from the update downloaded toast (#4771)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

6 files changed, 216 insertions(+), 10 deletions(-)

apps/web/src/components/Sidebar.tsx+2-5
......@@ -133,6 +133,7 @@ import {
133133 shouldShowArm64IntelBuildWarning,
134134 shouldToastDesktopUpdateActionResult,
135135} from "./desktopUpdate.logic";
136import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
136137import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
137138import { Button } from "./ui/button";
138139import {
......@@ -3509,11 +3510,7 @@ export default function Sidebar() {
35093510 .downloadUpdate()
35103511 .then((result) => {
35113512 if (result.completed) {
3512 toastManager.add({
3513 type: "success",
3514 title: "Update downloaded",
3515 description: "Restart the app from the update button to install it.",
3516 });
3513 showDesktopUpdateDownloadedToast(bridge, result.state);
35173514 }
35183515 if (!shouldToastDesktopUpdateActionResult(result)) return;
35193516 const actionError = getDesktopUpdateActionError(result);
apps/web/src/components/desktopUpdate.logic.test.ts+18
......@@ -7,6 +7,7 @@ import {
77 getDesktopUpdateActionError,
88 getDesktopUpdateButtonTooltip,
99 getDesktopUpdateInstallConfirmationMessage,
10 getDesktopUpdateReleaseUrl,
1011 isDesktopUpdateButtonDisabled,
1112 resolveDesktopUpdateButtonAction,
1213 shouldShowArm64IntelBuildWarning,
......@@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => {
158159});
159160
160161describe("desktop update UI helpers", () => {
162 it("builds the stable release URL for a downloaded version", () => {
163 expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe(
164 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
165 );
166 });
167
168 it("builds the nightly release URL without dropping its version suffix", () => {
169 expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe(
170 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931",
171 );
172 });
173
174 it("omits the release URL when the updater does not report a version", () => {
175 expect(getDesktopUpdateReleaseUrl(null)).toBeNull();
176 expect(getDesktopUpdateReleaseUrl(" ")).toBeNull();
177 });
178
161179 it("toasts only for actionable updater errors", () => {
162180 expect(
163181 shouldToastDesktopUpdateActionResult({
apps/web/src/components/desktopUpdate.logic.ts+18
......@@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils";
33
44export type DesktopUpdateButtonAction = "download" | "install" | "none";
55
6const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag";
7
8/**
9 * The main process fills `downloadedVersion` from the updater's `update-downloaded`
10 * event, which is dispatched on its own fiber. A download RPC can therefore resolve
11 * before that write lands, so fall back to the version the download was started for.
12 */
13export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null {
14 return state.downloadedVersion ?? state.availableVersion;
15}
16
17/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */
18export function getDesktopUpdateReleaseUrl(version: string | null): string | null {
19 const normalizedVersion = version?.trim();
20 if (!normalizedVersion) return null;
21 return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`;
22}
23
624export function resolveDesktopUpdateButtonAction(
725 state: DesktopUpdateState,
826): DesktopUpdateButtonAction {
apps/web/src/components/desktopUpdate.toast.test.tsx created+121
......@@ -0,0 +1,121 @@
1import { isValidElement, type ReactElement, type ReactNode } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4
5const testState = vi.hoisted(() => ({
6 addToast: vi.fn(),
7}));
8
9vi.mock("./ui/toast", () => ({
10 toastManager: { add: testState.addToast },
11}));
12
13import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
14
15type ClickableElement = ReactElement<{ readonly onClick?: () => void }>;
16
17/** Walks the rendered description, invoking function components, to find the link button. */
18function findReleaseNotesLink(node: ReactNode): ClickableElement | null {
19 if (Array.isArray(node)) {
20 for (const child of node) {
21 const found = findReleaseNotesLink(child);
22 if (found) return found;
23 }
24 return null;
25 }
26 if (!isValidElement(node)) return null;
27 const element = node as ReactElement<{ readonly children?: ReactNode }>;
28 if (element.type === "button") return element as ClickableElement;
29 if (typeof element.type === "function") {
30 const render = element.type as (props: unknown) => ReactNode;
31 return findReleaseNotesLink(render(element.props));
32 }
33 return findReleaseNotesLink(element.props.children);
34}
35
36function getDescription(): ReactNode {
37 const toast = testState.addToast.mock.calls[0]?.[0] as { description?: ReactNode } | undefined;
38 return toast?.description ?? null;
39}
40
41function downloadedState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
42 return {
43 enabled: true,
44 status: "downloaded",
45 channel: "latest",
46 currentVersion: "0.0.29",
47 hostArch: "arm64",
48 appArch: "arm64",
49 runningUnderArm64Translation: false,
50 availableVersion: "0.0.30",
51 downloadedVersion: "0.0.30",
52 releaseNotes: [],
53 downloadPercent: 100,
54 checkedAt: null,
55 message: null,
56 errorContext: null,
57 canRetry: true,
58 ...overrides,
59 };
60}
61
62describe("showDesktopUpdateDownloadedToast", () => {
63 beforeEach(() => {
64 testState.addToast.mockReset();
65 });
66
67 it("opens the downloaded version's release notes", async () => {
68 const openExternal = vi.fn().mockResolvedValue(true);
69
70 showDesktopUpdateDownloadedToast({ openExternal }, downloadedState());
71 const link = findReleaseNotesLink(getDescription());
72 link?.props.onClick?.();
73 await vi.waitFor(() => {
74 expect(openExternal).toHaveBeenCalledWith(
75 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
76 );
77 });
78 expect(testState.addToast).toHaveBeenCalledTimes(1);
79 });
80
81 it("falls back to the version the download was started for", async () => {
82 const openExternal = vi.fn().mockResolvedValue(true);
83
84 // The `update-downloaded` event can land after the download RPC resolves.
85 showDesktopUpdateDownloadedToast(
86 { openExternal },
87 downloadedState({ downloadedVersion: null }),
88 );
89 findReleaseNotesLink(getDescription())?.props.onClick?.();
90
91 await vi.waitFor(() => {
92 expect(openExternal).toHaveBeenCalledWith(
93 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
94 );
95 });
96 });
97
98 it("omits the link when the updater reports no version at all", () => {
99 showDesktopUpdateDownloadedToast(
100 { openExternal: vi.fn() },
101 downloadedState({ availableVersion: null, downloadedVersion: null }),
102 );
103
104 expect(findReleaseNotesLink(getDescription())).toBeNull();
105 });
106
107 it.each([
108 ["returns false", vi.fn().mockResolvedValue(false)],
109 ["rejects", vi.fn().mockRejectedValue(new Error("open failed"))],
110 ])("shows an error when opening release notes %s", async (_description, openExternal) => {
111 showDesktopUpdateDownloadedToast({ openExternal }, downloadedState());
112 findReleaseNotesLink(getDescription())?.props.onClick?.();
113
114 await vi.waitFor(() => {
115 expect(testState.addToast).toHaveBeenLastCalledWith({
116 type: "error",
117 title: "Unable to open release notes",
118 });
119 });
120 });
121});
apps/web/src/components/desktopUpdate.toast.tsx created+55
......@@ -0,0 +1,55 @@
1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2import { ArrowRightIcon } from "lucide-react";
3
4import {
5 getDesktopUpdateDownloadedVersion,
6 getDesktopUpdateReleaseUrl,
7} from "./desktopUpdate.logic";
8import { toastManager } from "./ui/toast";
9
10type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">;
11
12function ReleaseNotesLink({
13 shell,
14 releaseUrl,
15}: {
16 shell: DesktopUpdateShell;
17 releaseUrl: string;
18}) {
19 return (
20 <button
21 className="ml-2 inline-flex cursor-pointer items-center gap-1 align-baseline text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground"
22 onClick={() => {
23 void (async () => {
24 try {
25 if (await shell.openExternal(releaseUrl)) return;
26 } catch {
27 // Surface rejected IPC calls through the same user-visible fallback.
28 }
29 toastManager.add({ type: "error", title: "Unable to open release notes" });
30 })();
31 }}
32 type="button"
33 >
34 Read more
35 <ArrowRightIcon aria-hidden className="size-3 -rotate-45" strokeWidth={2.25} />
36 </button>
37 );
38}
39
40export function showDesktopUpdateDownloadedToast(
41 shell: DesktopUpdateShell,
42 state: DesktopUpdateState,
43): void {
44 const releaseUrl = getDesktopUpdateReleaseUrl(getDesktopUpdateDownloadedVersion(state));
45 toastManager.add({
46 type: "success",
47 title: "Update downloaded",
48 description: (
49 <>
50 Restart the app from the update button to install it.
51 {releaseUrl ? <ReleaseNotesLink releaseUrl={releaseUrl} shell={shell} /> : null}
52 </>
53 ),
54 });
55}
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+2-5
......@@ -14,6 +14,7 @@ import {
1414 shouldShowDesktopUpdateButton,
1515 shouldToastDesktopUpdateActionResult,
1616} from "../desktopUpdate.logic";
17import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
1718import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
1819import { Separator } from "../ui/separator";
1920import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -80,11 +81,7 @@ export function SidebarUpdatePill() {
8081 .downloadUpdate()
8182 .then((result) => {
8283 if (result.completed) {
83 toastManager.add({
84 type: "success",
85 title: "Update downloaded",
86 description: "Restart the app from the update button to install it.",
87 });
84 showDesktopUpdateDownloadedToast(bridge, result.state);
8885 }
8986 if (!shouldToastDesktopUpdateActionResult(result)) return;
9087 const actionError = getDesktopUpdateActionError(result);