Merge feature/ticket48-cell-error-banner into develop

fix(layout): bandeau d'erreur cellule au-dessus des boutons de contrôle (#48)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 08:42:23 +02:00
3 changed files with 264 additions and 5 deletions

View File

@ -60,7 +60,7 @@ export function TargetAnnouncementsOverlay({
data-testid="target-announcements-overlay" data-testid="target-announcements-overlay"
role="status" role="status"
aria-live="polite" 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 ── */} {/* ── Banner ── */}
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-surface/80 px-4 py-2"> <div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-surface/80 px-4 py-2">

View File

@ -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<GridSetup> {
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(
<DIProvider gateways={gateways}>
<AnnouncementsProvider>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</AnnouncementsProvider>
</DIProvider>,
);
}
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<CellAction>(["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<CellAction>(["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,
),
);
},
);
});

View File

@ -252,6 +252,28 @@ function goToCell(nodeId: string): void {
}, 1200); }, 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({ function LeafView({
id, id,
session, session,
@ -597,7 +619,9 @@ function LeafView({
// edge, ~15px wide). Without this offset the right-most control — the // edge, ~15px wide). Without this offset the right-most control — the
// close button — sits *behind* the scrollbar and is hard to click. // close button — sits *behind* the scrollbar and is hard to click.
right: 16, 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", display: "flex",
gap: 2, gap: 2,
alignItems: "center", alignItems: "center",
@ -711,7 +735,7 @@ function LeafView({
position: "absolute", position: "absolute",
top: 30, top: 30,
left: 4, left: 4,
zIndex: 3, zIndex: CELL_Z.banner,
display: "flex", display: "flex",
maxWidth: "calc(100% - 8px)", maxWidth: "calc(100% - 8px)",
alignItems: "center", alignItems: "center",
@ -878,7 +902,7 @@ function LeafView({
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
zIndex: 4, zIndex: CELL_Z.veil,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@ -886,6 +910,7 @@ function LeafView({
color: "var(--color-content, #e0e0e0)", color: "var(--color-content, #e0e0e0)",
fontSize: 13, fontSize: 13,
pointerEvents: "none", pointerEvents: "none",
// veil tier — below the cell controls (ticket #48).
userSelect: "none", userSelect: "none",
}} }}
> >
@ -924,7 +949,7 @@ function LeafView({
background: "var(--color-surface, #1e1e1e)", background: "var(--color-surface, #1e1e1e)",
padding: "2px 6px", padding: "2px 6px",
borderRadius: 3, borderRadius: 3,
zIndex: 3, zIndex: CELL_Z.banner,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",