/** * 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"; 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(`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("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 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(`agent selector ${setup.bId}`)).toBeTruthy(); expect(screen.getByRole("group", { name: `mode CLI agent ${setup.bId}`, })).toBeTruthy(); expect(screen.getByLabelText(`split ${setup.bId} columns`)).toBeTruthy(); expect(screen.getByLabelText(`split ${setup.bId} rows`)).toBeTruthy(); expect(screen.getByLabelText(`close ${setup.bId}`)).toBeTruthy(); expect(screen.queryByRole("button", { name: `cancel current turn ${setup.bId}`, })).toBeNull(); expect(screen.queryByText("Busy")).toBeNull(); fireEvent.click(screen.getByLabelText(`split ${setup.bId} columns`)); await waitFor(() => expect(mutate).toHaveBeenCalledWith( "p1", expect.objectContaining({ type: "split", target: setup.bId, direction: "row", }), undefined, ), ); const selector = screen.getByLabelText(`agent selector ${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, ), ); }, ); });