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();
+ });
+});