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>
416 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|