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:
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user