diff --git a/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx index ecf0fb8..677cf73 100644 --- a/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx +++ b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx @@ -60,7 +60,7 @@ export function TargetAnnouncementsOverlay({ data-testid="target-announcements-overlay" role="status" aria-live="polite" - className="pointer-events-none absolute inset-0 z-20 flex flex-col bg-canvas/85 backdrop-blur-sm" + className="pointer-events-none absolute inset-0 z-[4] flex flex-col bg-canvas/85 backdrop-blur-sm" > {/* ── Banner ── */}
diff --git a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx new file mode 100644 index 0000000..fbbdeec --- /dev/null +++ b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx @@ -0,0 +1,234 @@ +/** + * 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, + ), + ); + }, + ); +}); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index c9ee6f6..e390f94 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -252,6 +252,28 @@ function goToCell(nodeId: string): void { }, 1200); } +/** + * Local z-index scale for a single cell (ticket #48). Intra-cell layering only — + * unrelated to the global {@link zIndex} scale used for app-level surfaces. + * + * The cell controls (agent selector, split, close) must stay reachable even when + * a non-modal banner is shown, otherwise a cell stuck in an error state (e.g. an + * agent launch failure) can no longer be managed. Full-cell veils (write-portal + * injection, F3 busy) deliberately cover the terminal and sit above the banners, + * but they are `pointer-events:none` and stay *below* the controls so the user + * can always re-pick an agent / split / close the cell. + * + * terminal 0 (implicit — the xterm surface) + * status/error 3 (in-cell status strip, launch-error banner, notices) + * full-cell veils 4 (write-portal + F3 overlays; mutually exclusive) + * cell controls 5 (always on top and clickable) + */ +const CELL_Z = { + banner: 3, + veil: 4, + controls: 5, +} as const; + function LeafView({ id, session, @@ -597,7 +619,9 @@ function LeafView({ // edge, ~15px wide). Without this offset the right-most control — the // close button — sits *behind* the scrollbar and is hard to click. right: 16, - zIndex: 2, + // Above every banner/veil so a cell in an error state stays manageable + // (ticket #48). + zIndex: CELL_Z.controls, display: "flex", gap: 2, alignItems: "center", @@ -711,7 +735,7 @@ function LeafView({ position: "absolute", top: 30, left: 4, - zIndex: 3, + zIndex: CELL_Z.banner, display: "flex", maxWidth: "calc(100% - 8px)", alignItems: "center", @@ -878,7 +902,7 @@ function LeafView({ style={{ position: "absolute", inset: 0, - zIndex: 4, + zIndex: CELL_Z.veil, display: "flex", alignItems: "center", justifyContent: "center", @@ -886,6 +910,7 @@ function LeafView({ color: "var(--color-content, #e0e0e0)", fontSize: 13, pointerEvents: "none", + // veil tier — below the cell controls (ticket #48). userSelect: "none", }} > @@ -924,7 +949,7 @@ function LeafView({ background: "var(--color-surface, #1e1e1e)", padding: "2px 6px", borderRadius: 3, - zIndex: 3, + zIndex: CELL_Z.banner, display: "flex", alignItems: "center", justifyContent: "space-between",