fix(frontend): refit web agent cells after opening a new one (#61 web regression)
The web shell never adopted the desktop's #61 fix: WebAgentCell/WebWorkspace didn't pass any refitSignal to TerminalView, so a cell opened after another kept a stale xterm scaling — desktop "fixed" it via an incidental window resize, but mobile has no such escape hatch. - TerminalView: a refit landing on a transient 0x0 container now reschedules on the next few frames instead of giving up for good (bounded retries), closing the independent timing gap Architect identified. refitSignal stays the single explicit-refit mechanism; the terminal/PTY is never recreated, and resize is still pushed to the PTY only when rows/cols actually change. - WebAgentCell: new optional refitSignal prop, forwarded to TerminalView as-is (no key change, no remount). - WebWorkspace/LiveProjectPanel: new cellLayoutVersion counter, the web equivalent of desktop useLayout.layoutVersion, bumped on every cell open/close and forwarded as refitSignal to the visible WebAgentCell. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -333,6 +333,47 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
||||
fitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("recovers from a transient zero-size container instead of giving up (web regression)", async () => {
|
||||
// On mobile there is no window-resize equivalent that "repairs" a refit
|
||||
// that landed on a not-yet-settled 0x0 container — so a zero size at the
|
||||
// scheduled frame must reschedule on a following frame rather than
|
||||
// abandon the refit permanently.
|
||||
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||
const open = vi.fn(async () => makeHandle({ sessionId: "zero-size-1" }));
|
||||
|
||||
const { rerender } = renderView(new MockTerminalGateway(), "/cwd", {
|
||||
open,
|
||||
refitSignal: 1,
|
||||
});
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
fitSpy.mockClear();
|
||||
|
||||
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
|
||||
// jsdom's default layout box is 0x0 — exactly the transient-zero case:
|
||||
// left as-is, the container "hasn't settled" yet.
|
||||
|
||||
rerender(
|
||||
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
|
||||
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
// First scheduled frame: still zero size — must skip fit() and
|
||||
// reschedule, not give up.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
expect(fitSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The container settles to a real size before the rescheduled retry runs.
|
||||
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
|
||||
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
||||
|
||||
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
|
||||
// Still never reopened the PTY across the retries.
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
|
||||
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" }));
|
||||
|
||||
@ -338,10 +338,27 @@ export function TerminalView({
|
||||
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
|
||||
// re-triggers the ResizeObserver and retries, but on mobile (no window
|
||||
// resize possible) the cell was then stuck unfit forever. So a zero size
|
||||
// now reschedules on the next few frames instead of abandoning — bounded,
|
||||
// so a container that is genuinely never laid out (e.g. headless tests)
|
||||
// doesn't spin forever.
|
||||
const MAX_ZERO_SIZE_RETRIES = 8;
|
||||
let zeroSizeRetries = 0;
|
||||
const refit = () => {
|
||||
rafId = 0;
|
||||
if (disposed) return;
|
||||
if (container.clientWidth === 0 || container.clientHeight === 0) return;
|
||||
if (container.clientWidth === 0 || container.clientHeight === 0) {
|
||||
if (zeroSizeRetries < MAX_ZERO_SIZE_RETRIES) {
|
||||
zeroSizeRetries += 1;
|
||||
rafId = requestAnimationFrame(refit);
|
||||
}
|
||||
return;
|
||||
}
|
||||
zeroSizeRetries = 0;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user