merge fix/terminal-fit-remount-stability dans develop (fix: détection de stabilité réelle du fit terminal au remount)

QA: verdict VERT (suite automatisée), réserve manuelle : vérification desktop/Tauri (comportement réel du fit terminal dans l'app packagée) non exécutée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:36:10 +02:00
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(); 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" }));

View File

@ -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 =