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:
@ -455,10 +455,11 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
fitSpy.mockRestore();
|
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
|
// ProjectsView keys LayoutGrid by project. A project switch therefore
|
||||||
// unmounts/remounts TerminalView, so the mount path itself must keep a
|
// unmounts/remounts TerminalView, so the mount path itself must fit until
|
||||||
// short tail of fits alive while the new cell geometry settles.
|
// the new cell's real geometry has stabilized, not for a guessed count of
|
||||||
|
// frames.
|
||||||
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||||
const firstHandle = makeHandle({ sessionId: "project-1" });
|
const firstHandle = makeHandle({ sessionId: "project-1" });
|
||||||
const secondHandle = makeHandle({ sessionId: "project-2" });
|
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));
|
await waitFor(() => expect(open).toHaveBeenCalledTimes(2));
|
||||||
setTerminalBoxSize(520, 260);
|
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));
|
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());
|
await waitFor(() => expect(secondHandle.resize).toHaveBeenCalled());
|
||||||
expect(firstHandle.close).not.toHaveBeenCalled();
|
expect(firstHandle.close).not.toHaveBeenCalled();
|
||||||
expect(secondHandle.close).not.toHaveBeenCalled();
|
expect(secondHandle.close).not.toHaveBeenCalled();
|
||||||
@ -501,6 +502,70 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
fitSpy.mockRestore();
|
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 () => {
|
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 fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||||
const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" }));
|
const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" }));
|
||||||
|
|||||||
@ -191,7 +191,7 @@ export function TerminalView({
|
|||||||
// Holds the mounted instance's `refit` closure so the `refitSignal` effect
|
// Holds the mounted instance's `refit` closure so the `refitSignal` effect
|
||||||
// below (a separate effect, since it must NOT re-run/reopen the terminal on
|
// below (a separate effect, since it must NOT re-run/reopen the terminal on
|
||||||
// every parent render) can trigger it without depending on `cwd`'s effect.
|
// every parent render) can trigger it without depending on `cwd`'s effect.
|
||||||
const refitRef = useRef<((settleFrames?: number) => void) | null>(null);
|
const refitRef = useRef<((maxFrames?: number) => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@ -229,7 +229,6 @@ export function TerminalView({
|
|||||||
let lastRows = term.rows;
|
let lastRows = term.rows;
|
||||||
let lastCols = term.cols;
|
let lastCols = term.cols;
|
||||||
let hasUsefulFit = false;
|
let hasUsefulFit = false;
|
||||||
let settleFramesRemaining = 0;
|
|
||||||
|
|
||||||
// Keystroke → PTY path. The agent cell is a **native terminal**
|
// Keystroke → PTY path. The agent cell is a **native terminal**
|
||||||
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
|
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
|
||||||
@ -363,34 +362,51 @@ export function TerminalView({
|
|||||||
// doesn't spin forever.
|
// doesn't spin forever.
|
||||||
// A successful fit can also land on a non-zero but still intermediate box
|
// A successful fit can also land on a non-zero but still intermediate box
|
||||||
// during project/layout switches, split/merge commits, re-attach, and OS
|
// during project/layout switches, split/merge commits, re-attach, and OS
|
||||||
// minimize/restore. Keep a small coalesced tail of fits on following frames
|
// minimize/restore. Instead of guessing a fixed number of follow-up frames,
|
||||||
// so the final settled geometry is pushed automatically without requiring a
|
// keep fitting until the measured DOM box and resulting xterm grid stay
|
||||||
// manual resize. This stays bounded and preserves the rows/cols-changed
|
// stable for consecutive frames. A hard frame cap keeps genuinely unstable
|
||||||
// guard before touching the PTY.
|
// or never-laid-out containers bounded.
|
||||||
const SETTLE_REFIT_FRAMES = 4;
|
const STABLE_REFIT_FRAMES = 2;
|
||||||
// Project switches remount LayoutGrid/TerminalView by key. The new cell can
|
const SETTLE_REFIT_MAX_FRAMES = 24;
|
||||||
// report a non-zero but still intermediate box for more than the ordinary
|
const MOUNT_REFIT_MAX_FRAMES = 60;
|
||||||
// transition tail, so the mount path keeps fitting a little longer.
|
let refitFramesRemaining = 0;
|
||||||
const MOUNT_SETTLE_REFIT_FRAMES = 12;
|
let stableFrames = 0;
|
||||||
const MAX_ZERO_SIZE_RETRIES = MOUNT_SETTLE_REFIT_FRAMES;
|
let lastStabilityKey = "";
|
||||||
let zeroSizeRetries = 0;
|
|
||||||
const refit = () => {
|
const refit = () => {
|
||||||
rafId = 0;
|
rafId = 0;
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (container.clientWidth === 0 || container.clientHeight === 0) {
|
|
||||||
if (zeroSizeRetries < MAX_ZERO_SIZE_RETRIES) {
|
const consumeFrame = () => {
|
||||||
zeroSizeRetries += 1;
|
refitFramesRemaining = Math.max(0, refitFramesRemaining - 1);
|
||||||
|
};
|
||||||
|
const scheduleNextIfNeeded = () => {
|
||||||
|
if (refitFramesRemaining > 0) {
|
||||||
rafId = requestAnimationFrame(refit);
|
rafId = requestAnimationFrame(refit);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
if (width === 0 || height === 0) {
|
||||||
|
stableFrames = 0;
|
||||||
|
lastStabilityKey = "";
|
||||||
|
consumeFrame();
|
||||||
|
scheduleNextIfNeeded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
zeroSizeRetries = 0;
|
|
||||||
try {
|
try {
|
||||||
fit.fit();
|
fit.fit();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (term.rows <= 0 || term.cols <= 0) return;
|
if (term.rows <= 0 || term.cols <= 0) {
|
||||||
|
stableFrames = 0;
|
||||||
|
lastStabilityKey = "";
|
||||||
|
consumeFrame();
|
||||||
|
scheduleNextIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isFirstUsefulFit = !hasUsefulFit;
|
const isFirstUsefulFit = !hasUsefulFit;
|
||||||
if (isFirstUsefulFit) {
|
if (isFirstUsefulFit) {
|
||||||
@ -406,23 +422,31 @@ export function TerminalView({
|
|||||||
resizeHandleToCurrentGeometry();
|
resizeHandleToCurrentGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settleFramesRemaining > 0) {
|
const stabilityKey = `${width}x${height}:${term.rows}x${term.cols}`;
|
||||||
settleFramesRemaining -= 1;
|
if (stabilityKey === lastStabilityKey) {
|
||||||
rafId = requestAnimationFrame(refit);
|
stableFrames += 1;
|
||||||
|
} else {
|
||||||
|
lastStabilityKey = stabilityKey;
|
||||||
|
stableFrames = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
consumeFrame();
|
||||||
|
if (stableFrames < STABLE_REFIT_FRAMES) scheduleNextIfNeeded();
|
||||||
};
|
};
|
||||||
const scheduleRefit = (settleFrames = 0) => {
|
const scheduleRefit = (maxFrames = SETTLE_REFIT_MAX_FRAMES) => {
|
||||||
settleFramesRemaining = Math.max(settleFramesRemaining, settleFrames);
|
refitFramesRemaining = Math.max(refitFramesRemaining, maxFrames);
|
||||||
|
stableFrames = 0;
|
||||||
|
lastStabilityKey = "";
|
||||||
if (!rafId) rafId = requestAnimationFrame(refit);
|
if (!rafId) rafId = requestAnimationFrame(refit);
|
||||||
};
|
};
|
||||||
const scheduleSettledRefit = () => scheduleRefit(SETTLE_REFIT_FRAMES);
|
const scheduleSettledRefit = () => scheduleRefit(SETTLE_REFIT_MAX_FRAMES);
|
||||||
const scheduleVisibleRefit = () => {
|
const scheduleVisibleRefit = () => {
|
||||||
if (document.visibilityState === "hidden") return;
|
if (document.visibilityState === "hidden") return;
|
||||||
scheduleSettledRefit();
|
scheduleSettledRefit();
|
||||||
};
|
};
|
||||||
const ro = new ResizeObserver(() => scheduleRefit());
|
const ro = new ResizeObserver(() => scheduleRefit());
|
||||||
ro.observe(container);
|
ro.observe(container);
|
||||||
scheduleRefit(MOUNT_SETTLE_REFIT_FRAMES);
|
scheduleRefit(MOUNT_REFIT_MAX_FRAMES);
|
||||||
window.addEventListener("resize", scheduleSettledRefit);
|
window.addEventListener("resize", scheduleSettledRefit);
|
||||||
window.addEventListener("focus", scheduleSettledRefit);
|
window.addEventListener("focus", scheduleSettledRefit);
|
||||||
window.addEventListener("pageshow", scheduleSettledRefit);
|
window.addEventListener("pageshow", scheduleSettledRefit);
|
||||||
@ -468,7 +492,7 @@ export function TerminalView({
|
|||||||
// logic.
|
// logic.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refitSignal === undefined) return;
|
if (refitSignal === undefined) return;
|
||||||
refitRef.current?.(4);
|
refitRef.current?.(24);
|
||||||
}, [refitSignal]);
|
}, [refitSignal]);
|
||||||
|
|
||||||
const showNetworkBanner =
|
const showNetworkBanner =
|
||||||
|
|||||||
Reference in New Issue
Block a user