/** * Ticket #48 — cell controls must stay above in-cell failure/busy overlays. * * The regression was visual but user-facing: once a cell showed a terminal * launch failure banner or the F3 busy veil, the agent selector / split / close * controls could fall under it and become unusable. These tests keep the real * LayoutGrid/LeafView composition and stub only xterm, as the neighboring layout * tests do under jsdom. */ import { describe, it, expect, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; vi.mock("@xterm/xterm", () => ({ Terminal: class { loadAddon() {} open() {} onData() { return { dispose() {} }; } onResize() { return { dispose() {} }; } write() {} dispose() {} get cols() { return 80; } get rows() { return 24; } }, })); vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} }, })); vi.mock("@xterm/xterm/css/xterm.css", () => ({})); if (typeof globalThis.ResizeObserver === "undefined") { globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; } import type { DomainEvent } from "@/domain"; import type { Gateways } from "@/ports"; import { MockAgentGateway, MockLayoutGateway, MockSystemGateway, MockTerminalGateway, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { AnnouncementsProvider } from "@/features/announcements"; import { leaves } from "./layout"; import { LayoutGrid } from "./LayoutGrid"; interface GridSetup { agentId: string; bId: string; gateways: Gateways; layout: MockLayoutGateway; system: MockSystemGateway; } async function makeSplitAgentGrid(): Promise { const layout = new MockLayoutGateway(); const agentGateway = new MockAgentGateway(); const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude", }); const initial = await layout.loadLayout("p1"); const aId = leaves(initial)[0].id; await layout.mutateLayout("p1", { type: "split", target: aId, direction: "row", newLeaf: "b", container: "c", }); await layout.mutateLayout("p1", { type: "setCellAgent", target: "b", agent: agent.id, }); return { agentId: agent.id, bId: "b", gateways: { layout, agent: agentGateway, terminal, system, } as unknown as Gateways, layout, system, }; } function renderGrid(gateways: Gateways) { return render( , ); } function busy(agentId: string, isBusy: boolean): DomainEvent { return { type: "agentBusyChanged", agentId, busy: isBusy }; } async function flush() { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); } function controlsFor(cellId: string) { const selector = screen.getByLabelText(`agent selector ${cellId}`); const controls = selector.parentElement as HTMLElement; return { selector, controls, splitColumns: screen.getByLabelText(`split ${cellId} columns`), close: screen.getByLabelText(`close ${cellId}`), }; } type CellAction = "selector" | "split" | "close"; function performAction(action: CellAction, cellId: string) { const { selector, splitColumns, close } = controlsFor(cellId); if (action === "selector") { fireEvent.change(selector, { target: { value: "" } }); } else if (action === "split") { fireEvent.click(splitColumns); } else { fireEvent.click(close); } } function expectedOperation(action: CellAction, cellId: string) { if (action === "selector") { return expect.objectContaining({ type: "setCellAgent", target: cellId, agent: null, }); } if (action === "split") { return expect.objectContaining({ type: "split", target: cellId, direction: "row", }); } return expect.objectContaining({ type: "merge", container: "c", keepIndex: 0, }); } describe("LayoutGrid — ticket #48 cell control layering", () => { it.each(["selector", "split", "close"])( "keeps %s above/clickable over a terminal launch error", async (action) => { const setup = await makeSplitAgentGrid(); const agentGateway = setup.gateways.agent as MockAgentGateway; vi.spyOn(agentGateway, "launchAgent").mockRejectedValue( new Error("profile CLI failed to start"), ); const mutate = vi.spyOn(setup.layout, "mutateLayout"); renderGrid(setup.gateways); const error = await screen.findByTestId("terminal-error"); const { controls } = controlsFor(setup.bId); expect(controls.style.zIndex).toBe("5"); expect(error.style.zIndex).toBe("3"); performAction(action, setup.bId); await waitFor(() => expect(mutate).toHaveBeenCalledWith( "p1", expectedOperation(action, setup.bId), undefined, ), ); }, ); it.each(["selector", "split", "close"])( "keeps %s above/clickable over the F3 busy overlay", async (action) => { const setup = await makeSplitAgentGrid(); const mutate = vi.spyOn(setup.layout, "mutateLayout"); renderGrid(setup.gateways); await flush(); act(() => setup.system.emit(busy(setup.agentId, true))); const overlay = await screen.findByTestId("target-announcements-overlay"); const { controls } = controlsFor(setup.bId); expect(controls.style.zIndex).toBe("5"); expect(overlay.className).toContain("z-[4]"); expect(overlay.className).toContain("pointer-events-none"); performAction(action, setup.bId); await waitFor(() => expect(mutate).toHaveBeenCalledWith( "p1", expectedOperation(action, setup.bId), undefined, ), ); }, ); });