diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 864af61..2768842 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -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( + + + , + ); + + // 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" })); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 58d826f..47ef2df 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -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 { diff --git a/frontend/src/features/web/WebAgentCell.test.tsx b/frontend/src/features/web/WebAgentCell.test.tsx new file mode 100644 index 0000000..c873084 --- /dev/null +++ b/frontend/src/features/web/WebAgentCell.test.tsx @@ -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 { + const agent = new MockAgentGateway(); + await agent.createAgent(PROJECT_ID, { name: "Archi", profileId: "profile-1" }); + return agent; +} + +function renderCell( + agent: MockAgentGateway, + agentId: string, + extra?: Partial>, +) { + const gateways = { agent } as unknown as Gateways; + return render( + + + , + ); +} + +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( + + + , + ); + + 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( + + + , + ); + + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(fitSpy).not.toHaveBeenCalled(); + + fitSpy.mockRestore(); + }); +}); diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx index 45a4fb7..c1e2de6 100644 --- a/frontend/src/features/web/WebAgentCell.tsx +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -39,9 +39,16 @@ interface WebAgentCellProps { cwd: string; /** Layout leaf id, when the caller tracks one (drives the singleton guard). */ nodeId?: string; + /** + * Bumped by the caller after a web cell opens/closes (ticket #61 web + * regression) — the web equivalent of desktop `useLayout.layoutVersion`. + * Forwarded to {@link TerminalView} as-is; never changes this component's + * `key`, so the terminal/PTY is never recreated by it. + */ + refitSignal?: number; } -export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) { +export function WebAgentCell({ projectId, agentId, cwd, nodeId, refitSignal }: WebAgentCellProps) { const { agent } = useGateways(); const [sessionId, setSessionId] = useState(null); // Input API of the mounted xterm (#69), used by the mobile key bar. Stays @@ -85,6 +92,7 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr sessionId={sessionId} onSessionId={setSessionId} onReady={setInputApi} + refitSignal={refitSignal} /> diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 9bc3ed6..e72fb51 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -309,6 +309,16 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string // Re-sync the read-model when the WS reconnects (events missed while offline). useLiveReconnect(vm.refresh); const [openAgentId, setOpenAgentId] = useState(null); + // Web equivalent of desktop `useLayout.layoutVersion` (ticket #61 web + // regression): the web shell has no `useLayout`/`LayoutGrid`, so nothing was + // ever bumping `refitSignal` on `TerminalView` — a cell opened after another + // one was left with stale xterm scaling, with no window-resize equivalent to + // "repair" it on mobile. Bumped on every cell open/close and forwarded as + // `refitSignal` to every visible `WebAgentCell` — a single shared counter, so + // it already covers several simultaneously-visible cells if this panel ever + // grows one. + const [cellLayoutVersion, setCellLayoutVersion] = useState(0); + const bumpCellLayoutVersion = useCallback(() => setCellLayoutVersion((v) => v + 1), []); const agents = vm.state?.agents ?? []; // Per-agent background-tasks disclosure (collapsed by default). A live refresh @@ -360,9 +370,10 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string key={a.agentId} agent={a} open={openAgentId === a.agentId} - onToggleOpen={() => - setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId)) - } + onToggleOpen={() => { + setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId)); + bumpCellLayoutVersion(); + }} onRefresh={vm.refresh} bgExpanded={expandedBgAgents.has(a.agentId)} onToggleBgExpanded={() => toggleBgExpanded(a.agentId)} @@ -375,11 +386,24 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
Agent -
- +
)} diff --git a/frontend/src/features/web/WebWorkspaceLive.test.tsx b/frontend/src/features/web/WebWorkspaceLive.test.tsx index 33785cc..0868ccc 100644 --- a/frontend/src/features/web/WebWorkspaceLive.test.tsx +++ b/frontend/src/features/web/WebWorkspaceLive.test.tsx @@ -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(); + }); +});