From 8b1ea4dd2465f3cf3cfa02d6878beaa1ec22e71a Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 14 Sep 2026 09:10:39 -0700 Subject: [PATCH 01/11] fix(desktop): restore Node discovery for WSL providers (#11741) --- .../DesktopBackendConfiguration.test.ts | 6 +- .../backend/DesktopBackendConfiguration.ts | 4 +- .../src/wsl/DesktopWslEnvironment.test.ts | 70 +++++++++++++++++++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 26 ++++--- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 642a1bc82f42..edc719734912 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -399,10 +399,10 @@ describe("DesktopBackendConfiguration", () => { observedProbeRoots.push(root); return { ok: true, resolvedPath }; }, - // The staged runtime carries its own Node, so the preflight must not - // go looking for one in the distro. + // The staged runtime carries its own Node and node-pty, so it must + // not require the mounted server tree's native dependency check. ensureNodePty: () => { - throw new Error("the staged runtime must not probe for Node"); + throw new Error("the staged runtime must not probe for node-pty"); }, }), }, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 3486daebf79e..09014add865c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -384,8 +384,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime is self-contained, so the only question is whether - // it runs here; there is no Node to find or node-pty to load. + // The staged runtime supplies its own Node and node-pty. Provider PATH + // discovery must not require either dependency for runtime readiness. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 366bdfce9946..e28a9c6c7800 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,6 +15,7 @@ import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, + buildWslRuntimeProbeScript, DesktopWslDistroListError, formatMissingToolsReason, parseNodePath, @@ -446,6 +447,75 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed }; }; + const probeFixture = (fixture: ReturnType) => + runShell( + [ + `export HOME=${sh(`${fixture.work}/home`)}`, + 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', + // Isolate login profiles and hide the host's Node/version managers. + // The resolver must discover the fixture's installation itself. + "bash() { (", + " command() {", + ' case "$*" in', + ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;', + ' *) builtin command "$@" ;;', + " esac", + " }", + ' eval "$2"', + "); }", + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + it("discovers version-managed Node for providers with a standalone runtime", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`; + const setup = runShell( + [ + "set -eu", + `mkdir -p ${sh(nodeBin)}`, + `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`, + `chmod +x ${sh(`${nodeBin}/node`)}`, + ].join("\n"), + ); + expect(setup.status, setup.stderr).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + const resolvedPath = parseResolvedPath(probe.stdout); + expect(resolvedPath?.split(":")).toContain(nodeBin); + const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`); + expect(provider.status, provider.stderr).toBe(0); + expect(provider.stdout).toBe("linux-node-provider\n"); + }); + + it("keeps standalone runtime readiness independent of Node availability", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).not.toBeNull(); + }); + + it("keeps the inherited PATH when bash is unavailable", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const probe = runShell( + [ + "bash() { return 127; }", + 'export PATH="/fixture/bin:/usr/bin:/bin"', + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin"); + }); + it("reuses a warm cache without touching the archive", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index ebd4f853da60..95b217c622b1 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -122,8 +122,8 @@ export class DesktopWslEnvironment extends Context.Service< // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; // Proves a staged self-contained runtime can run (`/t3 --version`) - // and captures the user's login-shell PATH for the launch. Needs no Node - // in the distro; the mounted server tree still goes through ensureNodePty. + // and resolves the user's PATH, including version-managed Node for provider + // CLIs. Node is optional; the mounted tree still requires ensureNodePty. readonly probeRuntime: ( distro: string | null, linuxAppRoot: string, @@ -545,13 +545,12 @@ require("node-pty"); NODE`; // Readiness proof for a staged self-contained runtime: the executable runs and -// reports its version, and the login shell's PATH is captured for the launch. -// This runs under plain `sh` (no Node resolver preamble, since the runtime -// needs no Node), so the login shell is entered explicitly for the PATH -// capture; a distro without bash falls back to the PATH sh was started with. -const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => +// reports its version. Provider CLIs may still need version-managed Node, so +// resolve it before capturing PATH without requiring it for runtime readiness. +// A distro without bash falls back to the PATH sh was started with. +export const buildWslRuntimeProbeScript = (linuxAppRoot: string) => [ - `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, + `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, ].join("\n"); @@ -676,9 +675,14 @@ const probeWslRuntimeImpl = ( linuxAppRoot: string, ): Effect.Effect => Effect.gen(function* () { - const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { - resolveNode: false, - }); + const probe = yield* runWslShell( + distro, + buildWslRuntimeProbeScript(linuxAppRoot), + PROBE_TIMEOUT, + { + resolveNode: false, + }, + ); const transportFailureReason = formatWslShellTransportFailureReason( probe.transportFailure, "the staged runtime", From ae67c5b8159cb49a68c16f96f7bb64a2e3c6b1c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 09:10:43 -0700 Subject: [PATCH 02/11] fix(release): stop npm from pruning the platform packages' shipped node_modules (#11750) Co-authored-by: Claude Fable 5 --- scripts/build-npm-platform-packages.test.ts | 22 ++++++++- scripts/build-npm-platform-packages.ts | 49 +++++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 47568afbcb67..5fc0d859fc3d 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -55,9 +55,22 @@ const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () { const stem = `t3-${VERSION}-${key}`; const stage = path.join(root, "stage", key); const contentDir = path.join(stage, stem); - for (const dir of ["client", "resource-monitor", "node_modules/node-pty"]) { + for (const dir of [ + "client", + "resource-monitor", + "node_modules/node-pty", + "node_modules/@ff-labs/fff-node", + ]) { yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true }); } + yield* fs.writeFileString( + path.join(contentDir, "node_modules/node-pty/package.json"), + '{ "name": "node-pty", "version": "1.1.0" }\n', + ); + yield* fs.writeFileString( + path.join(contentDir, "node_modules/@ff-labs/fff-node/package.json"), + '{ "name": "@ff-labs/fff-node", "version": "0.9.4" }\n', + ); yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "\n"); yield* fs.writeFileString( path.join(contentDir, "t3"), @@ -127,6 +140,13 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { ]); assert.equal(linuxManifest.preferUnplugged, true); assert.isUndefined(linuxManifest.bin); + // The shipped node_modules is declared, or npm prunes it as extraneous + // on the next install in the same project and the executable breaks. + assert.deepStrictEqual(linuxManifest.dependencies, { + "@ff-labs/fff-node": "0.9.4", + "node-pty": "1.1.0", + }); + assert.deepStrictEqual(linuxManifest.bundleDependencies, ["@ff-labs/fff-node", "node-pty"]); // Archive contents sit at the package root, not under the archive stem. assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html"))); // A root README, or npm would display a bundled dependency's. diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index f73610d10aa8..fd8c4999a17a 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -88,9 +88,22 @@ export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): stri return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`; } -/** package.json for one platform package; `os`/`cpu` let npm skip the other five. */ -export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, version: string) { +/** + * package.json for one platform package; `os`/`cpu` let npm skip the other + * five. The archive's runtime `node_modules` (native addons and their + * loaders) ships inside the tarball, and npm only keeps a nested tree it can + * account for: anything not declared is extraneous and pruned on the next + * `npm install` in that project, which then breaks the executable. Declaring + * every bundled package as a bundled dependency at the exact version on disk + * makes npm treat the tree as part of this package and leave it alone. + */ +export function npmPlatformPackageManifest( + platformKey: CliArchivePlatformKey, + version: string, + bundled: Readonly>, +) { const [os, cpu] = platformKey.split("-") as [string, string]; + const bundleDependencies = Object.keys(bundled).sort(); return { name: npmPlatformPackageName(platformKey), version, @@ -101,9 +114,38 @@ export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, v cpu: [cpu], files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"], preferUnplugged: true, + dependencies: Object.fromEntries(bundleDependencies.map((name) => [name, bundled[name]])), + bundleDependencies, }; } +const PackageVersion = Schema.Struct({ version: Schema.String }); +const decodePackageVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageVersion)); + +/** Every top-level package under `node_modules`, scoped ones included, at the version its manifest names. */ +const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModulesDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bundled: Record = {}; + const packageDirs: Array<{ readonly name: string; readonly dir: string }> = []; + for (const entry of yield* fs.readDirectory(nodeModulesDir)) { + if (entry.startsWith(".")) continue; + const dir = path.join(nodeModulesDir, entry); + if (entry.startsWith("@")) { + for (const scoped of yield* fs.readDirectory(dir)) { + packageDirs.push({ name: `${entry}/${scoped}`, dir: path.join(dir, scoped) }); + } + } else { + packageDirs.push({ name: entry, dir }); + } + } + for (const { name, dir } of packageDirs) { + const manifest = yield* fs.readFileString(path.join(dir, "package.json")); + bundled[name] = (yield* decodePackageVersion(manifest)).version; + } + return bundled; +}); + /** * README for one platform package. Without one at the package root, npm * shows the first README it finds in the tarball, which is a bundled @@ -302,9 +344,10 @@ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: if (executableName === "t3") { yield* fs.chmod(executable, 0o755); } + const bundled = yield* readBundledPackages(path.join(contentDir, "node_modules")); yield* fs.writeFileString( path.join(contentDir, "package.json"), - `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`, + `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version, bundled))}\n`, ); yield* fs.writeFileString( path.join(contentDir, "README.md"), From 955b787e6b9fb8666e84abfa6868671859d72f7e Mon Sep 17 00:00:00 2001 From: NikodemNowak <71512463+NikodemNowak@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:15:45 +0200 Subject: [PATCH 03/11] fix(server): parse CLI versions with a "v" prefix (#11738) --- .../src/provider/providerSnapshot.test.ts | 24 +++++++++++++++++++ apps/server/src/provider/providerSnapshot.ts | 8 ++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 399d86f7a133..5c45362daa74 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -11,6 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isCommandMissingCause, + parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, } from "./providerSnapshot.ts"; @@ -93,6 +94,29 @@ describe("providerModelsFromSettings", () => { }); }); +describe("parseGenericCliVersion", () => { + it("parses a bare version", () => { + expect(parseGenericCliVersion("1.14.19")).toBe("1.14.19"); + }); + + it("parses a v-prefixed version", () => { + expect(parseGenericCliVersion("opencode v2.0.3")).toBe("2.0.3"); + expect(parseGenericCliVersion("v22.19.0")).toBe("22.19.0"); + }); + + it("parses a version embedded in other output", () => { + expect(parseGenericCliVersion("codex-cli 0.53.0 (build abc)")).toBe("0.53.0"); + }); + + it("returns null when no version is present", () => { + expect(parseGenericCliVersion("no version here")).toBeNull(); + }); + + it("ignores versions glued to other word characters", () => { + expect(parseGenericCliVersion("build2.0.3artifact")).toBeNull(); + }); +}); + describe("ProviderCommandNotFoundError", () => { it("classifies normalized platform failures without parsing messages", () => { expect( diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 40ae0eeefde0..c5503a175498 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -107,8 +107,14 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman return result; }).pipe(Effect.scoped); +/** + * Return the first semantic version found in CLI output, or null. Accepts a + * leading "v" (for example `opencode v2.0.3`). + */ export function parseGenericCliVersion(output: string): string | null { - const match = output.match(/\b(\d+\.\d+\.\d+)\b/); + // "opencode v2.0.3"-style output: the optional "v" has to be consumed first, + // since "v2" itself contains no word boundary. + const match = output.match(/\bv?(\d+\.\d+\.\d+)\b/); return match?.[1] ?? null; } From 05e3bcbc65d43adc70c900f176bec6c095b3b833 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 09:15:56 -0700 Subject: [PATCH 04/11] fix(desktop): keep preview releases out of the nightly update changelog (#11753) Co-authored-by: Claude Fable 5 --- apps/desktop/src/updates/DesktopUpdates.ts | 1 + apps/desktop/src/updates/releaseNotes.test.ts | 57 +++++++++++++++++-- apps/desktop/src/updates/releaseNotes.ts | 19 ++++++- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 51584e3c796d..c35b52e8343d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -711,6 +711,7 @@ export const make = Effect.gen(function* () { const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( info.releaseNotes, info.version, + state.channel, ); yield* setState( reduceDesktopUpdateStateOnUpdateAvailable( diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 3ba2444dc185..57d185c0975e 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -21,6 +21,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", ].join("\n"), "0.0.36-nightly.20260828.1213", + "nightly", ); expect(result).toEqual({ @@ -50,6 +51,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "

New Contributors

  • @human made their first contribution
" + "

Full Changelog

", "1.2.3", + "latest", ); expect(result).toEqual({ @@ -69,6 +71,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }, ], "1.2.4", + "latest", ); expect(result.releaseNotes).toEqual([ @@ -85,6 +88,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.2.1", note: "- Older release" }, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -96,6 +100,42 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); + it("drops releases from other trains before grouping on nightly", () => { + // electron-updater's full changelog is "every version above the running + // one", and preview sorts above nightly, so the preview cuts come first. + const releaseNotes = [ + { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, + { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, + { version: "0.0.41-nightly.20260914.1707", note: "- Nightly change 2" }, + { version: "0.0.41-nightly.20260914.1700", note: "- Nightly change 1" }, + ]; + + const result = normalizeDesktopUpdateReleaseNotes( + releaseNotes, + "0.0.41-nightly.20260914.1707", + "nightly", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual([ + "0.0.41-nightly.20260914.1707", + "0.0.41-nightly.20260914.1700", + ]); + expect(result.omittedReleaseCount).toBe(0); + }); + + it("keeps only stable releases on the latest channel", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "0.0.42", note: "- Stable change" }, + { version: "0.0.42-nightly.20260915.1710", note: "- Nightly change" }, + ], + "0.0.42", + "latest", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual(["0.0.42"]); + }); + it("counts valid groups before applying the six-release limit", () => { const releaseNotes = [ { version: "1.3.9", note: "- Change 9" }, @@ -108,7 +148,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.3.2", note: "- Change 2" }, ]; - const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); + const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9", "latest"); expect(result.releaseNotes.map(({ version }) => version)).toEqual([ "1.3.9", @@ -122,7 +162,11 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("decodes valid HTML entities", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Fix & polish 😀", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], omittedReleaseCount: 0, @@ -140,6 +184,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { null, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -149,14 +194,18 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("returns an empty result for an invalid payload", () => { - expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ + expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0", "latest")).toEqual({ releaseNotes: [], omittedReleaseCount: 0, }); }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Broken entity �", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], omittedReleaseCount: 0, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 3b2f32e646a1..3cab5f15e451 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,4 +1,6 @@ -import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; +import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -122,16 +124,27 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +/** + * Turns electron-updater's release notes into the groups the popover shows. + * With `fullChangelog` on (nightly), electron-updater collects every GitHub + * release whose version is semver-greater than the running one, whatever + * train it belongs to; a maintainers' `-preview.` cut sorts above every + * `-nightly.` of the same base version and would lead the list. Only + * releases on the channel being followed are kept, the same test the + * updater applies to the offered version itself. + */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, + channel: DesktopUpdateChannel, ): NormalizedDesktopUpdateReleaseNotes { - const rawNotes = + const rawNotes = ( typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) - : []; + : [] + ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { const { items, totalItems } = extractReleaseNoteItems(entry.note); From ec5ede5e6a1f2f9d564a5834462f47d1b7e5f0ec Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 14 Sep 2026 17:28:32 +0100 Subject: [PATCH 05/11] fix(web): open video attachment thumbnails in the viewer (#11734) --- .../components/chat/MessagesTimeline.test.tsx | 1 - .../src/components/chat/MessagesTimeline.tsx | 4 +++ .../src/components/media/MediaVideoPlayer.tsx | 35 +++++++++++++++---- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 8f982064869b..0987b852e716 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -791,7 +791,6 @@ describe("MessagesTimeline", () => { expect(markup).toContain(" { + const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file); + if (preview) ctx.onImageExpand(preview); + }} className="block aspect-[4/3] w-full" videoClassName="aspect-auto size-full rounded-lg border border-border/80" stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white" diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 6f2b15e7a73e..9bf65102bf70 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -1,4 +1,4 @@ -import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { cn } from "../../lib/utils"; @@ -15,6 +15,8 @@ interface MediaVideoPlayerProps { readonly revision?: string | null | undefined; readonly preload?: "visible" | "metadata" | undefined; readonly autoPlay?: boolean | undefined; + /** Presents a still thumbnail whose full surface opens the video in a viewer. */ + readonly onOpen?: (() => void) | undefined; readonly className?: string | undefined; readonly videoClassName?: string | undefined; /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ @@ -34,6 +36,7 @@ export function MediaVideoPlayer({ revision = null, preload = "visible", autoPlay = false, + onOpen, className, videoClassName, stateClassName, @@ -131,7 +134,7 @@ export function MediaVideoPlayer({ style={style} data-markdown-copy={copyMarkdown} > - {failed ? ( + {failed && !onOpen ? ( - ) : src !== null ? ( + ) : src !== null && !failed ? (