/** * 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 { beforeEach, describe, it, expect, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ComponentProps } from "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 { MOCK_REFERENCE_PROFILES, MockAgentGateway, MockLayoutGateway, MockProfileGateway, MockSystemGateway, MockTerminalGateway, MockWorkStateGateway, } 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; otherAgentId: string; bId: string; gateways: Gateways; layout: MockLayoutGateway; system: MockSystemGateway; workState: MockWorkStateGateway; } async function makeSplitAgentGrid(): Promise { const layout = new MockLayoutGateway(); const agentGateway = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); await profileGateway.saveProfile(MOCK_REFERENCE_PROFILES[0]); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "mock-claude", }); const otherAgent = await agentGateway.createAgent("p1", { name: "Reviewer", profileId: "mock-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, otherAgentId: otherAgent.id, bId: "b", gateways: { layout, agent: agentGateway, profile: profileGateway, terminal, system, workState, } as unknown as Gateways, layout, system, workState, }; } function renderGrid( gateways: Gateways, props: Partial> = {}, ) { 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(`Sélecteur d’agent ${cellId}`); const controls = selector.parentElement as HTMLElement; return { selector, controls, splitColumns: screen.getByLabelText(`Diviser ${cellId} en colonnes`), close: screen.getByLabelText(`Fermer ${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, }); } beforeEach(() => { window.localStorage.clear(); }); describe("LayoutGrid — ticket #48 cell control layering", () => { it("shows restart only for an agent cell and disables its inputs while scheduled", async () => { const setup = await makeSplitAgentGrid(); const restart = vi .spyOn(setup.gateways.agent, "restartCellAgentRuntime") .mockResolvedValue({ restartId: "restart-cell-1", status: "scheduled", agentId: setup.agentId, nodeId: setup.bId, kind: "pty", presentation: "tui", layoutId: null, }); renderGrid(setup.gateways); const button = await screen.findByRole("button", { name: "Redémarrer l’agent" }); expect(screen.getAllByRole("button", { name: "Redémarrer l’agent" })).toHaveLength(1); fireEvent.click(button); await waitFor(() => expect(restart).toHaveBeenCalledWith({ projectId: "p1", nodeId: setup.bId, agentId: setup.agentId, presentation: "tui", }), ); expect( (screen.getByRole("button", { name: "Redémarrage de l’agent…", }) as HTMLButtonElement).disabled, ).toBe(true); expect( (screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`) as HTMLSelectElement) .disabled, ).toBe(true); expect(screen.getByText("Redémarrage de l’agent…")).toBeTruthy(); await flush(); act(() => { setup.system.emit({ type: "agentRestartCompleted", agentId: setup.agentId, restartId: "restart-cell-1", }); }); await waitFor(() => expect( (screen.getByRole("button", { name: "Redémarrer l’agent", }) as HTMLButtonElement).disabled, ).toBe(false), ); }); it("confirms a busy restart and forwards the custom CLI presentation", async () => { const setup = await makeSplitAgentGrid(); window.localStorage.setItem(`idea.agent-cell-mode.p1.${setup.bId}`, "custom"); setup.workState._setProjectWorkState("p1", { agents: [ { agentId: setup.agentId, name: "Worker", profileId: "mock-claude", busy: { state: "busy", sinceMs: 1 }, }, ], }); const restart = vi .spyOn(setup.gateways.agent, "restartCellAgentRuntime") .mockResolvedValue({ restartId: "restart-cell-2", status: "scheduled", agentId: setup.agentId, nodeId: setup.bId, kind: "structured", presentation: "customCli", layoutId: null, }); renderGrid(setup.gateways); fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); const dialog = await screen.findByRole("alertdialog", { name: "Redémarrer l’agent ?", }); expect(restart).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Interrompre et redémarrer" })); await waitFor(() => expect(restart).toHaveBeenCalledWith({ projectId: "p1", nodeId: setup.bId, agentId: setup.agentId, presentation: "customCli", }), ); expect(dialog.isConnected).toBe(false); window.localStorage.removeItem(`idea.agent-cell-mode.p1.${setup.bId}`); }); it("distinguishes an asynchronous relaunch failure and offers retry/close", async () => { const setup = await makeSplitAgentGrid(); vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({ restartId: "restart-cell-3", status: "scheduled", agentId: setup.agentId, nodeId: setup.bId, kind: "pty", presentation: "tui", layoutId: null, }); renderGrid(setup.gateways); fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); await flush(); act(() => { setup.system.emit({ type: "agentRestartFailed", agentId: setup.agentId, restartId: "restart-cell-3", phase: "launching", code: "LAUNCH_FAILED", message: "provider unavailable", }); }); expect(await screen.findByText("Impossible de relancer l’agent.")).toBeTruthy(); expect(screen.getByText("provider unavailable")).toBeTruthy(); expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Fermer" })); expect(screen.queryByTestId("cell-agent-restart-state")).toBeNull(); }); it("distinguishes an asynchronous stop failure", async () => { const setup = await makeSplitAgentGrid(); vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({ restartId: "restart-cell-stop", status: "scheduled", agentId: setup.agentId, nodeId: setup.bId, kind: "pty", presentation: "tui", layoutId: null, }); renderGrid(setup.gateways); fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); await flush(); act(() => { setup.system.emit({ type: "agentRestartFailed", agentId: setup.agentId, restartId: "restart-cell-stop", phase: "stopping", code: "STOP_TIMEOUT", message: "stop timed out", }); }); expect(await screen.findByText("Impossible d’arrêter l’agent.")).toBeTruthy(); }); it("surfaces a synchronous typed binding error", async () => { const setup = await makeSplitAgentGrid(); vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockRejectedValue({ code: "AGENT_NOT_BOUND_TO_CELL", message: "stale cell binding", }); renderGrid(setup.gateways); fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); expect( await screen.findByText("L’agent n’est plus associé à cette cellule."), ).toBeTruthy(); expect(screen.getByText("stale cell binding")).toBeTruthy(); }); it("keeps custom CLI mode controls compact inside the cell toolbar (#151)", async () => { const setup = await makeSplitAgentGrid(); renderGrid(setup.gateways); const group = await screen.findByRole("group", { name: `Mode CLI de l’agent ${setup.bId}`, }); const { controls } = controlsFor(setup.bId); expect(group.textContent).toBe("TUI nativeCLI custom"); expect(group.style.maxWidth).toBe("128px"); expect(controls.style.left).toBe("4px"); expect(controls.style.right).toBe("16px"); expect(controls.style.justifyContent).toBe("flex-end"); expect(controls.style.overflow).toBe("hidden"); }); it("keeps cell controls available over a busy custom CLI surface (#249)", async () => { const setup = await makeSplitAgentGrid(); window.localStorage.setItem(`idea.agent-cell-mode.p1.${setup.bId}`, "custom"); setup.workState._setProjectWorkState("p1", { agents: [ { agentId: setup.agentId, name: "Worker", profileId: "mock-claude", busy: { state: "busy", sinceMs: 1 }, }, ], }); renderGrid(setup.gateways); await screen.findByTestId("custom-agent-chat-view"); window.localStorage.removeItem(`idea.agent-cell-mode.p1.${setup.bId}`); const customToolbar = screen.getByRole("toolbar", { name: "custom agent chat actions", }); const mutate = vi.spyOn(setup.layout, "mutateLayout"); expect(customToolbar.className).toContain("z-20"); expect(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`)).toBeTruthy(); expect(screen.getByRole("group", { name: `Mode CLI de l’agent ${setup.bId}`, })).toBeTruthy(); expect(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`)).toBeTruthy(); expect(screen.getByLabelText(`Diviser ${setup.bId} en lignes`)).toBeTruthy(); expect(screen.getByLabelText(`Fermer ${setup.bId}`)).toBeTruthy(); expect(screen.queryByRole("button", { name: `cancel current turn ${setup.bId}`, })).toBeNull(); expect(screen.queryByText("Busy")).toBeNull(); fireEvent.click(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`)); await waitFor(() => expect(mutate).toHaveBeenCalledWith( "p1", expect.objectContaining({ type: "split", target: setup.bId, direction: "row", }), undefined, ), ); const selector = screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`); fireEvent.change(selector, { target: { value: setup.otherAgentId } }); await waitFor(() => expect(mutate).toHaveBeenCalledWith( "p1", expect.objectContaining({ type: "setCellAgent", target: setup.bId, agent: setup.otherAgentId, }), undefined, ), ); }); it("hides busy controls while keeping persistent cell controls and History removed (#277)", async () => { const setup = await makeSplitAgentGrid(); await setup.layout.mutateLayout("p1", { type: "setCellConversation", target: setup.bId, conversationId: "prior-conv", }); setup.workState._setProjectWorkState("p1", { agents: [ { agentId: setup.agentId, name: "Worker", profileId: "mock-claude", busy: { state: "busy", sinceMs: 1 }, }, ], }); renderGrid(setup.gateways, { onOpenConversation: vi.fn() }); await flush(); expect(screen.queryByRole("button", { name: `cancel current turn ${setup.bId}`, })).toBeNull(); expect(screen.queryByText("Busy")).toBeNull(); const { controls } = controlsFor(setup.bId); expect(controls.style.zIndex).toBe("5"); expect(screen.queryByRole("button", { name: `open cell conversation ${setup.bId}`, })).toBeNull(); expect(screen.queryByText("History")).toBeNull(); }); 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, ), ); }, ); it("gives the native TUI restart overlay priority over other cell veils", async () => { const setup = await makeSplitAgentGrid(); renderGrid(setup.gateways); await flush(); act(() => { setup.system.emit(busy(setup.agentId, true)); setup.system.emit({ type: "agentRestartRequested", agentId: setup.agentId, restartId: "restart-1", reason: null, }); }); const overlay = await screen.findByTestId("agent-restart-overlay"); expect(overlay.className).toContain("z-[4]"); expect(screen.queryByTestId("target-announcements-overlay")).toBeNull(); expect(screen.queryByText("------- redemarrage de l'agent -------")).toBeNull(); expect(controlsFor(setup.bId).controls.style.zIndex).toBe("5"); }); });