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:
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user