From de3b36e8277d01352aa38d35a8174bcbafeba091 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 13:31:08 +0000 Subject: [PATCH 1/8] test(web): failed terminal resize is resent (reproduces #31013) --- .../ThreadTerminalDrawer.browser.test.tsx | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 9fd2758747533..3aea499328a57 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -5,6 +5,7 @@ import { type ScopedThreadRef, type FilesystemEntryMetadata, } from "@t3tools/contracts"; +import { Cause } from "effect"; import { Atom, AsyncResult } from "effect/reactivity"; import type { GhosttyTerminalSurfaceOptions } from "~/terminal/ghostty/surface"; import { AppAtomRegistryProvider, appAtomRegistry } from "../rpc/atomRegistry"; @@ -17,15 +18,26 @@ const fixture = vi.hoisted(() => ({ statusByThreadId: new Map(), pendingSurfaces: [] as Array<() => void>, surfaceOptions: [] as GhosttyTerminalSurfaceOptions[], + reportedSize: null as { cols: number; rows: number } | null, + resizeCommand: {}, + runResize: (_input: unknown): Promise> => + Promise.resolve(AsyncResult.success(undefined)), metadata: new Map< string, Atom.Writable> >(), })); -// Every surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. +// Every other surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. const fakeSurface: object = new Proxy( - { input: document.createElement("textarea") }, + { + input: document.createElement("textarea"), + // The real surface re-reports its measured grid. + resendSize: () => { + const size = fixture.reportedSize; + if (size) fixture.surfaceOptions.at(-1)!.onResize(size.cols, size.rows); + }, + }, { get: (target, key) => key === "then" ? undefined : key in target ? Reflect.get(target, key) : () => undefined, @@ -61,7 +73,8 @@ vi.mock("../state/session", () => ({ readEnvironmentScope: () => true, })); vi.mock("../state/use-atom-command", () => ({ - useAtomCommand: () => async () => ({ _tag: "Success" }), + useAtomCommand: (command: object) => async (input: unknown) => + command === fixture.resizeCommand ? fixture.runResize(input) : { _tag: "Success" }, })); // The commands are replaced above; importing their runtime would connect a client. vi.mock("../state/server", async () => { @@ -69,7 +82,9 @@ vi.mock("../state/server", async () => { const config = Atom.make(null); return { serverEnvironment: { configValueAtom: () => config } }; }); -vi.mock("../state/terminal", () => ({ terminalEnvironment: { write: {}, resize: {} } })); +vi.mock("../state/terminal", () => ({ + terminalEnvironment: { write: {}, resize: fixture.resizeCommand }, +})); vi.mock("../state/preview", () => ({ previewEnvironment: { open: {} } })); vi.mock("../state/projects", async () => { const { Atom, AsyncResult } = await import("effect/reactivity"); @@ -148,6 +163,8 @@ function setup() { ]); fixture.pendingSurfaces = []; fixture.surfaceOptions = []; + fixture.reportedSize = null; + fixture.runResize = () => Promise.resolve(AsyncResult.success(undefined)); fixture.metadata.clear(); return { original, other }; } @@ -248,3 +265,33 @@ it("only activates terminal paths confirmed to exist on their environment", asyn await hover("~/notes.ts:5:2"); expect(canActivate("~/notes.ts:5:2")).toBe(true); }); + +it("resends the terminal size after a resize request fails", async () => { + const { other: threadRef } = setup(); + const resizes: unknown[] = []; + fixture.runResize = (input) => { + resizes.push(input); + return Promise.resolve( + resizes.length === 1 + ? AsyncResult.failure(Cause.die(new Error("resize rejected"))) + : AsyncResult.success(undefined), + ); + }; + await using _screen = await render( {}} />); + using _timers = fakeTimers(); + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + await act(async () => { + fixture.reportedSize = { cols: 57, rows: 41 }; + fixture.surfaceOptions.at(-1)!.onResize(57, 41); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + const resize = { + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "term-1", cols: 57, rows: 41 }, + }; + expect(resizes).toEqual([resize, resize]); +}); From b4669292ebda470f51f8c4d5b50df316e752b2e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 13:40:53 +0000 Subject: [PATCH 2/8] fix(web): a failed terminal resize is sent again The surface reports each settled grid once, and the drawer dropped the resize command's result, so a failed request left the PTY at its old width until the next drag. After a failure (not an interrupt), ask the surface to resend its current grid once; a retry that also fails stops. Fixes pingdotgg/t3code#31013 --- .../ThreadTerminalDrawer.browser.test.tsx | 22 +++++++++++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 14 ++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 3aea499328a57..420f07af8e6b8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -295,3 +295,25 @@ it("resends the terminal size after a resize request fails", async () => { }; expect(resizes).toEqual([resize, resize]); }); + +it("stops resending when the resize keeps failing", async () => { + const { other: threadRef } = setup(); + const resizes: unknown[] = []; + fixture.runResize = (input) => { + resizes.push(input); + return Promise.resolve(AsyncResult.failure(Cause.die(new Error("resize rejected")))); + }; + await using _screen = await render( {}} />); + using _timers = fakeTimers(); + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + await act(async () => { + fixture.reportedSize = { cols: 57, rows: 41 }; + fixture.surfaceOptions.at(-1)!.onResize(57, 41); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(resizes).toHaveLength(2); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 45318dd598133..3355ce8625ab1 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -32,6 +32,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { @@ -635,14 +636,23 @@ function TerminalSessionViewport({ input: { threadId, terminalId, data }, }), ); - const resizeTerminal = useEffectEvent((cols: number, rows: number) => { + const resizeRetriedRef = useRef(false); + const resizeTerminal = useEffectEvent(async (cols: number, rows: number) => { if (writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()) === null) { return; } - return runTerminalResize({ + const result = await runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, }); + // The surface only reports a new grid, so retry a failure once. Resend the current grid, not + // this one: a newer resize may already be queued behind it. + const retry = + result._tag === "Failure" && + !Cause.hasInterruptsOnly(result.cause) && + !resizeRetriedRef.current; + resizeRetriedRef.current = retry; + if (retry) terminalRef.current?.resendSize(); }); const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); From eb78c4aa0f6c0fecffed39c3698488d0d7efb3f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 13:44:52 +0000 Subject: [PATCH 3/8] test(client-runtime): failed terminal resize is resent (reproduces #31013) --- .../client-runtime/src/state/terminal.test.ts | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts index 6fb2d21b19ea1..5ab84547b09ba 100644 --- a/packages/client-runtime/src/state/terminal.test.ts +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -1,4 +1,5 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { expect, it } from "vite-plus/test"; @@ -78,12 +79,16 @@ function setup(options: { readonly initialInput?: string } = {}) { AsyncResult.initial(true), ); const access = { canOperate: true }; + const resizeFailures = { remaining: 0 }; const calls: Array<{ readonly command: string; readonly input: unknown }> = []; const command = (name: string) => ({ label: name, run: async (_registry: AtomRegistry.AtomRegistry, value: { readonly input: unknown }) => { calls.push({ command: name, input: value.input }); - return AsyncResult.success(undefined); + if (name !== "resize" || resizeFailures.remaining === 0) + return AsyncResult.success(undefined); + resizeFailures.remaining -= 1; + return AsyncResult.failure(Cause.fail("offline")); }, }); const exits: Array = []; @@ -126,7 +131,21 @@ function setup(options: { readonly initialInput?: string } = {}) { registry.set(observation, snapshot(state)); const mount = (mode: TerminalSessionMode) => registry.mount(session.driver(mode)); const of = (name: string) => calls.filter((call) => call.command === name).map((c) => c.input); - return { session, attached, access, flush, detach, emit, observe, mount, calls, of, exits, live }; + return { + session, + attached, + access, + resizeFailures, + flush, + detach, + emit, + observe, + mount, + calls, + of, + exits, + live, + }; } it("replays the measured grid on every new attachment generation", () => { @@ -154,6 +173,23 @@ it("replays the measured grid on every new attachment generation", () => { ]); }); +it("sends a failed resize again, once per failure streak", async () => { + const { session, attached, resizeFailures, emit, mount, of } = setup(); + resizeFailures.remaining = 4; + mount(attached); + emit({ status: "running", version: 1 }); + await expect.poll(() => of("resize")).toHaveLength(2); + + session.reportGridSize({ cols: 100, rows: 30 }); + await expect.poll(() => of("resize")).toHaveLength(4); + expect(of("resize")).toEqual([ + { threadId, terminalId: "term-1", cols: 120, rows: 40 }, + { threadId, terminalId: "term-1", cols: 120, rows: 40 }, + { threadId, terminalId: "term-1", cols: 100, rows: 30 }, + { threadId, terminalId: "term-1", cols: 100, rows: 30 }, + ]); +}); + it("writes the launch's initial input once the first snapshot lands", () => { const { attached, emit, mount, calls, of } = setup({ initialInput: "pnpm dev\r" }); mount(attached); From 6ada42e1f1c25d3bc2ffc9dd2c2e7fd7027ae3b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 13:47:20 +0000 Subject: [PATCH 4/8] fix(mobile): a failed terminal resize is sent again The shared terminal session dropped the resize command's result and only sends again on a new grid or output generation, so a failed request left the PTY at its old width. After a failure (not an interrupt), resend the current grid once; a retry that also fails stops, and a success resets it. Fixes pingdotgg/t3code#31013 --- packages/client-runtime/src/state/terminal.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index a39e47b1340b4..a65acb8e31a18 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -18,6 +18,7 @@ import { createAtomCommandScheduler, createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily, + isAtomCommandInterrupted, runAtomCommand, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -262,12 +263,20 @@ export function createTerminalSession( let lastStatus: TerminalBufferState["status"] | null = null; let loggedFirstOutput = false; let resizeRegistry: AtomRegistry.AtomRegistry | null = null; + let resizeRetried = false; const sendResize = () => { if (generation === null || resizeRegistry === null || !options.canOperate()) return; void runAtomCommand(resizeRegistry, atoms.resize, { environmentId, input: { threadId, terminalId, cols: gridSize.cols, rows: gridSize.rows }, + }).then((result) => { + // Only a new grid or generation sends again, so retry a failure once. Resend the current + // grid, not this one: a newer resize may already be queued behind it. + const retry = + result._tag === "Failure" && !isAtomCommandInterrupted(result) && !resizeRetried; + resizeRetried = retry; + if (retry) sendResize(); }); }; From e5a44fd25ee22aecf5f68421c3a42f54d22628f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 14:06:06 +0000 Subject: [PATCH 5/8] fix(mobile): a failed terminal resize is retried in every attachment generation The retry allowance was a shared flag, set even when the retry never went out (the attachment had already dropped), so the next generation's first failed resize was not retried. The retry is now an argument of the send, and the test settles each resize itself instead of polling. --- .../client-runtime/src/state/terminal.test.ts | 36 ++++++++++++------- packages/client-runtime/src/state/terminal.ts | 10 +++--- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts index 5ab84547b09ba..ddf912c7aa082 100644 --- a/packages/client-runtime/src/state/terminal.test.ts +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -10,6 +10,7 @@ import { type TerminalLifecycleEvent, type TerminalSessionMode, } from "./terminal.ts"; +import type { AtomCommandResult } from "./runtime.ts"; import { EMPTY_TERMINAL_BUFFER_STATE, type TerminalBufferState } from "./terminalSession.ts"; function run(events: ReadonlyArray) { @@ -79,18 +80,22 @@ function setup(options: { readonly initialInput?: string } = {}) { AsyncResult.initial(true), ); const access = { canOperate: true }; - const resizeFailures = { remaining: 0 }; const calls: Array<{ readonly command: string; readonly input: unknown }> = []; + // Resize requests stay in flight until the test settles them. + const resizeResults: Array<(result: AtomCommandResult) => void> = []; const command = (name: string) => ({ label: name, - run: async (_registry: AtomRegistry.AtomRegistry, value: { readonly input: unknown }) => { + run: (_registry: AtomRegistry.AtomRegistry, value: { readonly input: unknown }) => { calls.push({ command: name, input: value.input }); - if (name !== "resize" || resizeFailures.remaining === 0) - return AsyncResult.success(undefined); - resizeFailures.remaining -= 1; - return AsyncResult.failure(Cause.fail("offline")); + if (name !== "resize") return Promise.resolve(AsyncResult.success(undefined)); + return new Promise>((resolve) => resizeResults.push(resolve)); }, }); + // The session sees a settled result within a fixed number of microtasks. + const failResize = async (index: number) => { + resizeResults[index]!(AsyncResult.failure(Cause.fail("offline"))); + for (let tick = 0; tick < 10; tick++) await Promise.resolve(); + }; const exits: Array = []; const live: Array = []; const session = createTerminalSession( @@ -135,7 +140,7 @@ function setup(options: { readonly initialInput?: string } = {}) { session, attached, access, - resizeFailures, + failResize, flush, detach, emit, @@ -173,16 +178,23 @@ it("replays the measured grid on every new attachment generation", () => { ]); }); -it("sends a failed resize again, once per failure streak", async () => { - const { session, attached, resizeFailures, emit, mount, of } = setup(); - resizeFailures.remaining = 4; +it("sends each failed resize again once, in every attachment generation", async () => { + const { session, attached, failResize, emit, detach, mount, of } = setup(); mount(attached); emit({ status: "running", version: 1 }); - await expect.poll(() => of("resize")).toHaveLength(2); + detach(); + await failResize(0); + expect(of("resize")).toHaveLength(1); + emit({ status: "running", version: 1, generation: 2 }); + await failResize(1); + expect(of("resize")).toHaveLength(3); + await failResize(2); session.reportGridSize({ cols: 100, rows: 30 }); - await expect.poll(() => of("resize")).toHaveLength(4); + await failResize(3); + await failResize(4); expect(of("resize")).toEqual([ + { threadId, terminalId: "term-1", cols: 120, rows: 40 }, { threadId, terminalId: "term-1", cols: 120, rows: 40 }, { threadId, terminalId: "term-1", cols: 120, rows: 40 }, { threadId, terminalId: "term-1", cols: 100, rows: 30 }, diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index a65acb8e31a18..26d319f1e88d1 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -263,9 +263,8 @@ export function createTerminalSession( let lastStatus: TerminalBufferState["status"] | null = null; let loggedFirstOutput = false; let resizeRegistry: AtomRegistry.AtomRegistry | null = null; - let resizeRetried = false; - const sendResize = () => { + const sendResize = (retry = false) => { if (generation === null || resizeRegistry === null || !options.canOperate()) return; void runAtomCommand(resizeRegistry, atoms.resize, { environmentId, @@ -273,10 +272,9 @@ export function createTerminalSession( }).then((result) => { // Only a new grid or generation sends again, so retry a failure once. Resend the current // grid, not this one: a newer resize may already be queued behind it. - const retry = - result._tag === "Failure" && !isAtomCommandInterrupted(result) && !resizeRetried; - resizeRetried = retry; - if (retry) sendResize(); + if (!retry && result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + sendResize(true); + } }); }; From db28804cc9990671600c79fee954702b02d53897 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 14:06:29 +0000 Subject: [PATCH 6/8] fix(web): a failed terminal resize is retried in every session A retry that reached a non-writable session left the retried flag set, so the next writable session's first failed resize was not retried. The pending retry now belongs to the generation that failed and is consumed by the next report. --- .../ThreadTerminalDrawer.browser.test.tsx | 43 ++++++++++++++----- .../src/components/ThreadTerminalDrawer.tsx | 20 ++++----- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 420f07af8e6b8..2ebf39127c212 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -1,4 +1,7 @@ -import { EMPTY_TERMINAL_SESSION_STATE } from "@t3tools/client-runtime/state/terminal"; +import { + EMPTY_TERMINAL_SESSION_STATE, + type TerminalSessionState, +} from "@t3tools/client-runtime/state/terminal"; import { EnvironmentId, ThreadId, @@ -16,6 +19,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; const fixture = vi.hoisted(() => ({ statusByThreadId: new Map(), + sessions: new Map>(), pendingSurfaces: [] as Array<() => void>, surfaceOptions: [] as GhosttyTerminalSurfaceOptions[], reportedSize: null as { cols: number; rows: number } | null, @@ -55,13 +59,15 @@ vi.mock("~/terminal/ghostty/surface", () => ({ vi.mock("../state/terminalSessions", async () => { const Atom = await import("effect/reactivity/Atom"); const sessions = Atom.family((_environmentId: EnvironmentId) => - Atom.family((threadId: ThreadId) => - Atom.make({ + Atom.family((threadId: ThreadId) => { + const session = Atom.make({ ...EMPTY_TERMINAL_SESSION_STATE, status: fixture.statusByThreadId.get(threadId) ?? "closed", version: 1, - }), - ), + }); + fixture.sessions.set(threadId, session); + return session; + }), ); return { attachedTerminalSessionAtom: (environmentId: EnvironmentId, terminal: { threadId: ThreadId }) => @@ -296,15 +302,26 @@ it("resends the terminal size after a resize request fails", async () => { expect(resizes).toEqual([resize, resize]); }); -it("stops resending when the resize keeps failing", async () => { +it("resends a failed resize once in each writable session", async () => { const { other: threadRef } = setup(); const resizes: unknown[] = []; + const failure = AsyncResult.failure(Cause.die(new Error("resize rejected"))); + let failFirstResize = () => {}; fixture.runResize = (input) => { resizes.push(input); - return Promise.resolve(AsyncResult.failure(Cause.die(new Error("resize rejected")))); + if (resizes.length > 1) return Promise.resolve(failure); + return new Promise((resolve) => { + failFirstResize = () => resolve(failure); + }); }; + const running = (generation: number): TerminalSessionState => ({ + ...EMPTY_TERMINAL_SESSION_STATE, + status: "running", + version: 1, + output: { ...EMPTY_TERMINAL_SESSION_STATE.output, generation }, + }); await using _screen = await render( {}} />); - using _timers = fakeTimers(); + const session = fixture.sessions.get(threadRef.threadId)!; await act(async () => { fixture.pendingSurfaces.shift()?.(); }); @@ -312,8 +329,14 @@ it("stops resending when the resize keeps failing", async () => { fixture.reportedSize = { cols: 57, rows: 41 }; fixture.surfaceOptions.at(-1)!.onResize(57, 41); }); + // The first request fails while the attachment reconnects, so its retry is not sent. await act(async () => { - await vi.advanceTimersByTimeAsync(10_000); + appAtomRegistry.set(session, { ...running(1), version: 0 }); + failFirstResize(); + }); + expect(resizes).toHaveLength(1); + await act(async () => { + appAtomRegistry.set(session, running(2)); }); - expect(resizes).toHaveLength(2); + expect(resizes).toHaveLength(3); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 3355ce8625ab1..553f9ba52c0c0 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -636,23 +636,23 @@ function TerminalSessionViewport({ input: { threadId, terminalId, data }, }), ); - const resizeRetriedRef = useRef(false); + // The surface reports a resent grid like any other, so the next report is the retry. + const resizeRetryGenerationRef = useRef(null); const resizeTerminal = useEffectEvent(async (cols: number, rows: number) => { - if (writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()) === null) { - return; - } + const generation = writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()); + const retry = generation !== null && generation === resizeRetryGenerationRef.current; + resizeRetryGenerationRef.current = null; + if (generation === null) return; const result = await runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, }); // The surface only reports a new grid, so retry a failure once. Resend the current grid, not // this one: a newer resize may already be queued behind it. - const retry = - result._tag === "Failure" && - !Cause.hasInterruptsOnly(result.cause) && - !resizeRetriedRef.current; - resizeRetriedRef.current = retry; - if (retry) terminalRef.current?.resendSize(); + if (!retry && result._tag === "Failure" && !Cause.hasInterruptsOnly(result.cause)) { + resizeRetryGenerationRef.current = generation; + terminalRef.current?.resendSize(); + } }); const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); From f6525a2d4c05a731fc1b1715fb672bee6683f92b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 14:14:28 +0000 Subject: [PATCH 7/8] refactor(web): reuse the interrupt helper for the resize retry Drop fake timers the resend test does not need, and note why the client-runtime test flushes microtasks. --- .../src/components/ThreadTerminalDrawer.browser.test.tsx | 4 ---- apps/web/src/components/ThreadTerminalDrawer.tsx | 6 ++---- packages/client-runtime/src/state/terminal.test.ts | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 2ebf39127c212..eace58be5bd79 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -284,7 +284,6 @@ it("resends the terminal size after a resize request fails", async () => { ); }; await using _screen = await render( {}} />); - using _timers = fakeTimers(); await act(async () => { fixture.pendingSurfaces.shift()?.(); }); @@ -292,9 +291,6 @@ it("resends the terminal size after a resize request fails", async () => { fixture.reportedSize = { cols: 57, rows: 41 }; fixture.surfaceOptions.at(-1)!.onResize(57, 41); }); - await act(async () => { - await vi.advanceTimersByTimeAsync(10_000); - }); const resize = { environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, terminalId: "term-1", cols: 57, rows: 41 }, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 553f9ba52c0c0..de5d4d2ed3c21 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -32,7 +32,6 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { @@ -647,9 +646,8 @@ function TerminalSessionViewport({ environmentId, input: { threadId, terminalId, cols, rows }, }); - // The surface only reports a new grid, so retry a failure once. Resend the current grid, not - // this one: a newer resize may already be queued behind it. - if (!retry && result._tag === "Failure" && !Cause.hasInterruptsOnly(result.cause)) { + // Resend the current grid, not this one: a newer resize may already be queued behind it. + if (!retry && result._tag === "Failure" && !isAtomCommandInterrupted(result)) { resizeRetryGenerationRef.current = generation; terminalRef.current?.resendSize(); } diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts index ddf912c7aa082..116fdd3993a0a 100644 --- a/packages/client-runtime/src/state/terminal.test.ts +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -94,6 +94,7 @@ function setup(options: { readonly initialInput?: string } = {}) { // The session sees a settled result within a fixed number of microtasks. const failResize = async (index: number) => { resizeResults[index]!(AsyncResult.failure(Cause.fail("offline"))); + // No promise marks the retry decision, so flush the command's continuation chain. for (let tick = 0; tick < 10; tick++) await Promise.resolve(); }; const exits: Array = []; From f7eb0e8e42fa50354555632fb1166a3fda98e149 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 21:12:45 +0000 Subject: [PATCH 8/8] test(web): fold the resize retry cases into one Co-Authored-By: Claude Opus 5.5 --- .../ThreadTerminalDrawer.browser.test.tsx | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index eace58be5bd79..37fd527142aba 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -272,32 +272,6 @@ it("only activates terminal paths confirmed to exist on their environment", asyn expect(canActivate("~/notes.ts:5:2")).toBe(true); }); -it("resends the terminal size after a resize request fails", async () => { - const { other: threadRef } = setup(); - const resizes: unknown[] = []; - fixture.runResize = (input) => { - resizes.push(input); - return Promise.resolve( - resizes.length === 1 - ? AsyncResult.failure(Cause.die(new Error("resize rejected"))) - : AsyncResult.success(undefined), - ); - }; - await using _screen = await render( {}} />); - await act(async () => { - fixture.pendingSurfaces.shift()?.(); - }); - await act(async () => { - fixture.reportedSize = { cols: 57, rows: 41 }; - fixture.surfaceOptions.at(-1)!.onResize(57, 41); - }); - const resize = { - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, terminalId: "term-1", cols: 57, rows: 41 }, - }; - expect(resizes).toEqual([resize, resize]); -}); - it("resends a failed resize once in each writable session", async () => { const { other: threadRef } = setup(); const resizes: unknown[] = []; @@ -334,5 +308,9 @@ it("resends a failed resize once in each writable session", async () => { await act(async () => { appAtomRegistry.set(session, running(2)); }); - expect(resizes).toHaveLength(3); + const resize = { + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "term-1", cols: 57, rows: 41 }, + }; + expect(resizes).toEqual([resize, resize, resize]); });