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:
2026-07-21 21:21:52 +02:00
parent a197197a90
commit 3a18556ffa
6 changed files with 354 additions and 9 deletions

View File

@ -3,8 +3,10 @@
* work-state; background tasks render with status + cancel/retry wired to the
* gateway; a WS reconnect re-synchronises the read-model.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { FitAddon } from "@xterm/addon-fit";
import type { Gateways } from "@/ports";
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
@ -319,3 +321,95 @@ describe("WebWorkspace live surfaces (F5)", () => {
await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen));
});
});
/**
* Ticket #61 (web regression) — `LiveProjectPanel`'s `cellLayoutVersion`.
*
* The web shell has no `useLayout`/`LayoutGrid`, so nothing ever bumped
* `refitSignal` on the web agent cell. `LiveProjectPanel` now keeps a local
* counter, bumped on every cell open/close, forwarded as `refitSignal` to the
* currently-visible `WebAgentCell`. The panel currently shows at most one cell
* at a time (opening a different agent replaces, rather than adds to, the
* visible cell) — so "the cell already displayed keeps a stale scaling" is
* exercised here as: opening a *second*, different agent's cell must still
* carry a bumped, defined `refitSignal` through to the freshly-mounted
* `TerminalView`, producing an explicit `fit()` call beyond the one guaranteed
* at mount — not just whatever the (here stubbed-inert) `ResizeObserver` would
* have produced on its own. That is the exact mechanism a real multi-cell
* surface would rely on for a survivor cell (item 4 of the plan).
*
* Needs the same `matchMedia`/`ResizeObserver` polyfills as
* `TerminalView.test.tsx` for xterm to actually mount under jsdom — scoped to
* this describe block only.
*/
describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", () => {
const w = window as unknown as { matchMedia?: (q: string) => MediaQueryList };
const savedMatchMedia = w.matchMedia;
const savedResizeObserver = globalThis.ResizeObserver;
beforeAll(() => {
w.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
// A no-op observer: real resize events must not be what drives the extra
// fit() below — only the explicit `refitSignal` bump should.
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterAll(() => {
w.matchMedia = savedMatchMedia;
globalThis.ResizeObserver = savedResizeObserver;
});
it("opening a second cell carries a bumped refitSignal, producing an extra fit()", async () => {
const { gateways } = await seeded(state([AGENT_IDLE, AGENT_FRONT]));
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("web-workstate");
const archiRow = screen.getByText("Archi").closest("li")!;
fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
await screen.findByTestId("web-agent-cell");
// First cell mounted: at least the one guaranteed fit-on-mount happened.
expect(fitSpy.mock.calls.length).toBeGreaterThan(0);
// Switch to a different agent's cell — the panel shows one cell at a time,
// so this replaces (unmounts A, mounts B) rather than adding a second.
const frontRow = screen.getByText("Front").closest("li")!;
fireEvent.click(within(frontRow).getByRole("button", { name: "Ouvrir" }));
const cellB = await screen.findByTestId("web-agent-cell");
const fitCallsAtBMount = fitSpy.mock.calls.length;
// jsdom reports a zero-size layout box, which the refit deliberately skips
// — give the freshly-mounted cell's inner xterm container a real size so
// the refitSignal-driven refit actually reaches `fit.fit()`.
const containerB = cellB.querySelector('[data-testid="terminal-view"]')!
.firstElementChild as HTMLElement;
Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });
// The bumped `cellLayoutVersion` reaching `refitSignal` schedules this
// extra refit — with the no-op ResizeObserver stubbed above, nothing else
// could have produced it.
await waitFor(() =>
expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtBMount),
);
fitSpy.mockRestore();
});
});