Files
IdeA/frontend/src/features/web/WebWorkspaceLive.test.tsx
Blomios 3a18556ffa 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>
2026-07-21 21:21:52 +02:00

416 lines
15 KiB
TypeScript

/**
* F5 — the live web surfaces: a `event.domain` event refreshes the displayed
* 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, 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";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import {
WsLiveClient,
setWebLiveClient,
WebSession,
} from "@/adapters/http";
import { WebApp } from "./WebApp";
afterEach(() => setWebLiveClient(null));
const okFetch = async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true }),
text: async () => "{}",
});
function memStore() {
const map = new Map<string, string>();
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
};
}
function state(agents: ProjectWorkState["agents"]): ProjectWorkState {
return { agents, conversations: [] };
}
async function seeded(initial: ProjectWorkState): Promise<{ gateways: Gateways; projectId: string }> {
const gateways = createMockGateways();
const project = await gateways.project.createProject("Demo", "/srv/demo");
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, initial);
return { gateways, projectId: project.id };
}
function renderPaired(gateways: Gateways) {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
return render(
<DIProvider gateways={gateways}>
<WebApp session={session} />
</DIProvider>,
);
}
const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] };
const AGENT_FRONT = { agentId: "a2", name: "Front", profileId: "p2", busy: { state: "idle" as const }, tickets: [] };
function backgroundTask(
taskId: string,
ownerAgentId: string,
updatedAtMs = 1,
): BackgroundCompletion {
return {
taskId,
ownerAgentId,
projectId: "p",
kind: "shell",
status: "running",
exitCode: null,
summary: null,
stdoutTail: null,
stderrTail: null,
updatedAtMs,
};
}
async function openLivePanel(gateways: Gateways) {
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
return screen.findByTestId("web-workstate");
}
describe("WebWorkspace live surfaces (F5)", () => {
it("refreshes the work-state on a relevant event.domain event", async () => {
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
const panel = await screen.findByTestId("web-workstate");
expect(panel.textContent).toContain("Idle");
// The agent goes busy server-side; the read-model now reflects it…
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
state([{ ...AGENT_IDLE, busy: { state: "busy", ticket: "t-1", sinceMs: 1 } }]),
);
// …and an `agentBusyChanged` domain event drives a live refresh.
act(() => {
(gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true });
});
await waitFor(() =>
expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"),
);
});
it("renders background tasks and wires cancel through the gateway", async () => {
const task = backgroundTask("bt-1", "a1");
const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }]));
const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask");
await openLivePanel(gateways);
fireEvent.click(
await screen.findByRole("button", { name: "Afficher les background tasks de Archi" }),
);
const tasks = await screen.findByLabelText("Archi background tasks");
expect(tasks.textContent).toContain("Running");
expect(tasks.textContent).toContain("shell");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1"));
});
it("keeps background task groups collapsed by default and toggles them per agent", async () => {
const { gateways } = await seeded(state([
{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-a", "a1")] },
{ ...AGENT_FRONT, backgroundTasks: [backgroundTask("bt-f", "a2")] },
]));
await openLivePanel(gateways);
const archiToggle = await screen.findByRole("button", {
name: "Afficher les background tasks de Archi",
});
const frontToggle = await screen.findByRole("button", {
name: "Afficher les background tasks de Front",
});
expect(archiToggle.getAttribute("aria-expanded")).toBe("false");
expect(frontToggle.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
expect(screen.queryByLabelText("Front background tasks")).toBeNull();
fireEvent.click(archiToggle);
expect(
screen
.getByRole("button", { name: "Masquer les background tasks de Archi" })
.getAttribute("aria-expanded"),
).toBe("true");
expect(
screen
.getByRole("button", { name: "Afficher les background tasks de Front" })
.getAttribute("aria-expanded"),
).toBe("false");
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
expect(screen.queryByLabelText("Front background tasks")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Front" }));
expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
expect(screen.getByLabelText("Front background tasks")).toBeTruthy();
});
it("updates task counters without changing the current expanded state", async () => {
const { gateways, projectId } = await seeded(state([
{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] },
]));
await openLivePanel(gateways);
const collapsedToggle = await screen.findByRole("button", {
name: "Afficher les background tasks de Archi",
});
expect(collapsedToggle.getAttribute("aria-expanded")).toBe("false");
expect(collapsedToggle.textContent).toContain("1");
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
state([
{
...AGENT_IDLE,
backgroundTasks: [backgroundTask("bt-1", "a1", 1), backgroundTask("bt-2", "a1", 2)],
},
]),
);
act(() => {
(gateways.system as MockSystemGateway).emit({
type: "backgroundTaskChanged",
projectId,
taskId: "bt-2",
agentId: "a1",
state: "running",
});
});
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Afficher les background tasks de Archi" }).textContent,
).toContain("2"),
);
expect(
screen
.getByRole("button", { name: "Afficher les background tasks de Archi" })
.getAttribute("aria-expanded"),
).toBe("false");
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Archi" }));
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1", 3)] }]),
);
act(() => {
(gateways.system as MockSystemGateway).emit({
type: "backgroundTaskChanged",
projectId,
taskId: "bt-2",
agentId: "a1",
state: "completed",
});
});
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Masquer les background tasks de Archi" }).textContent,
).toContain("1"),
);
expect(
screen
.getByRole("button", { name: "Masquer les background tasks de Archi" })
.getAttribute("aria-expanded"),
).toBe("true");
expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
});
it("hides and resets an open background task group when its task count reaches zero", async () => {
const { gateways, projectId } = await seeded(state([
{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] },
]));
await openLivePanel(gateways);
fireEvent.click(await screen.findByRole("button", {
name: "Afficher les background tasks de Archi",
}));
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
state([{ ...AGENT_IDLE, backgroundTasks: [] }]),
);
act(() => {
(gateways.system as MockSystemGateway).emit({
type: "backgroundTaskChanged",
projectId,
taskId: "bt-1",
agentId: "a1",
state: "completed",
});
});
await waitFor(() =>
expect(screen.queryByRole("button", { name: /background tasks de Archi/ })).toBeNull(),
);
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-2", "a1", 2)] }]),
);
act(() => {
(gateways.system as MockSystemGateway).emit({
type: "backgroundTaskChanged",
projectId,
taskId: "bt-2",
agentId: "a1",
state: "running",
});
});
await waitFor(() =>
expect(
screen
.getByRole("button", { name: "Afficher les background tasks de Archi" })
.getAttribute("aria-expanded"),
).toBe("false"),
);
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
});
it("re-synchronises the read-model when the WS reconnects", async () => {
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
// Register a live client whose connection state we can drive.
let notify: ((s: "connecting" | "connected" | "reconnecting" | "closed") => void) | null = null;
const fakeClient = {
getConnectionState: () => "connected" as const,
onConnectionStateChange: (l: (s: "connecting" | "connected" | "reconnecting" | "closed") => void) => {
notify = l;
return () => {
notify = null;
};
},
};
setWebLiveClient(fakeClient as unknown as WsLiveClient);
const refreshSpy = vi.spyOn(gateways.workState, "getProjectWorkState");
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("web-workstate");
const callsAfterOpen = refreshSpy.mock.calls.length;
expect(projectId).toBeTruthy();
// Simulate a reconnect: reconnecting → connected must re-fetch the read-model.
act(() => {
notify?.("reconnecting");
notify?.("connected");
});
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();
});
});