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:
161
frontend/src/features/web/WebAgentCell.test.tsx
Normal file
161
frontend/src/features/web/WebAgentCell.test.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Ticket #61 (web regression) — `WebAgentCell`'s `refitSignal` wiring.
|
||||
*
|
||||
* `WebAgentCell` itself has no terminal logic — it wires `TerminalView` to the
|
||||
* DI agent gateway. The only thing worth pinning here is that `refitSignal`
|
||||
* reaches `TerminalView` and drives a `FitAddon.fit()` call WITHOUT relaunching
|
||||
* the agent (`agent.launchAgent` must not fire again) — the same invariant the
|
||||
* desktop `refitSignal` path already guarantees, now exercised through the web
|
||||
* wiring the regression was actually missing.
|
||||
*
|
||||
* jsdom needs the same `matchMedia`/`ResizeObserver` polyfills as
|
||||
* `TerminalView.test.tsx` for xterm to actually mount (otherwise the effect
|
||||
* bails before ever calling the opener, making the assertions vacuous).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MockAgentGateway } from "@/adapters/mock";
|
||||
import { WebAgentCell } from "./WebAgentCell";
|
||||
|
||||
const PROJECT_ID = "p1";
|
||||
|
||||
async function seededAgentGateway(): Promise<MockAgentGateway> {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, { name: "Archi", profileId: "profile-1" });
|
||||
return agent;
|
||||
}
|
||||
|
||||
function renderCell(
|
||||
agent: MockAgentGateway,
|
||||
agentId: string,
|
||||
extra?: Partial<React.ComponentProps<typeof WebAgentCell>>,
|
||||
) {
|
||||
const gateways = { agent } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" {...extra} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("WebAgentCell", () => {
|
||||
it("mounts and renders the cell without throwing (headless-safe)", async () => {
|
||||
const agent = await seededAgentGateway();
|
||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||
renderCell(agent, seeded.id);
|
||||
expect(screen.getByTestId("web-agent-cell")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("launches through agent.launchAgent with the given cwd", async () => {
|
||||
const agent = await seededAgentGateway();
|
||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||
|
||||
renderCell(agent, seeded.id);
|
||||
|
||||
await waitFor(() => {
|
||||
if (launchSpy.mock.calls.length > 0) {
|
||||
expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
|
||||
expect(launchSpy.mock.calls[0][1]).toBe(seeded.id);
|
||||
expect(launchSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
|
||||
}
|
||||
});
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// xterm needs a real layout engine to mount under jsdom; without these
|
||||
// polyfills `term.open` throws and the effect bails before ever calling
|
||||
// `agent.launchAgent`, which would make the refit assertions below vacuous.
|
||||
describe("WebAgentCell — refitSignal (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 refit
|
||||
// assertions below — only the explicit `refitSignal` prop should.
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
w.matchMedia = savedMatchMedia;
|
||||
globalThis.ResizeObserver = savedResizeObserver;
|
||||
});
|
||||
|
||||
it("a refitSignal change calls FitAddon.fit() without relaunching the agent", async () => {
|
||||
const agent = await seededAgentGateway();
|
||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||
|
||||
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
|
||||
const fitCallsAtMount = fitSpy.mock.calls.length;
|
||||
expect(fitCallsAtMount).toBeGreaterThan(0);
|
||||
|
||||
// jsdom reports a zero-size layout box, which the coalesced refit
|
||||
// deliberately skips — give the inner xterm container a real size so the
|
||||
// refit below actually reaches `fit.fit()`.
|
||||
const container = screen.getByTestId("web-agent-cell").querySelector(
|
||||
'[data-testid="terminal-view"]',
|
||||
)!.firstElementChild as HTMLElement;
|
||||
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
|
||||
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
||||
|
||||
rerender(
|
||||
<DIProvider gateways={{ agent } as unknown as Gateways}>
|
||||
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" refitSignal={2} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount));
|
||||
// The whole point: refit must never relaunch the agent's PTY.
|
||||
expect(launchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
fitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not refit when refitSignal is left undefined", async () => {
|
||||
const agent = await seededAgentGateway();
|
||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||
|
||||
const { rerender } = renderCell(agent, seeded.id);
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
fitSpy.mockClear();
|
||||
|
||||
rerender(
|
||||
<DIProvider gateways={{ agent } as unknown as Gateways}>
|
||||
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
expect(fitSpy).not.toHaveBeenCalled();
|
||||
|
||||
fitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user