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 {...@@ -133,6 +133,7 @@ import {
133 shouldShowArm64IntelBuildWarning,133 shouldShowArm64IntelBuildWarning,
134 shouldToastDesktopUpdateActionResult,134 shouldToastDesktopUpdateActionResult,
135} from "./desktopUpdate.logic";135} from "./desktopUpdate.logic";
136import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
136import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";137import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
137import { Button } from "./ui/button";138import { Button } from "./ui/button";
138import {139import {
...@@ -3509,11 +3510,7 @@ export default function Sidebar() {...@@ -3509,11 +3510,7 @@ export default function Sidebar() {
3509 .downloadUpdate()3510 .downloadUpdate()
3510 .then((result) => {3511 .then((result) => {
3511 if (result.completed) {3512 if (result.completed) {
3512 toastManager.add({3513 showDesktopUpdateDownloadedToast(bridge, result.state);
3513 type: "success",
3514 title: "Update downloaded",
3515 description: "Restart the app from the update button to install it.",
3516 });
3517 }3514 }
3518 if (!shouldToastDesktopUpdateActionResult(result)) return;3515 if (!shouldToastDesktopUpdateActionResult(result)) return;
3519 const actionError = getDesktopUpdateActionError(result);3516 const actionError = getDesktopUpdateActionError(result);
apps/web/src/components/desktopUpdate.logic.test.ts+18
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 getDesktopUpdateActionError,7 getDesktopUpdateActionError,
8 getDesktopUpdateButtonTooltip,8 getDesktopUpdateButtonTooltip,
9 getDesktopUpdateInstallConfirmationMessage,9 getDesktopUpdateInstallConfirmationMessage,
10 getDesktopUpdateReleaseUrl,
10 isDesktopUpdateButtonDisabled,11 isDesktopUpdateButtonDisabled,
11 resolveDesktopUpdateButtonAction,12 resolveDesktopUpdateButtonAction,
12 shouldShowArm64IntelBuildWarning,13 shouldShowArm64IntelBuildWarning,
...@@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => {...@@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => {
158});159});
159160
160describe("desktop update UI helpers", () => {161describe("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
161 it("toasts only for actionable updater errors", () => {179 it("toasts only for actionable updater errors", () => {
162 expect(180 expect(
163 shouldToastDesktopUpdateActionResult({181 shouldToastDesktopUpdateActionResult({
apps/web/src/components/desktopUpdate.logic.ts+18
...@@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils";...@@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils";
33
4export type DesktopUpdateButtonAction = "download" | "install" | "none";4export 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
6export function resolveDesktopUpdateButtonAction(24export function resolveDesktopUpdateButtonAction(
7 state: DesktopUpdateState,25 state: DesktopUpdateState,
8): DesktopUpdateButtonAction {26): 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 {...@@ -14,6 +14,7 @@ import {
14 shouldShowDesktopUpdateButton,14 shouldShowDesktopUpdateButton,
15 shouldToastDesktopUpdateActionResult,15 shouldToastDesktopUpdateActionResult,
16} from "../desktopUpdate.logic";16} from "../desktopUpdate.logic";
17import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
17import { Alert, AlertDescription, AlertTitle } from "../ui/alert";18import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
18import { Separator } from "../ui/separator";19import { Separator } from "../ui/separator";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -80,11 +81,7 @@ export function SidebarUpdatePill() {...@@ -80,11 +81,7 @@ export function SidebarUpdatePill() {
80 .downloadUpdate()81 .downloadUpdate()
81 .then((result) => {82 .then((result) => {
82 if (result.completed) {83 if (result.completed) {
83 toastManager.add({84 showDesktopUpdateDownloadedToast(bridge, result.state);
84 type: "success",
85 title: "Update downloaded",
86 description: "Restart the app from the update button to install it.",
87 });
88 }85 }
89 if (!shouldToastDesktopUpdateActionResult(result)) return;86 if (!shouldToastDesktopUpdateActionResult(result)) return;
90 const actionError = getDesktopUpdateActionError(result);87 const actionError = getDesktopUpdateActionError(result);