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 1/4] 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}
+
+ }
/>
) : (
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 ?
: 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 2/4] 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 3/4] 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 4/4] 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"));