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