diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 9fd2758747533..37fd527142aba 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -1,10 +1,14 @@ -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, 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"; @@ -15,17 +19,29 @@ 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, + 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, @@ -43,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 }) => @@ -61,7 +79,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 +88,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 +169,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 +271,46 @@ 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 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); + 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( {}} />); + const session = fixture.sessions.get(threadRef.threadId)!; + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + await act(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 () => { + appAtomRegistry.set(session, { ...running(1), version: 0 }); + failFirstResize(); + }); + expect(resizes).toHaveLength(1); + await act(async () => { + appAtomRegistry.set(session, running(2)); + }); + const resize = { + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "term-1", cols: 57, rows: 41 }, + }; + expect(resizes).toEqual([resize, resize, resize]); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 049589d0b671a..134869ea04e0a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -635,14 +635,22 @@ function TerminalSessionViewport({ input: { threadId, terminalId, data }, }), ); - const resizeTerminal = useEffectEvent((cols: number, rows: number) => { - if (writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()) === null) { - return; - } - return runTerminalResize({ + // 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) => { + 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 }, }); + // 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(); + } }); const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts index 6fb2d21b19ea1..116fdd3993a0a 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"; @@ -9,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,13 +81,22 @@ function setup(options: { readonly initialInput?: string } = {}) { ); const access = { canOperate: true }; 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 }); - return AsyncResult.success(undefined); + 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"))); + // 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 = []; const live: Array = []; const session = createTerminalSession( @@ -126,7 +137,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, + failResize, + flush, + detach, + emit, + observe, + mount, + calls, + of, + exits, + live, + }; } it("replays the measured grid on every new attachment generation", () => { @@ -154,6 +179,30 @@ it("replays the measured grid on every new attachment generation", () => { ]); }); +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 }); + 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 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 }, + { 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); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index a39e47b1340b4..26d319f1e88d1 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"; @@ -263,11 +264,17 @@ export function createTerminalSession( let loggedFirstOutput = false; let resizeRegistry: AtomRegistry.AtomRegistry | null = null; - const sendResize = () => { + const sendResize = (retry = false) => { 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. + if (!retry && result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + sendResize(true); + } }); };