371 lines
10 KiB
TypeScript
371 lines
10 KiB
TypeScript
/**
|
|
* 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<GridSetup> {
|
|
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<ComponentProps<typeof LayoutGrid>> = {},
|
|
) {
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<AnnouncementsProvider projectId="p1">
|
|
<LayoutGrid projectId="p1" cwd="/home/me/proj" {...props} />
|
|
</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("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<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,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
});
|