From 01e05c15268dedb76da95f442fbf5201cd8e7a44 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:39:57 +0530 Subject: [PATCH 01/56] docs(claude): clarify OpenRouter model selection (#11369) --- docs/user/providers-claude.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 6d3ee3b16300..cce194dbd2b4 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -88,8 +88,15 @@ If that Claude config directory has a cached Anthropic login, run `/logout` in a Claude Code session using that directory before starting the router setup. Cached login credentials can conflict with the router token. -Verify requests in OpenRouter's activity dashboard. For model-role overrides and -current compatibility requirements, use the +Select the model you want in T3 Code. For an OpenRouter model outside the built-in +list, open that Claude instance in **Settings > Providers** and add its full model +ID with **Add custom model**. Then select it in the chat model picker. +`ANTHROPIC_DEFAULT_*_MODEL` variables map Claude Code aliases such as `sonnet`; they +do not replace the explicit model ID selected in T3 Code. Custom models may have +fewer effort, thinking, or context controls than built-in models. + +Verify the model used in OpenRouter's activity dashboard. For current compatibility +requirements, use the [OpenRouter Claude Code guide](https://openrouter.ai/docs/cookbook/coding-agents/claude-code-integration). ## Other routers From 0dec07d91488e08206ad32f9758a95acd746c424 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:26:20 +0530 Subject: [PATCH 02/56] fix(web): keep large image previews from stalling composer typing (#11324) --- apps/web/src/components/chat/ChatComposer.tsx | 24 +++++++- .../chat/ComposerImageThumbnail.tsx | 29 +++++++++ apps/web/src/lib/imageCompression.test.ts | 60 +++++++++++++++++++ apps/web/src/lib/imageCompression.ts | 41 +++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerImageThumbnail.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e3ae13911036..fafdee59e042 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -242,6 +242,7 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { ComposerImageThumbnail } from "./ComposerImageThumbnail"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; @@ -4673,7 +4674,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }} > {image.previewUrl ? ( - + + } + /> ) : ( - {image.name} + {image.name} + + } /> ) : ( diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx new file mode 100644 index 000000000000..c803d5693009 --- /dev/null +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -0,0 +1,29 @@ +import { memo, useEffect, useState, type ReactNode } from "react"; + +import { createComposerImageThumbnail } from "../../lib/imageCompression"; + +/** Keep full-resolution image decoding out of composer rerenders. */ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ + file, + alt, + className, + fallback, +}: { + file: File; + alt: string; + className: string; + fallback: ReactNode; +}) { + const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null); + useEffect(() => { + let active = true; + void createComposerImageThumbnail(file).then((src) => { + if (active) setPreview({ file, src }); + }); + return () => { + active = false; + }; + }, [file]); + const src = preview?.file === file ? preview.src : null; + return src ? {alt} : fallback; +}); diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 5c8952144f5a..d83bc1ef87c1 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + createComposerImageThumbnail, compressImageForStash, compressImageToByteLimit, dataUrlToFile, @@ -123,6 +124,65 @@ afterEach(() => { globalThis.OffscreenCanvas = originalOffscreenCanvas; }); +describe("composer image thumbnails", () => { + it("decodes a tall original once and caches a bounded center crop", async () => { + const close = vi.fn(); + const bitmap = { width: 2304, height: 32766, close }; + const decode = vi.fn(async () => bitmap); + const drawImage = vi.fn(); + const dimensions: number[][] = []; + vi.stubGlobal("createImageBitmap", decode); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor(width: number, height: number) { + dimensions.push([width, height]); + } + getContext() { + return { drawImage }; + } + async convertToBlob() { + return new Blob(["thumbnail"], { type: "image/png" }); + } + }, + ); + const original = new File(["original bytes"], "tall.png", { type: "image/png" }); + const [first, second] = await Promise.all([ + createComposerImageThumbnail(original), + createComposerImageThumbnail(original), + ]); + expect(first).toBe("data:image/png;base64,dGh1bWJuYWls"); + expect(second).toBe(first); + expect(await createComposerImageThumbnail(original)).toBe(first); + expect(decode).toHaveBeenCalledExactlyOnceWith(original); + expect(dimensions).toEqual([[256, 256]]); + expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 15231, 2304, 2304, 0, 0, 256, 256); + expect(close).toHaveBeenCalledOnce(); + expect(await original.text()).toBe("original bytes"); + }); + + it("releases the decoded image when thumbnail encoding fails", async () => { + const close = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 500, height: 500, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + getContext() { + return { drawImage: vi.fn() }; + } + async convertToBlob() { + throw new Error("encoder unavailable"); + } + }, + ); + expect(await createComposerImageThumbnail(makeFile(5))).toBeNull(); + expect(close).toHaveBeenCalledOnce(); + }); +}); + describe("dataUrlToFile", () => { it("decodes a captured image without a fetch request", async () => { const file = dataUrlToFile("data:image/png;base64,AAEC/w==", "window.png", "image/png"); diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index af51001f7102..8414f190d5d9 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -247,6 +247,47 @@ async function encodeCanvas( return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; } +const composerThumbnails = new WeakMap>(); + +/** Cache a centered square crop for the composer's object-cover image tiles. */ +export function createComposerImageThumbnail(file: File): Promise { + const cached = composerThumbnails.get(file); + if (cached) return cached; + const thumbnail = (async () => { + if (!canRecompress()) return null; + let bitmap: ImageBitmap | undefined; + try { + bitmap = await createImageBitmap(file); + const side = Math.min(bitmap.width, bitmap.height); + if (side <= 0) return null; + const dimension = Math.min(256, side); + const surface = createCanvas(dimension, dimension); + if (!surface) return null; + surface.context.drawImage( + bitmap, + (bitmap.width - side) / 2, + (bitmap.height - side) / 2, + side, + side, + 0, + 0, + dimension, + dimension, + ); + return ( + (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ?? + null + ); + } catch { + return null; + } finally { + bitmap?.close(); + } + })(); + composerThumbnails.set(file, thumbnail); + return thumbnail; +} + /** * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping * quality down until the data URL fits `budgetChars`. From 8ef478eb0a6deeef81c5421dd7bb1c6b8c4d0e7d Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:01:43 +0300 Subject: [PATCH 03/56] fix(server): avoid extra round trips for terminal output (#11407) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/terminal/OutputProtocol.test.ts | 79 +++++++++++++++++++ apps/server/src/terminal/OutputProtocol.ts | 67 ++++++++++++++++ apps/server/src/ws.ts | 11 ++- 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/terminal/OutputProtocol.test.ts create mode 100644 apps/server/src/terminal/OutputProtocol.ts diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts new file mode 100644 index 000000000000..76629cbf6181 --- /dev/null +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import { WS_METHODS } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; + +import { withTerminalOutputWindow } from "./OutputProtocol.ts"; + +describe("terminal output window", () => { + for (const { tag, size, limit } of [ + { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, + { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, + { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, + { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, + ]) { + it.effect(`limits ${tag} with ${size}-byte values to ${limit} pending chunks`, () => + Effect.gen(function* () { + const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); + const output = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (_clientId, response) => Queue.offer(responses, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + yield* RpcServer.make(group).pipe( + Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); + + for (let index = 0; index < limit; index++) { + const value = String(index).repeat(size); + yield* Queue.offer(output, value); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 1); + assert.deepEqual(yield* Queue.take(responses), { + _tag: "Chunk", + requestId: "1", + values: [value], + }); + } + yield* Queue.offer(output, "i".repeat(size)); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Ack", requestId: "1" }); + const resumed = yield* Queue.take(responses); + assert.equal(resumed._tag, "Chunk"); + if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); + + yield* Queue.offer(output, "j"); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Interrupt", requestId: "1" }); + assert.equal((yield* Queue.take(responses))._tag, "Exit"); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + } +}); diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts new file mode 100644 index 000000000000..1bd4934f94cc --- /dev/null +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -0,0 +1,67 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type { RpcServer } from "effect/unstable/rpc"; + +const MAX_PENDING_CHUNKS = 8; +const MAX_PENDING_BYTES = 64 * 1024; +const isFull = (sizes: number[]) => + sizes.length >= MAX_PENDING_CHUNKS || + sizes.reduce((total, size) => total + size, 0) >= MAX_PENDING_BYTES; + +export function withTerminalOutputWindow( + protocol: RpcServer.Protocol["Service"], +): RpcServer.Protocol["Service"] { + const windows = new Map(); + let receive: Parameters[0]; + return { + ...protocol, + run: (write) => { + receive = write; + return protocol.run((clientId, message) => + Effect.suspend(() => { + if ( + message._tag === "Request" && + (message.tag === WS_METHODS.terminalAttach || + message.tag === WS_METHODS.subscribeTerminalEvents) + ) { + const key = `${clientId}:${message.id}`; + if (!windows.has(key)) windows.set(key, []); + } else if (message._tag === "Ack") { + const window = windows.get(`${clientId}:${message.requestId}`); + if (window) { + const wasFull = isFull(window); + window.shift(); + if (!wasFull || isFull(window)) return Effect.void; + } + } + return write(clientId, message); + }), + ); + }, + send: (clientId, response, transferables) => + Effect.suspend(() => { + const send = protocol.send(clientId, response, transferables); + if (response._tag === "Exit") { + windows.delete(`${clientId}:${response.requestId}`); + } else if (response._tag === "Chunk") { + const window = windows.get(`${clientId}:${response.requestId}`); + if (window) { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const size = Buffer.byteLength(JSON.stringify(response)); + window.push(size); + if (!isFull(window)) { + return send.pipe( + Effect.andThen(() => + receive(clientId, { + _tag: "Ack", + requestId: response.requestId, + }), + ), + ); + } + } + } + return send; + }), + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0e628879712a..6a16640f7034 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -115,6 +115,7 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; @@ -3446,8 +3447,14 @@ export const websocketRpcRouteLayer = Layer.unwrap( const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); yield* analytics.record("client.connected", clientAnalyticsProps); - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - disableTracing: true, + const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { + const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; + yield* RpcServer.make(WsRpcGroup, { disableTracing: true }).pipe( + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + // @effect-diagnostics-next-line returnEffectInGen:off + return httpEffect; }).pipe( Effect.provide( makeWsRpcLayer( From 6f00d3881a197dd33c2cb43c6a11a9e759e56089 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:15:43 +0530 Subject: [PATCH 04/56] fix(web): remember panel width for each thread (#11310) --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/hooks/useResizableWidth.test.tsx | 67 +++++++++++++++++-- apps/web/src/hooks/useResizableWidth.ts | 50 ++++++++------ apps/web/src/hooks/useResizeDrag.ts | 11 ++- 4 files changed, 103 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 574ed0f372a1..cc8e43f46738 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9364,6 +9364,7 @@ export default function ChatView(props: ChatViewProps) { {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? ( (); +const setItem = vi.fn((key: string, value: string) => savedWidths.set(key, value)); const cancelAnimationFrame = vi.fn(); let events: EventTarget; let frame: FrameRequestCallback | undefined; @@ -40,9 +41,17 @@ function pointer(clientX = 100) { } as unknown as PointerEvent; } -function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; maxWidth?: number }) { +function Panel({ + edge = "left", + maxWidth = 800, + storageKey = "test-panel-width", +}: { + edge?: "left" | "right"; + maxWidth?: number; + storageKey?: string; +}) { const resize = useResizableWidth({ - storageKey: "test-panel-width", + storageKey, defaultWidth: 400, minWidth: 200, maxWidth, @@ -55,6 +64,7 @@ function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; max } beforeEach(async () => { + savedWidths.clear(); captured = false; frame = undefined; style.cursor = ""; @@ -64,7 +74,7 @@ beforeEach(async () => { vi.stubGlobal("window", { addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), - localStorage: { getItem: () => null, setItem }, + localStorage: { getItem: (key: string) => savedWidths.get(key) ?? null, setItem }, }); vi.stubGlobal("document", { body: { style } }); vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { @@ -182,3 +192,52 @@ describe("panel resize cleanup", () => { expect(captured).toBe(false); }); }); + +describe("panel width storage changes", () => { + it("restores separate thread widths without remounting and retains them after reload", async () => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + result.handlers.onPointerUp(pointer(50)); + }); + expect(result.width).toBe(450); + await act(() => renderer.update()); + expect(result.width).toBe(400); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(-100)); + result.handlers.onPointerUp(pointer(-100)); + }); + expect(result.width).toBe(600); + await act(() => renderer.update()); + expect(result.width).toBe(450); + await act(() => renderer.unmount()); + await act(() => { + renderer = create(); + }); + expect(result.width).toBe(600); + }); + + it("cancels an unfinished drag on a thread switch without saving it to either thread", async () => { + savedWidths.set("thread-b", "650"); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + }); + await act(() => frame?.(0)); + expect(result.width).toBe(450); + await act(() => result.handlers.onPointerMove(pointer(25))); + await act(() => renderer.update()); + expect(result.width).toBe(650); + expect(captured).toBe(false); + expect(style.cursor).toBe(""); + await act(() => { + frame?.(0); + result.handlers.onPointerUp(pointer(25)); + }); + expect(result.width).toBe(650); + expect(setItem).not.toHaveBeenCalled(); + await act(() => renderer.update()); + expect(result.width).toBe(400); + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 93ab2218d54a..22ab44bdead8 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -36,7 +36,7 @@ export interface ResizableWidthHandlers { /** * Width state for a side-anchored panel resized via a drag handle on the - * specified edge. Width is read from localStorage on mount and persisted on + * specified edge. Width is read on mount or storage-key changes and persisted on * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). * * The hook updates an internal `width` state during drag (so the panel @@ -58,7 +58,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { ); // No cross-tab subscription: panel width is per-window state. - const [width, setWidth] = useState(() => { + const readWidth = () => { if (typeof window === "undefined") return defaultWidth; try { const stored = getLocalStorageItem(storageKey, WidthSchema); @@ -67,31 +67,39 @@ export function useResizableWidth(options: UseResizableWidthOptions): { console.error("Could not read persisted panel width.", error); return defaultWidth; } - }); + }; + const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); + // Panels stay mounted across threads; restore the destination width before paint. + if (widthState.storageKey !== storageKey) { + setWidthState({ storageKey, width: readWidth() }); + } - const clampedWidth = clamp(width); + const clampedWidth = clamp(widthState.width); const latestOptions = useRef({ clamp, storageKey }); useLayoutEffect(() => { latestOptions.current = { clamp, storageKey }; }, [clamp, storageKey]); - const handlers = useResizeDrag(() => ({ - width: clampedWidth, - edge, - resize(value) { - const nextWidth = latestOptions.current.clamp(value); - setWidth(nextWidth); - return nextWidth; - }, - finish(finalWidth) { - // Commit once at drag-end to avoid 60Hz localStorage writes. - try { - setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); - } catch (error) { - console.error("Could not persist panel width.", error); - } - }, - })); + const handlers = useResizeDrag( + () => ({ + width: clampedWidth, + edge, + resize(value) { + const nextWidth = latestOptions.current.clamp(value); + setWidthState({ storageKey, width: nextWidth }); + return nextWidth; + }, + finish(finalWidth) { + // Commit once at drag-end to avoid 60Hz localStorage writes. + try { + setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); + } catch (error) { + console.error("Could not persist panel width.", error); + } + }, + }), + storageKey, + ); return { width: clampedWidth, handlers }; } diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index e9e645436174..6509a61767d0 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -1,4 +1,4 @@ -import { type PointerEvent, useCallback, useEffect, useRef } from "react"; +import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react"; interface ResizeSession { width: number; @@ -11,6 +11,7 @@ interface ResizeSession { /** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ export function useResizeDrag( start: (event: PointerEvent) => ResizeSession | null, + resetKey?: string, ) { const drag = useRef<{ session: ResizeSession; @@ -54,6 +55,14 @@ export function useResizeDrag( [flush], ); + const previousResetKey = useRef(resetKey); + useLayoutEffect(() => { + if (previousResetKey.current !== resetKey) { + finish(false); + previousResetKey.current = resetKey; + } + }, [finish, resetKey]); + useEffect(() => { const onBlur = () => finish(); window.addEventListener("blur", onBlur); From 9375c779707fb95c06670db6da87441720b2d2e2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 14 Sep 2026 05:02:04 -0700 Subject: [PATCH 05/56] fix(release): preserve updates from npm-based services (#11732) --- apps/server/src/cloud/pinnedRuntime.test.ts | 48 ++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 27 +++++++- packages/shared/package.json | 4 ++ packages/shared/src/legacyCliLauncher.test.ts | 62 +++++++++++++++++++ packages/shared/src/legacyCliLauncher.ts | 36 +++++++++++ scripts/build-npm-platform-packages.test.ts | 52 +++++++++++++--- scripts/build-npm-platform-packages.ts | 7 ++- 7 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/legacyCliLauncher.test.ts create mode 100644 packages/shared/src/legacyCliLauncher.ts diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index e4b16b8f7190..ca090bc2870d 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -1,3 +1,4 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -94,6 +95,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(commands, ["tar"]); assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); + if ((yield* HostProcessPlatform) !== "win32") { + // The old launcher must still be able to start this archive after the + // first npm-to-executable update, including from the final directory. + yield* fs.writeFileString(paths.entryPath, '#!/bin/sh\nprintf "%s\\n" "$@"\n'); + yield* fs.chmod(paths.entryPath, 0o755); + const runner = yield* ProcessRunner.make(); + const legacyStart = yield* runner.run({ + command: process.execPath, + args: [path.join(paths.versionDir, "node_modules/t3/dist/bin.mjs"), "serve"], + }); + assert.equal(Number(legacyStart.code), 0, legacyStart.stderr); + assert.equal(legacyStart.stdout.trim(), "serve"); + } }), ); @@ -209,6 +223,40 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); + it.effect("backfills a cached archive without downloading or replacing it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-legacy-cache-" }); + const cached = pinnedRuntimePaths(path, baseDir, version, "linux"); + const legacyEntry = path.join(cached.versionDir, "node_modules/t3/dist/bin.mjs"); + yield* fs.makeDirectory(cached.versionDir, { recursive: true }); + yield* fs.writeFileString(cached.entryPath, "cached executable\n"); + yield* fs.writeFileString(cached.sentinelPath, `${version}\n`); + const requests: string[] = []; + const commands: string[] = []; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path, commands), + validate: () => + fs.exists(legacyEntry).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing legacy entry"))), + Effect.orDie, + ), + }); + assert.deepEqual(requests, []); + assert.deepEqual(commands, []); + assert.equal(yield* fs.readFileString(cached.entryPath), "cached executable\n"); + assert.equal(yield* fs.readFileString(cached.sentinelPath), `${version}\n`); + }), + ); + it.effect("preserves a completed runtime when validation fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 680d80e46cd1..ef929ebb8236 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -8,6 +8,7 @@ import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import { CLI_RELEASE_CHECKSUMS_FILE, cliArchiveFileName, @@ -229,6 +230,24 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( input: PinnedRuntimeInstallInput, ) { const { fs } = input; + // Old service launchers still use the npm entry point, including when an + // archive was cached before this compatibility wrapper existed. + const ensureLegacyEntry = Effect.fn("cloud.pinned_runtime.ensure_legacy_entry")( + function* (versionDir: string) { + const legacyDir = input.path.join(versionDir, "node_modules", "t3", "dist"); + const entryPath = input.path.join(legacyDir, "bin.mjs"); + if (yield* fs.exists(entryPath)) return; + yield* fs.makeDirectory(legacyDir, { recursive: true }); + yield* fs.writeFileString(entryPath, legacyCliLauncherScript("archive")); + }, + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "writing the legacy service entry point", + cause, + }), + ), + ); const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.versionDir), @@ -242,6 +261,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( const alreadyPinned = entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; if (alreadyPinned) { + yield* ensureLegacyEntry(paths.versionDir); yield* input.validate(paths); return paths; } @@ -290,6 +310,8 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { yield* installFromArchive(input, stagingDir); + yield* ensureLegacyEntry(stagingDir); + yield* input.validate(stagingPaths); yield* fs .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) @@ -328,7 +350,10 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ), ); - if (!published) yield* input.validate(paths); + if (!published) { + yield* ensureLegacyEntry(paths.versionDir); + yield* input.validate(paths); + } return paths; }).pipe( Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), diff --git a/packages/shared/package.json b/packages/shared/package.json index d30c26c2de79..b213261b0c3a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./legacyCliLauncher": { + "types": "./src/legacyCliLauncher.ts", + "import": "./src/legacyCliLauncher.ts" + }, "./delimitedPreview": { "types": "./src/delimitedPreview.ts", "import": "./src/delimitedPreview.ts" diff --git a/packages/shared/src/legacyCliLauncher.test.ts b/packages/shared/src/legacyCliLauncher.test.ts new file mode 100644 index 000000000000..0a0f3feabb76 --- /dev/null +++ b/packages/shared/src/legacyCliLauncher.test.ts @@ -0,0 +1,62 @@ +// @effect-diagnostics nodeBuiltinImport:off - Exercises real Node IPC and process signals. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; + +import { legacyCliLauncherScript } from "./legacyCliLauncher.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- This test launches a real host executable. +const hostPlatform = NodeOS.platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Match the real executable used by the subprocess. +const hostArch = NodeOS.arch(); + +// The fixture executable uses a POSIX shebang. The wrapper itself also runs on Windows. +it.skipIf(hostPlatform === "win32").each(["npm", "archive"] as const)( + "keeps %s service IPC, arguments, and termination connected", + async (distribution) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-launcher-")); + const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs"); + const executable = + distribution === "archive" + ? NodePath.join(root, "t3") + : NodePath.join(root, `node_modules/@t3code/t3-${hostPlatform}-${hostArch}/t3`); + await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); + await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(NodePath.dirname(executable), "package.json"), + '{"type":"commonjs"}', + ); + await NodeFSP.writeFile(entry, legacyCliLauncherScript(distribution)); + await NodeFSP.writeFile( + executable, + `#!${process.execPath} +process.on("SIGTERM", () => process.exit(23)); +process.on("message", message => process.send({ reply: message })); +process.send({ args: process.argv.slice(2) }); +`, + ); + await NodeFSP.chmod(executable, 0o755); + const child = NodeChildProcess.fork(entry, ["serve", "a path with spaces"], { silent: true }); + try { + expect((await NodeEvents.EventEmitter.once(child, "message"))[0]).toEqual({ + args: ["serve", "a path with spaces"], + }); + const reply = NodeEvents.EventEmitter.once(child, "message"); + child.send({ type: "trial-accepted" }); + expect((await reply)[0]).toEqual({ reply: { type: "trial-accepted" } }); + const exit = NodeEvents.EventEmitter.once(child, "exit"); + child.kill("SIGTERM"); + expect(await exit).toEqual([23, null]); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exit = NodeEvents.EventEmitter.once(child, "exit"); + child.kill("SIGTERM"); + await exit; + } + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/shared/src/legacyCliLauncher.ts b/packages/shared/src/legacyCliLauncher.ts new file mode 100644 index 000000000000..f159c41d3e08 --- /dev/null +++ b/packages/shared/src/legacyCliLauncher.ts @@ -0,0 +1,36 @@ +/** Node entry point for service launchers installed before executable releases. */ +export function legacyCliLauncherScript(distribution: "npm" | "archive"): string { + const executable = + distribution === "npm" + ? 'join(dirname(require.resolve("@t3code/t3-" + process.platform + "-" + process.arch + "/package.json")), executableName)' + : 'resolve(dirname(fileURLToPath(import.meta.url)), "../../..", executableName)'; + return `import { spawn } from "node:child_process"; +import { constants } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const executableName = process.platform === "win32" ? "t3.exe" : "t3"; +const executable = ${executable}; +const ipc = process.send !== undefined; +const child = spawn(executable, process.argv.slice(2), { + stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", +}); +const fail = (error) => { + if (!error) return; + process.stderr.write("t3: " + error.message + "\\n"); + child.kill("SIGTERM"); + process.exitCode = 1; +}; +if (ipc) { + process.on("message", (message) => { if (child.connected) child.send(message, fail); }); + child.on("message", (message) => { if (process.connected) process.send(message, fail); }); + process.on("disconnect", () => { if (child.connected) child.disconnect(); }); +} +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => child.kill(signal)); +} +child.on("error", (error) => { fail(error); process.exit(1); }); +child.on("exit", (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] || 1))); +`; +} diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 2e3a35a0e9c9..47568afbcb67 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -1,3 +1,4 @@ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -151,7 +152,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.equal(launcherManifest.name, "t3"); assert.equal(launcherManifest.version, VERSION); assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); - assert.deepStrictEqual(launcherManifest.files, ["bin"]); + assert.deepStrictEqual(launcherManifest.files, ["bin", "dist"]); assert.deepStrictEqual(launcherManifest.optionalDependencies, { "@t3code/t3-darwin-arm64": VERSION, "@t3code/t3-linux-x64": VERSION, @@ -182,13 +183,50 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { // NODE_PATH stands in for node_modules: require.resolve finds the // platform package there exactly as it would after `npm install`. + const hostPlatform = yield* HostProcessPlatform; + const hostArch = yield* HostProcessArchitecture; const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record; - const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { - cwd: launcherDir, - env, - }); - assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234"); - assert.equal(passthrough.exitCode, 7); + if (KEYS.some((key) => key === `${hostPlatform}-${hostArch}`)) { + const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { + cwd: launcherDir, + env, + }); + assert.equal( + passthrough.stdout.trim(), + `stub ${hostPlatform}-${hostArch} serve --port 1234`, + ); + assert.equal(passthrough.exitCode, 7); + + // Run the entry point used by already-installed service updaters from + // the published tarball, including their preflight arguments. + const installedLauncher = path.join(fixture.root, "installed-launcher"); + yield* fs.makeDirectory(installedLauncher); + const unpack = yield* run( + "tar", + ["-xf", path.join(fixture.outputDir, "t3.tgz"), "-C", installedLauncher], + { + cwd: fixture.root, + }, + ); + assert.equal(unpack.exitCode, 0, unpack.stderr); + const legacy = yield* run( + process.execPath, + [ + "dist/bin.mjs", + "__service-preflight", + "--database-path", + "a database.sqlite", + "--launcher-protocol", + "2", + ], + { cwd: path.join(installedLauncher, "package"), env }, + ); + assert.equal( + legacy.stdout.trim(), + `stub ${hostPlatform}-${hostArch} __service-preflight --database-path a database.sqlite --launcher-protocol 2`, + ); + assert.equal(legacy.exitCode, 7); + } const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], { cwd: launcherDir, diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index f9417426b82b..f73610d10aa8 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -19,6 +19,7 @@ * bundleDependencies needs an arborist tree these flattened installs are * not), whereas `npm publish ` uploads the bytes as given. */ +import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -137,7 +138,7 @@ export function npmLauncherPackageManifest( license: serverPackageJson.license, repository: serverPackageJson.repository, bin: { t3: "./bin/t3.js" }, - files: ["bin"], + files: ["bin", "dist"], optionalDependencies: Object.fromEntries( platformKeys.map((key) => [npmPlatformPackageName(key), version]), ), @@ -342,6 +343,10 @@ const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: const launcherScript = path.join(stageDir, "bin/t3.js"); yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT); yield* fs.chmod(launcherScript, 0o755); + // Older service updaters and launchers run this exact path with Node. + // Keep it in the package so they can preflight and start the new executable. + yield* fs.makeDirectory(path.join(stageDir, "dist")); + yield* fs.writeFileString(path.join(stageDir, "dist/bin.mjs"), legacyCliLauncherScript("npm")); const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url)); if (yield* fs.exists(readme)) { yield* fs.copyFile(readme, path.join(stageDir, "README.md")); From 8b1ea4dd2465f3cf3cfa02d6878beaa1ec22e71a Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 14 Sep 2026 09:10:39 -0700 Subject: [PATCH 06/56] 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 07/56] 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 08/56] 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 09/56] 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 10/56] 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 ? (