fix(terminals): détecte la stabilité réelle du fit terminal au remount

L'ancien correctif (traîne de refits à nombre de frames fixe,
MOUNT_SETTLE_REFIT_FRAMES) restait insuffisant : une cellule CLI pouvait
rester mal dimensionnée après un changement de projet/layout si l'agent
écrivait en arrière-plan pendant la fenêtre de stabilisation, nécessitant
un resize manuel pour corriger l'affichage. TerminalView détecte
désormais la stabilité réelle du fit (dimensions inchangées sur des
mesures successives) au lieu de s'appuyer sur un nombre de frames fixe
avant de rendre la main au ResizeObserver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:36:05 +02:00
parent c100a0317c
commit 6165eaf8d9
2 changed files with 120 additions and 31 deletions

View File

@ -455,10 +455,11 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
fitSpy.mockRestore();
});
it("keeps fitting across a keyed project remount until the new cell settles", async () => {
it("keeps fitting across a keyed project remount until the new cell is actually stable", async () => {
// ProjectsView keys LayoutGrid by project. A project switch therefore
// unmounts/remounts TerminalView, so the mount path itself must keep a
// short tail of fits alive while the new cell geometry settles.
// unmounts/remounts TerminalView, so the mount path itself must fit until
// the new cell's real geometry has stabilized, not for a guessed count of
// frames.
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const firstHandle = makeHandle({ sessionId: "project-1" });
const secondHandle = makeHandle({ sessionId: "project-2" });
@ -489,11 +490,11 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
await waitFor(() => expect(open).toHaveBeenCalledTimes(2));
setTerminalBoxSize(520, 260);
for (let i = 0; i < 8; i += 1) {
for (let i = 0; i < 3; i += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
expect(fitSpy.mock.calls.length).toBeGreaterThan(5);
expect(fitSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
await waitFor(() => expect(secondHandle.resize).toHaveBeenCalled());
expect(firstHandle.close).not.toHaveBeenCalled();
expect(secondHandle.close).not.toHaveBeenCalled();
@ -501,6 +502,70 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
fitSpy.mockRestore();
});
it("refits a late remount after scrollback while the cell was absent", async () => {
// Regression for project/layout switch: the agent can keep writing while
// its view is unmounted. On reattach, scrollback is repainted immediately,
// but the new container may stay 0x0 longer than the old fixed remount
// frame tail. The view must keep retrying until a real size appears.
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const handle = makeHandle({ sessionId: "late-remount-1" });
const reattach = vi.fn(
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
onData(new TextEncoder().encode("agent wrote while absent\r\n"));
return {
handle,
scrollback: new TextEncoder().encode("retained scrollback\r\n"),
} satisfies ReattachResult;
},
);
const open = vi.fn(async () => makeHandle({ sessionId: "should-not-open" }));
renderView(new MockTerminalGateway(), "/cwd", {
sessionId: "late-remount-1",
open,
reattach,
});
await waitFor(() => expect(reattach).toHaveBeenCalledTimes(1));
expect(open).not.toHaveBeenCalled();
fitSpy.mockClear();
// Stay zero-sized past the previous fixed remount tail.
for (let i = 0; i < 16; i += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
expect(fitSpy).not.toHaveBeenCalled();
expect(handle.resize).not.toHaveBeenCalled();
setTerminalBoxSize(560, 280);
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
await waitFor(() => expect(handle.resize).toHaveBeenCalled());
expect(handle.close).not.toHaveBeenCalled();
fitSpy.mockRestore();
});
it("continues refitting while remount geometry keeps changing, then stops after stability", async () => {
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const handle = makeHandle({ sessionId: "moving-remount-1" });
const open = vi.fn(async () => handle);
renderView(new MockTerminalGateway(), "/cwd", { open });
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
for (let i = 0; i < 16; i += 1) {
setTerminalBoxSize(420 + i, 220);
await new Promise((resolve) => requestAnimationFrame(resolve));
}
expect(fitSpy.mock.calls.length).toBeGreaterThan(12);
setTerminalBoxSize(520, 260);
await waitFor(() => expect(handle.resize).toHaveBeenCalled());
fitSpy.mockRestore();
});
it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => {
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" }));