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",