Fix terminal resize bug

Fix resize handling in TerminalView component and update related tests
This commit is contained in:
2026-07-25 14:54:57 +02:00
parent 6a87c4635f
commit da907b880e
4 changed files with 117 additions and 32 deletions

View File

@ -246,6 +246,13 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
globalThis.ResizeObserver = savedResizeObserver;
});
function setTerminalBoxSize(width: number, height: number) {
const container = screen.getByTestId("terminal-xterm-container");
Object.defineProperty(container, "clientWidth", { value: width, configurable: true });
Object.defineProperty(container, "clientHeight", { value: height, configurable: true });
return container;
}
it("sanity: with the polyfills xterm mounts and the opener runs", async () => {
// Guards the premise of the tests below: if this fails, the opener never
// fired and the error assertions would be vacuous.
@ -292,6 +299,48 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
expect(screen.queryByTestId("terminal-error")).toBeNull();
});
it("keeps a boot placeholder while the terminal box has no usable size", async () => {
const handle = makeHandle({ sessionId: "boot-zero-1" });
const open = vi.fn(async () => handle);
renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1 });
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy();
expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe(
"hidden",
);
expect(handle.resize).not.toHaveBeenCalled();
});
it("reveals xterm and resizes the handle after the first useful fit", async () => {
const handle = makeHandle({ sessionId: "boot-ready-1" });
const open = vi.fn(async () => handle);
const { rerender } = renderView(new MockTerminalGateway(), "/cwd", {
open,
refitSignal: 1,
});
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy();
setTerminalBoxSize(480, 240);
rerender(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
</DIProvider>,
);
await waitFor(() =>
expect(screen.queryByTestId("terminal-boot-placeholder")).toBeNull(),
);
expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe(
"visible",
);
await waitFor(() => expect(handle.resize).toHaveBeenCalled());
});
describe("refitSignal (ticket #61 — refit after split/merge)", () => {
it("refits WITHOUT reopening the terminal when refitSignal changes", async () => {
// Simulates LayoutGrid bumping `useLayout`'s layout version after a
@ -305,18 +354,14 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
refitSignal: 1,
});
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
const fitCallsAtMount = fitSpy.mock.calls.length;
expect(fitCallsAtMount).toBeGreaterThan(0);
fitSpy.mockClear();
// jsdom reports a zero-size layout box, which the coalesced refit
// deliberately skips (the same guard that protects the resize-observer
// path from fitting to a transient zero size). Stub a real size on the
// inner xterm container so the refit triggered below actually reaches
// `fit.fit()` instead of bailing on the zero-size guard.
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
setTerminalBoxSize(400, 200);
rerender(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
@ -324,9 +369,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
</DIProvider>,
);
await waitFor(() =>
expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount),
);
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
// The structural-mutation refit must never reopen the PTY.
expect(open).toHaveBeenCalledTimes(1);
@ -348,7 +391,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
fitSpy.mockClear();
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
const container = screen.getByTestId("terminal-xterm-container");
// jsdom's default layout box is 0x0 — exactly the transient-zero case:
// left as-is, the container "hasn't settled" yet.

View File

@ -153,6 +153,7 @@ export function TerminalView({
// buffer (which is invisible to assistive tech and absent when xterm can't
// mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable.
const [openError, setOpenError] = useState<string | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
// The opener (`open` or the terminal gateway) is read through a ref so the
// effect does NOT depend on its identity. Otherwise every parent re-render
@ -191,6 +192,7 @@ export function TerminalView({
// Fresh (re)mount: clear any prior failure banner before we try to open.
setOpenError(null);
setTerminalReady(false);
const term = new Terminal({
convertEol: false,
@ -209,15 +211,13 @@ export function TerminalView({
term.dispose();
return;
}
try {
fit.fit();
} catch {
/* container not laid out yet; a resize will retry */
}
let disposed = false;
let handle: TerminalHandle | null = null;
const encoder = new TextEncoder();
let rafId = 0;
let lastRows = term.rows;
let lastCols = term.cols;
let hasUsefulFit = false;
// Keystroke → PTY path. The agent cell is a **native terminal**
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
@ -259,6 +259,11 @@ export function TerminalView({
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
// was disposed before the promise resolved, just detach (NEVER close — the
// PTY must survive a transient mount/unmount).
const resizeHandleToCurrentGeometry = () => {
if (!handle) return;
if (term.rows <= 0 || term.cols <= 0) return;
void handle.resize(term.rows, term.cols);
};
const adopt = (h: TerminalHandle) => {
if (disposed) {
h.detach();
@ -272,6 +277,7 @@ export function TerminalView({
void h.write(encoder.encode(pending));
pending = "";
}
if (hasUsefulFit) resizeHandleToCurrentGeometry();
};
const onOpenError = (e: unknown) => {
@ -335,9 +341,6 @@ export function TerminalView({
// into a single `requestAnimationFrame` that runs after layout settles,
// (2) skip fitting while the container has no real size, and (3) push a PTY
// resize only when rows/cols actually change (avoids redundant reflows).
let rafId = 0;
let lastRows = term.rows;
let lastCols = term.cols;
// A refit can land on a transient 0x0 container (mount, or a structural
// layout mutation, before the box has actually settled). Previously this
// just gave up — fine on desktop, where a later window resize always
@ -364,10 +367,20 @@ export function TerminalView({
} catch {
return;
}
if (handle && (term.rows !== lastRows || term.cols !== lastCols)) {
if (term.rows <= 0 || term.cols <= 0) return;
const isFirstUsefulFit = !hasUsefulFit;
if (isFirstUsefulFit) {
hasUsefulFit = true;
setTerminalReady(true);
}
if (term.rows !== lastRows || term.cols !== lastCols) {
lastRows = term.rows;
lastCols = term.cols;
void handle.resize(term.rows, term.cols);
resizeHandleToCurrentGeometry();
} else if (isFirstUsefulFit) {
resizeHandleToCurrentGeometry();
}
};
const scheduleRefit = () => {
@ -376,6 +389,7 @@ export function TerminalView({
};
const ro = new ResizeObserver(scheduleRefit);
ro.observe(container);
scheduleRefit();
// Let the `refitSignal` effect below trigger the SAME coalesced refit after
// a structural layout mutation (split/merge, ticket #61) — surviving cells
// don't always get a timely useful ResizeObserver event from a sibling
@ -426,7 +440,38 @@ export function TerminalView({
>
{/* xterm mounts into this inner node; the error banner is a sibling so
React never fights xterm over the same subtree. */}
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
<div
ref={containerRef}
data-testid="terminal-xterm-container"
aria-hidden={!terminalReady}
style={{
width: "100%",
height: "100%",
visibility: terminalReady ? "visible" : "hidden",
}}
/>
{!terminalReady && !openError && (
<div
data-testid="terminal-boot-placeholder"
aria-live="polite"
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
background: "#101214",
color: "#9ca3af",
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
zIndex: 1,
}}
>
Préparation du terminal
</div>
)}
{openError && (
<div
role="alert"

View File

@ -111,16 +111,14 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
const fitCallsAtMount = fitSpy.mock.calls.length;
expect(fitCallsAtMount).toBeGreaterThan(0);
fitSpy.mockClear();
// jsdom reports a zero-size layout box, which the coalesced refit
// deliberately skips — give the inner xterm container a real size so the
// refit below actually reaches `fit.fit()`.
const container = screen.getByTestId("web-agent-cell").querySelector(
'[data-testid="terminal-view"]',
)!.firstElementChild as HTMLElement;
'[data-testid="terminal-xterm-container"]',
) as HTMLElement;
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
@ -130,7 +128,7 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
</DIProvider>,
);
await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount));
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
// The whole point: refit must never relaunch the agent's PTY.
expect(launchSpy).toHaveBeenCalledTimes(1);

View File

@ -384,8 +384,6 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
const archiRow = screen.getByText("Archi").closest("li")!;
fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
await screen.findByTestId("web-agent-cell");
// First cell mounted: at least the one guaranteed fit-on-mount happened.
expect(fitSpy.mock.calls.length).toBeGreaterThan(0);
// Switch to a different agent's cell — the panel shows one cell at a time,
// so this replaces (unmounts A, mounts B) rather than adding a second.
@ -398,8 +396,9 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
// jsdom reports a zero-size layout box, which the refit deliberately skips
// — give the freshly-mounted cell's inner xterm container a real size so
// the refitSignal-driven refit actually reaches `fit.fit()`.
const containerB = cellB.querySelector('[data-testid="terminal-view"]')!
.firstElementChild as HTMLElement;
const containerB = cellB.querySelector(
'[data-testid="terminal-xterm-container"]',
) as HTMLElement;
Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });