Skip to content
Open
84 changes: 75 additions & 9 deletions apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,17 +19,29 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer";

const fixture = vi.hoisted(() => ({
statusByThreadId: new Map<string, "running" | "exited">(),
sessions: new Map<string, Atom.Writable<TerminalSessionState>>(),
pendingSurfaces: [] as Array<() => void>,
surfaceOptions: [] as GhosttyTerminalSurfaceOptions[],
reportedSize: null as { cols: number; rows: number } | null,
resizeCommand: {},
runResize: (_input: unknown): Promise<AsyncResult.AsyncResult<unknown, unknown>> =>
Promise.resolve(AsyncResult.success(undefined)),
metadata: new Map<
string,
Atom.Writable<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>
>(),
}));

// 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,
Expand All @@ -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<TerminalSessionState>({
...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 }) =>
Expand All @@ -61,15 +79,18 @@ 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 () => {
const Atom = await import("effect/reactivity/Atom");
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");
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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(<Drawer threadRef={threadRef} onCloseTerminal={() => {}} />);
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]);
});
18 changes: 13 additions & 5 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(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<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR);
const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");
Expand Down
55 changes: 52 additions & 3 deletions packages/client-runtime/src/state/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<TerminalLifecycleEvent>) {
Expand Down Expand Up @@ -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, string>) => 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<AtomCommandResult<void, string>>((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<true> = [];
const live: Array<boolean> = [];
const session = createTerminalSession(
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion packages/client-runtime/src/state/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
createAtomCommandScheduler,
createEnvironmentRpcCommand,
createEnvironmentSubscriptionAtomFamily,
isAtomCommandInterrupted,
runAtomCommand,
} from "./runtime.ts";
import type { EnvironmentRegistry } from "../connection/registry.ts";
Expand Down Expand Up @@ -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);
}
});
};

Expand Down
Loading