authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 18:17:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:17:59-07:00
log5014e5fcdd6e5f4a53d0fea4cbecba733821a854
treea76dc27e13b7e3f73e08953253d725e5bb9f3576
parentfc53b273039cf5e0a932582b1fbb56e7223ad4ab
signature Signed by PGP key B5690EEEBB952194

fix(desktop): show newest changes in nightly previews (#9138)


17 files changed, 804 insertions(+), 181 deletions(-)

apps/desktop/src/updates/DesktopUpdates.test.ts+23-3
......@@ -336,6 +336,11 @@ describe("DesktopUpdates", () => {
336336 version: "1.2.4-nightly.20260709.765",
337337 note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821",
338338 },
339 { version: "1.2.4-nightly.20260709.764", note: "- Change 764" },
340 { version: "1.2.4-nightly.20260709.763", note: "- Change 763" },
341 { version: "1.2.4-nightly.20260709.762", note: "- Change 762" },
342 { version: "1.2.4-nightly.20260709.761", note: "- Change 761" },
343 { version: "1.2.4-nightly.20260709.760", note: "- Change 760" },
339344 ],
340345 });
341346 yield* flushCallbacks;
......@@ -346,13 +351,21 @@ describe("DesktopUpdates", () => {
346351 {
347352 version: "1.2.4-nightly.20260709.766",
348353 items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"],
354 totalItems: 1,
349355 },
350356 {
351357 version: "1.2.4-nightly.20260709.765",
352358 items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"],
359 totalItems: 1,
353360 },
361 { version: "1.2.4-nightly.20260709.764", items: ["Change 764"], totalItems: 1 },
362 { version: "1.2.4-nightly.20260709.763", items: ["Change 763"], totalItems: 1 },
363 { version: "1.2.4-nightly.20260709.762", items: ["Change 762"], totalItems: 1 },
364 { version: "1.2.4-nightly.20260709.761", items: ["Change 761"], totalItems: 1 },
354365 ]);
366 assert.equal(state.omittedReleaseCount, 1);
355367 assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes);
368 assert.equal(harness.sentStates.at(-1)?.omittedReleaseCount, 1);
356369 }),
357370 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
358371 });
......@@ -383,8 +396,9 @@ describe("DesktopUpdates", () => {
383396 assert.equal(unchangedState.status, "downloaded");
384397 assert.equal(unchangedState.downloadedVersion, "1.2.4");
385398 assert.deepEqual(unchangedState.releaseNotes, [
386 { version: "1.2.4", items: ["fix: queued update"] },
399 { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 },
387400 ]);
401 assert.equal(unchangedState.omittedReleaseCount, 0);
388402
389403 const nextResult = yield* updates.check("poll");
390404 assert.isTrue(nextResult.checked);
......@@ -424,7 +438,10 @@ describe("DesktopUpdates", () => {
424438 assert.equal(state.status, "downloaded");
425439 assert.equal(state.availableVersion, "1.2.4");
426440 assert.equal(state.downloadedVersion, "1.2.4");
427 assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]);
441 assert.deepEqual(state.releaseNotes, [
442 { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 },
443 ]);
444 assert.equal(state.omittedReleaseCount, 0);
428445 assert.equal(state.downloadPercent, 100);
429446 }),
430447 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
......@@ -454,7 +471,10 @@ describe("DesktopUpdates", () => {
454471 assert.equal(state.status, "downloaded");
455472 assert.equal(state.availableVersion, "1.2.4");
456473 assert.equal(state.downloadedVersion, "1.2.4");
457 assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]);
474 assert.deepEqual(state.releaseNotes, [
475 { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 },
476 ]);
477 assert.equal(state.omittedReleaseCount, 0);
458478 assert.equal(state.downloadPercent, 100);
459479 }),
460480 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
apps/desktop/src/updates/DesktopUpdates.ts+12-2
......@@ -593,14 +593,24 @@ export const make = Effect.gen(function* () {
593593 }
594594
595595 const checkedAt = yield* currentIsoTimestamp;
596 const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version);
596 const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(
597 info.releaseNotes,
598 info.version,
599 );
597600 yield* setState(
598 reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes),
601 reduceDesktopUpdateStateOnUpdateAvailable(
602 state,
603 info.version,
604 checkedAt,
605 releaseNotes,
606 omittedReleaseCount,
607 ),
599608 );
600609 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
601610 yield* logUpdaterInfo("update available", {
602611 version: info.version,
603612 releaseNoteGroups: releaseNotes.length,
613 omittedReleaseCount,
604614 });
605615 }),
606616 ),
apps/desktop/src/updates/releaseNotes.test.ts+129-28
......@@ -3,38 +3,137 @@ import { describe, expect, it } from "vite-plus/test";
33import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";
44
55describe("normalizeDesktopUpdateReleaseNotes", () => {
6 it("splits a plain string note into items under the fallback version", () => {
7 const notes = normalizeDesktopUpdateReleaseNotes(
8 "## What's changed\n- First fix\n- Second fix",
6 it("shows the newest changes and counts all real changes", () => {
7 const result = normalizeDesktopUpdateReleaseNotes(
8 [
9 "- feat: first change",
10 "- fix: second change",
11 "- fix: third change",
12 "- fix: fourth change",
13 "- fix: fifth change",
14 "- fix: sixth change",
15 "- fix: seventh change",
16 "- fix: eighth change",
17 "- fix(web): keep long task drawers usable on small screens by @human in #8313",
18 "- fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480",
19 "## New Contributors",
20 "- @human made their first contribution in #8435",
21 "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new",
22 ].join("\n"),
23 "0.0.36-nightly.20260828.1213",
24 );
25
26 expect(result).toEqual({
27 releaseNotes: [
28 {
29 version: "0.0.36-nightly.20260828.1213",
30 items: [
31 "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480",
32 "fix(web): keep long task drawers usable on small screens by @human in #8313",
33 "fix: eighth change",
34 "fix: seventh change",
35 "fix: sixth change",
36 "fix: fifth change",
37 "fix: fourth change",
38 "fix: third change",
39 ],
40 totalItems: 10,
41 },
42 ],
43 omittedReleaseCount: 0,
44 });
45 });
46
47 it("excludes a GitHub HTML contributor section", () => {
48 const result = normalizeDesktopUpdateReleaseNotes(
49 "<h2>What's Changed</h2><ul><li>Older fix</li><li>Newer fix</li></ul>" +
50 "<h2>New Contributors</h2><ul><li>@human made their first contribution</li></ul>" +
51 "<h2>Full Changelog</h2>",
952 "1.2.3",
1053 );
11 expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]);
54
55 expect(result).toEqual({
56 releaseNotes: [{ version: "1.2.3", items: ["Newer fix", "Older fix"], totalItems: 2 }],
57 omittedReleaseCount: 0,
58 });
59 });
60
61 it("does not count Markdown or HTML section headings as changes", () => {
62 const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`);
63 const result = normalizeDesktopUpdateReleaseNotes(
64 [
65 { version: "1.2.4", note: ["### Features", ...changes].join("\n- ") },
66 {
67 version: "1.2.3",
68 note: `<h3>Fixes</h3><ul>${changes.map((change) => `<li>${change}</li>`).join("")}</ul>`,
69 },
70 ],
71 "1.2.4",
72 );
73
74 expect(result.releaseNotes).toEqual([
75 { version: "1.2.4", items: changes.toReversed(), totalItems: 8 },
76 { version: "1.2.3", items: changes.toReversed(), totalItems: 8 },
77 ]);
1278 });
1379
14 it("keeps per-version groups and drops empty ones", () => {
15 const notes = normalizeDesktopUpdateReleaseNotes(
80 it("keeps per-version order and drops empty groups", () => {
81 const result = normalizeDesktopUpdateReleaseNotes(
1682 [
17 { version: "1.2.3", note: "- Newer change" },
83 { version: "1.2.3", note: "- Newer release" },
1884 { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" },
19 { version: "1.2.1", note: "- Older change" },
85 { version: "1.2.1", note: "- Older release" },
2086 ],
2187 "1.2.3",
2288 );
23 expect(notes).toEqual([
24 { version: "1.2.3", items: ["Newer change"] },
25 { version: "1.2.1", items: ["Older change"] },
89
90 expect(result).toEqual({
91 releaseNotes: [
92 { version: "1.2.3", items: ["Newer release"], totalItems: 1 },
93 { version: "1.2.1", items: ["Older release"], totalItems: 1 },
94 ],
95 omittedReleaseCount: 0,
96 });
97 });
98
99 it("counts valid groups before applying the six-release limit", () => {
100 const releaseNotes = [
101 { version: "1.3.9", note: "- Change 9" },
102 { version: "1.3.8", note: "Full changelog: https://example.com/compare/x...y" },
103 { version: "1.3.7", note: "- Change 7" },
104 { version: "1.3.6", note: "- Change 6" },
105 { version: "1.3.5", note: "- Change 5" },
106 { version: "1.3.4", note: "- Change 4" },
107 { version: "1.3.3", note: "- Change 3" },
108 { version: "1.3.2", note: "- Change 2" },
109 ];
110
111 const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9");
112
113 expect(result.releaseNotes.map(({ version }) => version)).toEqual([
114 "1.3.9",
115 "1.3.7",
116 "1.3.6",
117 "1.3.5",
118 "1.3.4",
119 "1.3.3",
26120 ]);
121 expect(result.omittedReleaseCount).toBe(1);
27122 });
28123
29124 it("decodes valid HTML entities", () => {
30 const notes = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0");
31 expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]);
125 const result = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0");
126 expect(result).toEqual({
127 releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }],
128 omittedReleaseCount: 0,
129 });
32130 });
33131
34 it("ignores malformed entries instead of throwing", () => {
35 const notes = normalizeDesktopUpdateReleaseNotes(
132 it("ignores malformed and empty entries instead of throwing", () => {
133 const result = normalizeDesktopUpdateReleaseNotes(
36134 [
37135 { version: "1.2.3", note: "- Valid change" },
136 { version: "1.2.2", note: "" },
38137 { version: 42, note: "- Bad version type" },
39138 { version: "1.2.1", note: { html: "<p>object note</p>" } },
40139 "not an object",
......@@ -42,23 +141,25 @@ describe("normalizeDesktopUpdateReleaseNotes", () => {
42141 ],
43142 "1.2.3",
44143 );
45 expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]);
144
145 expect(result).toEqual({
146 releaseNotes: [{ version: "1.2.3", items: ["Valid change"], totalItems: 1 }],
147 omittedReleaseCount: 0,
148 });
46149 });
47150
48 it("returns non-empty groups even when preceded by many boilerplate-only groups", () => {
49 const boilerplate = Array.from({ length: 7 }, (_, index) => ({
50 version: `1.3.${9 - index}`,
51 note: "Full changelog: https://example.com/compare/x...y",
52 }));
53 const notes = normalizeDesktopUpdateReleaseNotes(
54 [...boilerplate, { version: "1.3.2", note: "- Older but real change" }],
55 "1.3.9",
56 );
57 expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]);
151 it("returns an empty result for an invalid payload", () => {
152 expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({
153 releaseNotes: [],
154 omittedReleaseCount: 0,
155 });
58156 });
59157
60158 it("does not throw on out-of-range numeric entities and keeps the literal", () => {
61 const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0");
62 expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity &#9999999999;"] }]);
159 const result = normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0");
160 expect(result).toEqual({
161 releaseNotes: [{ version: "1.0.0", items: ["Broken entity &#9999999999;"], totalItems: 1 }],
162 omittedReleaseCount: 0,
163 });
63164 });
64165});
apps/desktop/src/updates/releaseNotes.ts+43-16
......@@ -59,6 +59,7 @@ function stripMarkup(input: string): string {
5959 input
6060 .replace(/<br\s*\/?>/gi, "\n")
6161 .replace(/<li\b[^>]*>/gi, "\n- ")
62 .replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `)
6263 .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n")
6364 .replace(/<[^>]*>/g, "")
6465 .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
......@@ -71,43 +72,60 @@ function truncateReleaseNoteItem(item: string): string {
7172 return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`;
7273}
7374
74function isIgnoredReleaseNoteLine(line: string): boolean {
75 const normalized = line
75function normalizeReleaseNoteLine(line: string): string {
76 return line
7677 .toLowerCase()
7778 .replace(/[*_`#]/g, "")
7879 .trim();
80}
81
82function isIgnoredReleaseNoteLine(line: string): boolean {
83 const normalized = normalizeReleaseNoteLine(line);
7984 return (
8085 normalized === "" ||
8186 normalized === "what's changed" ||
8287 normalized === "whats changed" ||
83 normalized === "full changelog" ||
84 normalized === "new contributors" ||
8588 normalized.startsWith("compare: ") ||
8689 normalized.includes("/compare/")
8790 );
8891}
8992
90function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray<string> {
91 if (!note) return [];
93interface ExtractedReleaseNoteItems {
94 readonly items: ReadonlyArray<string>;
95 readonly totalItems: number;
96}
97
98function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems {
99 if (!note) return { items: [], totalItems: 0 };
92100
93101 const items: string[] = [];
102 let totalItems = 0;
94103 for (const rawLine of stripMarkup(note).split("\n")) {
95104 const item = rawLine
96105 .trim()
97106 .replace(/^[-*]\s+/, "")
98107 .replace(/^\d+[.)]\s+/, "")
99108 .replace(/\s+/g, " ");
109 const normalized = normalizeReleaseNoteLine(item);
110 if (normalized === "new contributors" || normalized === "full changelog") break;
111 if (/^#{1,6}\s+/.test(item)) continue;
100112 if (isIgnoredReleaseNoteLine(item)) continue;
113 totalItems += 1;
101114 items.push(truncateReleaseNoteItem(item));
102 if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break;
115 if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift();
103116 }
104 return items;
117 return { items: items.toReversed(), totalItems };
118}
119
120interface NormalizedDesktopUpdateReleaseNotes {
121 readonly releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>;
122 readonly omittedReleaseCount: number;
105123}
106124
107125export function normalizeDesktopUpdateReleaseNotes(
108126 releaseNotes: unknown,
109127 fallbackVersion: string,
110): ReadonlyArray<DesktopUpdateReleaseNote> {
128): NormalizedDesktopUpdateReleaseNotes {
111129 const rawNotes =
112130 typeof releaseNotes === "string"
113131 ? [{ version: fallbackVersion, note: releaseNotes }]
......@@ -115,11 +133,20 @@ export function normalizeDesktopUpdateReleaseNotes(
115133 ? releaseNotes.filter(isElectronReleaseNoteInfo)
116134 : [];
117135
118 return rawNotes
119 .map((entry) => ({
120 version: entry.version,
121 items: extractReleaseNoteItems(entry.note),
122 }))
123 .filter((entry) => entry.items.length > 0)
124 .slice(0, MAX_RELEASE_NOTE_GROUPS);
136 const normalizedNotes = rawNotes.flatMap((entry) => {
137 const { items, totalItems } = extractReleaseNoteItems(entry.note);
138 if (totalItems === 0) return [];
139 return [
140 {
141 version: entry.version,
142 items,
143 totalItems,
144 },
145 ];
146 });
147
148 return {
149 releaseNotes: normalizedNotes.slice(0, MAX_RELEASE_NOTE_GROUPS),
150 omittedReleaseCount: Math.max(0, normalizedNotes.length - MAX_RELEASE_NOTE_GROUPS),
151 };
125152}
apps/desktop/src/updates/updateMachine.test.ts+19-5
......@@ -62,7 +62,8 @@ describe("updateMachine", () => {
6262 status: "downloaded" as const,
6363 availableVersion: "1.1.0",
6464 downloadedVersion: "1.1.0",
65 releaseNotes: [{ version: "1.1.0", items: ["fix: queued update"] }],
65 releaseNotes: [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }],
66 omittedReleaseCount: 2,
6667 downloadPercent: 100,
6768 };
6869 const checking = reduceDesktopUpdateStateOnCheckStart(
......@@ -78,14 +79,16 @@ describe("updateMachine", () => {
7879 expect(checking.status).toBe("checking");
7980 expect(checking.downloadedVersion).toBe("1.1.0");
8081 expect(checking.releaseNotes).toEqual(downloadedState.releaseNotes);
82 expect(checking.omittedReleaseCount).toBe(2);
8183 expect(failed.status).toBe("downloaded");
8284 expect(failed.downloadedVersion).toBe("1.1.0");
8385 expect(failed.releaseNotes).toEqual(downloadedState.releaseNotes);
86 expect(failed.omittedReleaseCount).toBe(2);
8487 expect(failed.message).toBeNull();
8588 });
8689
8790 it("keeps the installer when the feed still offers its version", () => {
88 const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }];
91 const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }];
8992 const state = reduceDesktopUpdateStateOnUpdateAvailable(
9093 {
9194 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
......@@ -94,6 +97,7 @@ describe("updateMachine", () => {
9497 availableVersion: "1.1.0",
9598 downloadedVersion: "1.1.0",
9699 releaseNotes,
100 omittedReleaseCount: 2,
97101 downloadPercent: 100,
98102 },
99103 "1.1.0",
......@@ -103,6 +107,7 @@ describe("updateMachine", () => {
103107 expect(state.status).toBe("downloaded");
104108 expect(state.downloadedVersion).toBe("1.1.0");
105109 expect(state.releaseNotes).toEqual(releaseNotes);
110 expect(state.omittedReleaseCount).toBe(2);
106111 expect(state.downloadPercent).toBe(100);
107112 });
108113
......@@ -147,7 +152,7 @@ describe("updateMachine", () => {
147152 });
148153
149154 it("preserves a downloaded update when no update is available", () => {
150 const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }];
155 const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }];
151156 const state = reduceDesktopUpdateStateOnNoUpdate(
152157 {
153158 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
......@@ -156,6 +161,7 @@ describe("updateMachine", () => {
156161 availableVersion: "1.1.0",
157162 downloadedVersion: "1.1.0",
158163 releaseNotes,
164 omittedReleaseCount: 2,
159165 message: "old failure",
160166 errorContext: "download",
161167 canRetry: true,
......@@ -167,6 +173,7 @@ describe("updateMachine", () => {
167173 expect(state.availableVersion).toBe("1.1.0");
168174 expect(state.downloadedVersion).toBe("1.1.0");
169175 expect(state.releaseNotes).toBe(releaseNotes);
176 expect(state.omittedReleaseCount).toBe(2);
170177 expect(state.downloadPercent).toBe(100);
171178 expect(state.message).toBeNull();
172179 expect(state.errorContext).toBeNull();
......@@ -180,7 +187,8 @@ describe("updateMachine", () => {
180187 enabled: true,
181188 status: "error",
182189 availableVersion: "1.1.0",
183 releaseNotes: [{ version: "1.1.0", items: ["fix: stale update"] }],
190 releaseNotes: [{ version: "1.1.0", items: ["fix: stale update"], totalItems: 1 }],
191 omittedReleaseCount: 2,
184192 message: "old failure",
185193 errorContext: "download",
186194 canRetry: true,
......@@ -192,6 +200,7 @@ describe("updateMachine", () => {
192200 expect(state.availableVersion).toBeNull();
193201 expect(state.downloadedVersion).toBeNull();
194202 expect(state.releaseNotes).toEqual([]);
203 expect(state.omittedReleaseCount).toBe(0);
195204 expect(state.message).toBeNull();
196205 expect(state.errorContext).toBeNull();
197206 });
......@@ -201,6 +210,7 @@ describe("updateMachine", () => {
201210 {
202211 version: "1.1.0",
203212 items: ["feat: add update release notes"],
213 totalItems: 1,
204214 },
205215 ];
206216 const available = reduceDesktopUpdateStateOnUpdateAvailable(
......@@ -212,6 +222,7 @@ describe("updateMachine", () => {
212222 "1.1.0",
213223 "2026-03-04T00:00:00.000Z",
214224 releaseNotes,
225 2,
215226 );
216227 const downloading = reduceDesktopUpdateStateOnDownloadStart(available);
217228 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
......@@ -219,6 +230,7 @@ describe("updateMachine", () => {
219230 expect(available.status).toBe("available");
220231 expect(available.channel).toBe("latest");
221232 expect(available.releaseNotes).toBe(releaseNotes);
233 expect(available.omittedReleaseCount).toBe(2);
222234 expect(downloading.releaseNotes).toBe(releaseNotes);
223235 expect(downloading.status).toBe("downloading");
224236 expect(downloading.downloadPercent).toBe(0);
......@@ -233,11 +245,13 @@ describe("updateMachine", () => {
233245 enabled: true,
234246 status: "available",
235247 availableVersion: "1.1.0-nightly.1",
236 releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }],
248 releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"], totalItems: 1 }],
249 omittedReleaseCount: 2,
237250 },
238251 "2026-03-04T00:00:00.000Z",
239252 );
240253
241254 expect(state.releaseNotes).toEqual([]);
255 expect(state.omittedReleaseCount).toBe(0);
242256 });
243257});
apps/desktop/src/updates/updateMachine.ts+7-3
......@@ -31,6 +31,7 @@ export function createInitialDesktopUpdateState(
3131 availableVersion: null,
3232 downloadedVersion: null,
3333 releaseNotes: [],
34 omittedReleaseCount: 0,
3435 downloadPercent: null,
3536 checkedAt: null,
3637 message: null,
......@@ -49,6 +50,7 @@ export function reduceDesktopUpdateStateOnCheckStart(
4950 status: "checking",
5051 checkedAt,
5152 releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [],
53 omittedReleaseCount: hasDownloadedUpdate ? state.omittedReleaseCount : 0,
5254 message: null,
5355 downloadPercent: hasDownloadedUpdate ? 100 : null,
5456 errorContext: null,
......@@ -89,16 +91,17 @@ export function reduceDesktopUpdateStateOnUpdateAvailable(
8991 version: string,
9092 checkedAt: string,
9193 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [],
94 omittedReleaseCount = 0,
9295): DesktopUpdateState {
9396 const isDownloadedVersion = state.downloadedVersion === version;
94 const nextReleaseNotes =
95 isDownloadedVersion && releaseNotes.length === 0 ? state.releaseNotes : releaseNotes;
97 const preserveReleaseNotes = isDownloadedVersion && releaseNotes.length === 0;
9698 return {
9799 ...state,
98100 status: isDownloadedVersion ? "downloaded" : "available",
99101 availableVersion: version,
100102 downloadedVersion: isDownloadedVersion ? version : null,
101 releaseNotes: nextReleaseNotes,
103 releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes,
104 omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount,
102105 downloadPercent: isDownloadedVersion ? 100 : null,
103106 checkedAt,
104107 message: null,
......@@ -130,6 +133,7 @@ export function reduceDesktopUpdateStateOnNoUpdate(
130133 availableVersion: null,
131134 downloadedVersion: null,
132135 releaseNotes: [],
136 omittedReleaseCount: 0,
133137 downloadPercent: null,
134138 checkedAt,
135139 message: null,
apps/web/src/components/desktopUpdate.logic.test.ts+8
......@@ -7,6 +7,7 @@ import {
77 getDesktopUpdateActionError,
88 getDesktopUpdateButtonTooltip,
99 getDesktopUpdateInstallConfirmationMessage,
10 getDesktopUpdateReleaseHistoryUrl,
1011 getDesktopUpdateReleaseUrl,
1112 isDesktopUpdateButtonDisabled,
1213 resolveDesktopUpdateButtonAction,
......@@ -26,6 +27,7 @@ const baseState: DesktopUpdateState = {
2627 availableVersion: null,
2728 downloadedVersion: null,
2829 releaseNotes: [],
30 omittedReleaseCount: 0,
2931 downloadPercent: null,
3032 checkedAt: null,
3133 message: null,
......@@ -202,6 +204,12 @@ describe("desktop update UI helpers", () => {
202204 expect(getDesktopUpdateReleaseUrl(" ")).toBeNull();
203205 });
204206
207 it("builds the release history URL", () => {
208 expect(getDesktopUpdateReleaseHistoryUrl()).toBe(
209 "https://github.com/pingdotgg/t3code/releases",
210 );
211 });
212
205213 it("toasts only for actionable updater errors", () => {
206214 expect(
207215 shouldToastDesktopUpdateActionResult({
apps/web/src/components/desktopUpdate.logic.ts+6-1
......@@ -2,7 +2,8 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con
22
33export type DesktopUpdateButtonAction = "download" | "install" | "none";
44
5const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag";
5const DESKTOP_RELEASE_HISTORY_URL = "https://github.com/pingdotgg/t3code/releases";
6const DESKTOP_RELEASE_TAG_URL = `${DESKTOP_RELEASE_HISTORY_URL}/tag`;
67
78/**
89 * The main process fills `downloadedVersion` from the updater's `update-downloaded`
......@@ -20,6 +21,10 @@ export function getDesktopUpdateReleaseUrl(version: string | null): string | nul
2021 return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`;
2122}
2223
24export function getDesktopUpdateReleaseHistoryUrl(): string {
25 return DESKTOP_RELEASE_HISTORY_URL;
26}
27
2328export function resolveDesktopUpdateButtonAction(
2429 state: DesktopUpdateState,
2530): DesktopUpdateButtonAction {
apps/web/src/components/desktopUpdate.toast.test.tsx+1
......@@ -50,6 +50,7 @@ function downloadedState(overrides: Partial<DesktopUpdateState> = {}): DesktopUp
5050 availableVersion: "0.0.30",
5151 downloadedVersion: "0.0.30",
5252 releaseNotes: [],
53 omittedReleaseCount: 0,
5354 downloadPercent: 100,
5455 checkedAt: null,
5556 message: null,
apps/web/src/components/desktopUpdate.toast.tsx+13-8
......@@ -9,6 +9,18 @@ import { toastManager } from "./ui/toast";
99
1010type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">;
1111
12export async function openDesktopUpdateReleaseNotes(
13 shell: DesktopUpdateShell | undefined,
14 releaseUrl: string,
15): Promise<void> {
16 try {
17 if (shell && (await shell.openExternal(releaseUrl))) return;
18 } catch {
19 // Surface rejected IPC calls through the same user-visible fallback.
20 }
21 toastManager.add({ type: "error", title: "Unable to open release notes" });
22}
23
1224function ReleaseNotesLink({
1325 shell,
1426 releaseUrl,
......@@ -20,14 +32,7 @@ function ReleaseNotesLink({
2032 <button
2133 className="ml-2 inline cursor-pointer text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground"
2234 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 })();
35 void openDesktopUpdateReleaseNotes(shell, releaseUrl);
3136 }}
3237 type="button"
3338 >
apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx created+86
......@@ -0,0 +1,86 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 handleSidebarUpdateReleaseNotesPopoverOpenChange,
6 openSidebarUpdateReleaseNotesPopoverOnForwardTab,
7 shouldUseSidebarUpdateReleaseNotesPopover,
8} from "./SidebarUpdatePill";
9
10const nightlyState: DesktopUpdateState = {
11 enabled: true,
12 status: "available",
13 channel: "nightly",
14 currentVersion: "0.0.35",
15 hostArch: "arm64",
16 appArch: "arm64",
17 runningUnderArm64Translation: false,
18 availableVersion: "0.0.36-nightly.3",
19 downloadedVersion: null,
20 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Newest change"], totalItems: 1 }],
21 omittedReleaseCount: 0,
22 downloadPercent: null,
23 checkedAt: null,
24 message: null,
25 errorContext: null,
26 canRetry: false,
27};
28
29describe("sidebar update release notes popover", () => {
30 it("uses the popover only for visible nightly release notes", () => {
31 expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true);
32 expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false);
33 expect(
34 shouldUseSidebarUpdateReleaseNotesPopover(true, {
35 ...nightlyState,
36 channel: "latest",
37 }),
38 ).toBe(false);
39 expect(
40 shouldUseSidebarUpdateReleaseNotesPopover(true, {
41 ...nightlyState,
42 releaseNotes: [],
43 }),
44 ).toBe(false);
45 });
46
47 it("cancels trigger presses without canceling other open reasons", () => {
48 const cancelTriggerPress = vi.fn();
49 const cancelHover = vi.fn();
50
51 handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {
52 reason: "trigger-press",
53 cancel: cancelTriggerPress,
54 });
55 handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {
56 reason: "trigger-hover",
57 cancel: cancelHover,
58 });
59
60 expect(cancelTriggerPress).toHaveBeenCalledOnce();
61 expect(cancelHover).not.toHaveBeenCalled();
62 });
63
64 it("promotes forward Tab without preventing native navigation", () => {
65 const open = vi.fn();
66 const preventDefault = vi.fn();
67 const event = { key: "Tab", shiftKey: false, preventDefault };
68
69 openSidebarUpdateReleaseNotesPopoverOnForwardTab(event, { open }, "nightly-release-notes");
70
71 expect(open).toHaveBeenCalledWith("nightly-release-notes");
72 expect(preventDefault).not.toHaveBeenCalled();
73 });
74
75 it("does not promote backward Tab", () => {
76 const open = vi.fn();
77
78 openSidebarUpdateReleaseNotesPopoverOnForwardTab(
79 { key: "Tab", shiftKey: true },
80 { open },
81 "nightly-release-notes",
82 );
83
84 expect(open).not.toHaveBeenCalled();
85 });
86});
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+170-115
......@@ -1,5 +1,7 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";
12import { TriangleAlertIcon } from "lucide-react";
2import { useCallback, useEffect, useState } from "react";
3import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react";
4import { flushSync } from "react-dom";
35import { isElectron } from "../../env";
46import { useMediaQuery } from "../../hooks/useMediaQuery";
57import { cn } from "../../lib/utils";
......@@ -19,7 +21,7 @@ import {
1921} from "../desktopUpdate.logic";
2022import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
2123import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
22import { Separator } from "../ui/separator";
24import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover";
2325import { SidebarMenuItem } from "../ui/sidebar";
2426import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2527import {
......@@ -27,6 +29,37 @@ import {
2729 shouldContinueDesktopUpdateCheckAnimation,
2830 shouldShowDesktopUpdateCheckIcon,
2931} from "./DesktopUpdateStatusIcon";
32import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
33
34type SidebarUpdatePopoverChangeDetails = Parameters<
35 NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>
36>[1];
37type SidebarUpdatePopoverHandle = ReturnType<typeof PopoverCreateHandle>;
38
39export function shouldUseSidebarUpdateReleaseNotesPopover(
40 showUpdateDetails: boolean,
41 state: DesktopUpdateState | null,
42): boolean {
43 return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0;
44}
45
46export function handleSidebarUpdateReleaseNotesPopoverOpenChange(
47 _open: boolean,
48 details: Pick<SidebarUpdatePopoverChangeDetails, "reason" | "cancel">,
49): void {
50 // The trigger is the update action, so its presses must not also toggle the Popover.
51 if (details.reason === "trigger-press") details.cancel();
52}
53
54export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(
55 event: { readonly key: string; readonly shiftKey: boolean },
56 handle: Pick<SidebarUpdatePopoverHandle, "open">,
57 triggerId: string,
58): void {
59 if (event.key !== "Tab" || event.shiftKey) return;
60 // Hover-open popovers do not manage focus. Promote this one before native Tab runs.
61 flushSync(() => handle.open(triggerId));
62}
3063
3164function resolveSidebarUpdatePresentation({
3265 action,
......@@ -55,67 +88,6 @@ function resolveSidebarUpdatePresentation({
5588 } as const;
5689}
5790
58function keyReleaseNoteItems(items: ReadonlyArray<string>) {
59 const occurrences = new Map<string, number>();
60 return items.map((item) => {
61 const occurrence = occurrences.get(item) ?? 0;
62 occurrences.set(item, occurrence + 1);
63 return { item, key: JSON.stringify([item, occurrence]) };
64 });
65}
66
67function SidebarUpdateReleaseNotesTooltip({
68 state,
69 tooltip,
70}: {
71 readonly state: NonNullable<ReturnType<typeof useDesktopUpdateState>>;
72 readonly tooltip: string;
73}) {
74 if (state.channel !== "nightly" || state.releaseNotes.length === 0) {
75 return <>{tooltip}</>;
76 }
77
78 return (
79 <div className="w-fit max-w-[min(24rem,calc(100vw-2rem))] text-left">
80 <div className="px-1">
81 {state.status === "available" ? (
82 <div>
83 <div className="whitespace-nowrap text-sm leading-5 font-medium">
84 Update ready to download
85 </div>
86 {state.availableVersion ? (
87 <div className="mt-0.5 text-xs leading-4 text-muted-foreground">
88 {state.availableVersion}
89 </div>
90 ) : null}
91 </div>
92 ) : (
93 <div className="text-sm leading-5 font-medium">{tooltip}</div>
94 )}
95 </div>
96 <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">
97 {state.releaseNotes.map((releaseNote, index) => (
98 <div key={releaseNote.version}>
99 {index > 0 && <Separator className="my-3 bg-border/60" />}
100 <section>
101 <h3 className="text-foreground text-xs leading-4 font-semibold">
102 {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
103 </h3>
104 <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">
105 {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => (
106 <li className="list-disc break-words" key={key}>
107 {item}
108 </li>
109 ))}
110 </ul>
111 </section>
112 </div>
113 ))}
114 </div>
115 </div>
116 );
117}
118
11991export function SidebarUpdateArchitectureWarning() {
12092 return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null;
12193}
......@@ -145,6 +117,10 @@ function SidebarUpdateControl() {
145117 const [isActionPending, setIsActionPending] = useState(false);
146118 const [checkAnimationKey, setCheckAnimationKey] = useState(0);
147119 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
120 const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
121 const suppressReleaseNotesFocusOpen = useRef(false);
122 const releaseNotesPopupRef = useRef<HTMLDivElement>(null);
123 const releaseNotesTriggerId = useId();
148124 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
149125
150126 useEffect(() => {
......@@ -180,6 +156,22 @@ function SidebarUpdateControl() {
180156 ? isDesktopUpdateButtonDisabled(state)
181157 : !canCheckForUpdate(state);
182158 const isInteractionDisabled = disabled || isActionPending;
159 const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover(
160 showUpdateDetails,
161 state,
162 );
163
164 useEffect(() => {
165 if (!showReleaseNotesPopover) {
166 releaseNotesPopoverHandle.close();
167 return;
168 }
169
170 const trigger = document.getElementById(releaseNotesTriggerId);
171 if (trigger?.matches(":focus-visible")) {
172 releaseNotesPopoverHandle.open(releaseNotesTriggerId);
173 }
174 }, [releaseNotesPopoverHandle, releaseNotesTriggerId, showReleaseNotesPopover]);
183175
184176 const handleAction = useCallback(async () => {
185177 const bridge = window.desktopBridge;
......@@ -305,61 +297,124 @@ function SidebarUpdateControl() {
305297 );
306298 }, [prefersReducedMotion, state?.status]);
307299
300 const updateButton = (
301 <button
302 type="button"
303 aria-label={tooltip}
304 aria-disabled={isInteractionDisabled || undefined}
305 className={cn(
306 "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2",
307 isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer",
308 showUpdateIconState
309 ? cn(
310 "bg-sidebar-control-surface text-sidebar-foreground",
311 !isInteractionDisabled && "hover:bg-sidebar-row-hover",
312 )
313 : cn(
314 "text-[var(--sidebar-icon-color)]",
315 !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
316 ),
317 disabled && !showUpdateIconState && "opacity-60",
318 )}
319 onClick={handleAction}
320 onBlur={() => {
321 suppressReleaseNotesFocusOpen.current = false;
322 }}
323 onFocus={(event) => {
324 if (!showReleaseNotesPopover || !event.currentTarget.matches(":focus-visible")) return;
325 if (suppressReleaseNotesFocusOpen.current) {
326 suppressReleaseNotesFocusOpen.current = false;
327 return;
328 }
329 flushSync(() => releaseNotesPopoverHandle.open(releaseNotesTriggerId));
330 }}
331 onKeyDown={(event) => {
332 if (!showReleaseNotesPopover) return;
333 openSidebarUpdateReleaseNotesPopoverOnForwardTab(
334 event,
335 releaseNotesPopoverHandle,
336 releaseNotesTriggerId,
337 );
338 }}
339 >
340 <DesktopUpdateStatusIcon
341 key={showCheckIcon ? checkAnimationKey : iconStatus}
342 downloadPercent={state?.downloadPercent ?? null}
343 isCheckAnimating={showCheckIcon && !prefersReducedMotion}
344 onCheckAnimationIteration={handleCheckAnimationIteration}
345 status={iconStatus}
346 />
347 </button>
348 );
349
308350 return (
309351 <SidebarMenuItem className="ml-auto shrink-0">
310 <Tooltip>
311 <TooltipTrigger
312 render={
313 <button
314 type="button"
315 aria-label={tooltip}
316 aria-disabled={isInteractionDisabled || undefined}
317 className={cn(
318 "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2",
319 isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer",
320 showUpdateIconState
321 ? cn(
322 "bg-sidebar-control-surface text-sidebar-foreground",
323 !isInteractionDisabled && "hover:bg-sidebar-row-hover",
324 )
325 : cn(
326 "text-[var(--sidebar-icon-color)]",
327 !isInteractionDisabled &&
328 "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
329 ),
330 disabled && !showUpdateIconState && "opacity-60",
331 )}
332 onClick={handleAction}
333 >
334 <DesktopUpdateStatusIcon
335 key={showCheckIcon ? checkAnimationKey : iconStatus}
336 downloadPercent={state?.downloadPercent ?? null}
337 isCheckAnimating={showCheckIcon && !prefersReducedMotion}
338 onCheckAnimationIteration={handleCheckAnimationIteration}
339 status={iconStatus}
340 />
341 </button>
342 }
343 />
344 <TooltipPopup
345 align="center"
346 className={
347 showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0
348 ? // pointer-events-auto overrides the positioner's pointer-events-none so the
349 // release notes stay open (and scrollable) when the cursor moves into them.
350 "pointer-events-auto max-w-none text-balance"
351 : undefined
352 <Popover
353 handle={releaseNotesPopoverHandle}
354 onOpenChange={(open, details) => {
355 if (open && !showReleaseNotesPopover) {
356 details.cancel();
357 return;
352358 }
353 side="top"
354 variant={showUpdateDetails ? "glass" : "default"}
355 >
356 {showUpdateDetails && state ? (
357 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
358 ) : (
359 tooltip
360 )}
361 </TooltipPopup>
362 </Tooltip>
359 handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);
360 }}
361 >
362 <Tooltip disabled={showReleaseNotesPopover}>
363 <TooltipTrigger
364 id={releaseNotesTriggerId}
365 render={
366 <PopoverTrigger
367 {...(!showReleaseNotesPopover
368 ? {
369 "aria-controls": undefined,
370 "aria-expanded": undefined,
371 "aria-haspopup": undefined,
372 }
373 : {})}
374 closeDelay={150}
375 handle={releaseNotesPopoverHandle}
376 id={releaseNotesTriggerId}
377 openOnHover={showReleaseNotesPopover}
378 render={updateButton}
379 />
380 }
381 />
382 {!showReleaseNotesPopover ? (
383 <TooltipPopup
384 align="center"
385 side="top"
386 variant={showUpdateDetails ? "glass" : "default"}
387 >
388 {tooltip}
389 </TooltipPopup>
390 ) : null}
391 </Tooltip>
392 {showReleaseNotesPopover && state ? (
393 <PopoverPopup
394 align="center"
395 aria-label="Nightly update release notes"
396 className="max-w-none text-balance shadow-xl shadow-black/25"
397 initialFocus={false}
398 onKeyDownCapture={(event) => {
399 if (
400 event.key === "Escape" &&
401 releaseNotesPopupRef.current?.contains(document.activeElement)
402 ) {
403 suppressReleaseNotesFocusOpen.current = true;
404 }
405 }}
406 ref={releaseNotesPopupRef}
407 side="top"
408 tooltipStyle
409 >
410 <SidebarUpdateReleaseNotes
411 shell={window.desktopBridge}
412 state={state}
413 tooltip={tooltip}
414 />
415 </PopoverPopup>
416 ) : null}
417 </Popover>
363418 </SidebarMenuItem>
364419 );
365420}
apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx created+152
......@@ -0,0 +1,152 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";
2import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const testState = vi.hoisted(() => ({
6 addToast: vi.fn(),
7}));
8
9vi.mock("../ui/toast", () => ({
10 toastManager: { add: testState.addToast },
11}));
12
13import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
14
15type AnchorElement = ReactElement<{
16 readonly children?: ReactNode;
17 readonly href?: string;
18 readonly onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
19}>;
20
21const baseState: DesktopUpdateState = {
22 enabled: true,
23 status: "available",
24 channel: "nightly",
25 currentVersion: "0.0.35",
26 hostArch: "arm64",
27 appArch: "arm64",
28 runningUnderArm64Translation: false,
29 availableVersion: "0.0.36-nightly.3",
30 downloadedVersion: null,
31 releaseNotes: [],
32 omittedReleaseCount: 0,
33 downloadPercent: null,
34 checkedAt: null,
35 message: null,
36 errorContext: null,
37 canRetry: false,
38};
39
40function collectAnchors(node: ReactNode, anchors: AnchorElement[] = []): AnchorElement[] {
41 if (Array.isArray(node)) {
42 for (const child of node) collectAnchors(child, anchors);
43 return anchors;
44 }
45 if (!isValidElement(node)) return anchors;
46
47 const element = node as ReactElement<{ readonly children?: ReactNode }>;
48 if (typeof element.type === "function") {
49 const render = element.type as (props: unknown) => ReactNode;
50 return collectAnchors(render(element.props), anchors);
51 }
52 if (element.type === "a") anchors.push(element as AnchorElement);
53 return collectAnchors(element.props.children, anchors);
54}
55
56function textContent(node: ReactNode): string {
57 if (typeof node === "string" || typeof node === "number") return String(node);
58 if (Array.isArray(node)) return node.map(textContent).join("");
59 if (!isValidElement(node)) return "";
60 const element = node as ReactElement<{ readonly children?: ReactNode }>;
61 return textContent(element.props.children);
62}
63
64function renderNotes(state: DesktopUpdateState, openExternal = vi.fn().mockResolvedValue(true)) {
65 return SidebarUpdateReleaseNotes({
66 shell: { openExternal },
67 state,
68 tooltip: "Update available",
69 });
70}
71
72describe("SidebarUpdateReleaseNotes", () => {
73 beforeEach(() => {
74 testState.addToast.mockReset();
75 });
76
77 it("links each preview to its exact release and labels hidden changes", () => {
78 const anchors = collectAnchors(
79 renderNotes({
80 ...baseState,
81 releaseNotes: [
82 { version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 },
83 { version: "0.0.36-nightly.2", items: ["Change 2"], totalItems: 2 },
84 { version: "0.0.36-nightly.1", items: ["Change 1", "Earlier"], totalItems: 4 },
85 ],
86 }),
87 );
88
89 expect(anchors.map(({ props }) => props.href)).toEqual([
90 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3",
91 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.2",
92 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.1",
93 ]);
94 expect(anchors.map(({ props }) => textContent(props.children))).toEqual([
95 "View release on GitHub",
96 "1 more change on GitHub",
97 "2 more changes on GitHub",
98 ]);
99 });
100
101 it("links omitted releases to release history", () => {
102 const anchors = collectAnchors(
103 renderNotes({
104 ...baseState,
105 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }],
106 omittedReleaseCount: 1,
107 }),
108 );
109
110 expect(anchors.at(-1)?.props.href).toBe("https://github.com/pingdotgg/t3code/releases");
111 expect(textContent(anchors.at(-1)?.props.children)).toBe("1 older release on GitHub");
112 });
113
114 it("shows plural history text for multiple omitted releases", () => {
115 const anchors = collectAnchors(
116 renderNotes({
117 ...baseState,
118 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }],
119 omittedReleaseCount: 3,
120 }),
121 );
122
123 expect(textContent(anchors.at(-1)?.props.children)).toBe("3 older releases on GitHub");
124 });
125
126 it("reports a release link that fails to open", async () => {
127 const openExternal = vi.fn().mockResolvedValue(false);
128 const [anchor] = collectAnchors(
129 renderNotes(
130 {
131 ...baseState,
132 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }],
133 },
134 openExternal,
135 ),
136 );
137 const preventDefault = vi.fn();
138
139 anchor?.props.onClick?.({ preventDefault } as unknown as MouseEvent<HTMLAnchorElement>);
140
141 expect(preventDefault).toHaveBeenCalledOnce();
142 await vi.waitFor(() => {
143 expect(openExternal).toHaveBeenCalledWith(
144 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3",
145 );
146 expect(testState.addToast).toHaveBeenCalledWith({
147 type: "error",
148 title: "Unable to open release notes",
149 });
150 });
151 });
152});
apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx created+120
......@@ -0,0 +1,120 @@
1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2import { ExternalLinkIcon } from "lucide-react";
3
4import {
5 getDesktopUpdateReleaseHistoryUrl,
6 getDesktopUpdateReleaseUrl,
7} from "../desktopUpdate.logic";
8import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast";
9import { Separator } from "../ui/separator";
10
11type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">;
12
13function keyReleaseNoteItems(items: ReadonlyArray<string>) {
14 const occurrences = new Map<string, number>();
15 return items.map((item) => {
16 const occurrence = occurrences.get(item) ?? 0;
17 occurrences.set(item, occurrence + 1);
18 return { item, key: JSON.stringify([item, occurrence]) };
19 });
20}
21
22function ReleaseLink({
23 children,
24 releaseUrl,
25 shell,
26}: {
27 readonly children: string;
28 readonly releaseUrl: string;
29 readonly shell: DesktopUpdateShell | undefined;
30}) {
31 return (
32 <a
33 className="mt-2 inline-flex items-center gap-1 rounded-sm text-xs leading-5 text-muted-foreground underline decoration-dotted underline-offset-4 outline-none transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
34 href={releaseUrl}
35 onClick={(event) => {
36 event.preventDefault();
37 void openDesktopUpdateReleaseNotes(shell, releaseUrl);
38 }}
39 >
40 {children}
41 <ExternalLinkIcon aria-hidden className="size-3 shrink-0" strokeWidth={2.25} />
42 </a>
43 );
44}
45
46export function SidebarUpdateReleaseNotes({
47 shell,
48 state,
49 tooltip,
50}: {
51 readonly shell: DesktopUpdateShell | undefined;
52 readonly state: DesktopUpdateState;
53 readonly tooltip: string;
54}) {
55 if (state.channel !== "nightly" || state.releaseNotes.length === 0) {
56 return <>{tooltip}</>;
57 }
58
59 return (
60 <div className="flex max-h-[calc(var(--available-height)-0.5rem)] min-h-0 w-fit max-w-[min(24rem,calc(100vw-2rem))] flex-col text-left">
61 <div className="shrink-0 px-1">
62 {state.status === "available" ? (
63 <div>
64 <div className="whitespace-nowrap text-sm leading-5 font-medium">
65 Update ready to download
66 </div>
67 {state.availableVersion ? (
68 <div className="mt-0.5 text-xs leading-4 text-muted-foreground">
69 {state.availableVersion}
70 </div>
71 ) : null}
72 </div>
73 ) : (
74 <div className="text-sm leading-5 font-medium">{tooltip}</div>
75 )}
76 </div>
77 <div className="min-h-0 max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">
78 {state.releaseNotes.map((releaseNote, index) => {
79 const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version);
80 const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length);
81 const linkLabel =
82 omittedItemCount === 0
83 ? "View release on GitHub"
84 : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`;
85
86 return (
87 <div key={releaseNote.version}>
88 {index > 0 && <Separator className="my-3 bg-border/60" />}
89 <section>
90 <h3 className="text-foreground text-xs leading-4 font-semibold">
91 {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
92 </h3>
93 <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">
94 {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => (
95 <li className="list-disc break-words" key={key}>
96 {item}
97 </li>
98 ))}
99 </ul>
100 {releaseUrl ? (
101 <ReleaseLink releaseUrl={releaseUrl} shell={shell}>
102 {linkLabel}
103 </ReleaseLink>
104 ) : null}
105 </section>
106 </div>
107 );
108 })}
109 {state.omittedReleaseCount > 0 ? (
110 <div>
111 <Separator className="my-3 bg-border/60" />
112 <ReleaseLink releaseUrl={getDesktopUpdateReleaseHistoryUrl()} shell={shell}>
113 {`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`}
114 </ReleaseLink>
115 </div>
116 ) : null}
117 </div>
118 </div>
119 );
120}
apps/web/src/state/desktopUpdate.test.ts+1
......@@ -16,6 +16,7 @@ const baseState: DesktopUpdateState = {
1616 availableVersion: null,
1717 downloadedVersion: null,
1818 releaseNotes: [],
19 omittedReleaseCount: 0,
1920 downloadPercent: null,
2021 checkedAt: null,
2122 message: null,
docs/user/updating.md+10
......@@ -57,6 +57,16 @@ the warning always works.
5757See [Running T3 Code in the Background](./background-service.md) for install, status, and removal
5858commands.
5959
60## Nightly desktop release notes
61
62The desktop app shows a compact release-notes preview when a nightly update is available. Changes
63appear newest first within each release. Each release links to its exact page on GitHub, even when
64all changes fit in the preview.
65
66The preview shows up to eight changes from each of six releases. When it leaves out changes or older
67releases, it shows the exact number and links to the rest. Contributor credits do not count as
68changes.
69
6070## After the Update
6171
6272Keep the web or desktop app open while the server restarts. The update completes only after the
packages/contracts/src/ipc.ts+4
......@@ -209,6 +209,7 @@ export interface DesktopUpdateState {
209209 availableVersion: string | null;
210210 downloadedVersion: string | null;
211211 releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>;
212 omittedReleaseCount: number;
212213 downloadPercent: number | null;
213214 checkedAt: string | null;
214215 message: string | null;
......@@ -219,11 +220,13 @@ export interface DesktopUpdateState {
219220export interface DesktopUpdateReleaseNote {
220221 version: string;
221222 items: ReadonlyArray<string>;
223 totalItems: number;
222224}
223225
224226export const DesktopUpdateReleaseNoteSchema = Schema.Struct({
225227 version: Schema.String,
226228 items: Schema.Array(Schema.String),
229 totalItems: Schema.Number,
227230});
228231
229232export const DesktopUpdateStateSchema = Schema.Struct({
......@@ -237,6 +240,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({
237240 availableVersion: Schema.NullOr(Schema.String),
238241 downloadedVersion: Schema.NullOr(Schema.String),
239242 releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema),
243 omittedReleaseCount: Schema.Number,
240244 downloadPercent: Schema.NullOr(Schema.Number),
241245 checkedAt: Schema.NullOr(Schema.String),
242246 message: Schema.NullOr(Schema.String),