Lot en un seul commit: les deux features partagent les mêmes contrats (domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes surfaces UI — la séparation par fichiers était impossible sans chirurgie de hunks risquant des états intermédiaires non compilables (convention lot des batches #310-#313 et #315). #325 — icône configurable par projet, watermark CLI custom: - domaine: ProjectIconSet (devicon, simple-icons, lucide, idea), ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon optionnel sur ProjectSettings; sérialisation camelCase et try_from strict - application: UpdateProjectSettings.execute accepte un patch theme_color OU icon (absent = inchangé, null = défaut produit), tests in-dependants avec store mémoire - backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto avec icon optionnel (désérialisation additive), commandes update_project_settings alignées Tauri/HTTP, tests DTO et HTTP (défaut null, set, clear, nom invalide rejeté) - frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en grille multi-lignes dans Paramètres projet, affichage de l'icône dans ProjectTabs et watermark grand format à faible opacité en fond de CLI custom; ports ProjectSettings.icon, Partial<ProjectSettings>, adapters tauri/mock/http et tests alignés #326 — localisation FR/EN et traduction des textes IdeA: - domaine: IdeaLanguage (fr | en), champ language optionnel sur IdeaUserSettings (absence = défaut produit, série additive validée) - backend/web-server: UserSettingsDto.language, update_user_settings accepte language, tests HTTP alignés; fixtures structured_launch_d3 mises à jour - frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression des sources visibles), GeneralSettings avec sélecteur de langue dans les paramètres, setupI18n dans vitest; migration de l'ensemble des textes hardcodés vers le catalogue sur toutes les surfaces (agents, layout, projets, tickets, sprints, web, plugins, permissions, git, templates, skills, mémoire, model servers, embedder, first-run, devices, scheduled tasks, workstate, dialogs), FR conservé par défaut; recherche d'icônes #325 en anglais conformément au ticket QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
574 lines
17 KiB
TypeScript
574 lines
17 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 { beforeEach, 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(`Sélecteur d’agent ${cellId}`);
|
||
const controls = selector.parentElement as HTMLElement;
|
||
return {
|
||
selector,
|
||
controls,
|
||
splitColumns: screen.getByLabelText(`Diviser ${cellId} en colonnes`),
|
||
close: screen.getByLabelText(`Fermer ${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,
|
||
});
|
||
}
|
||
|
||
beforeEach(() => {
|
||
window.localStorage.clear();
|
||
});
|
||
|
||
describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||
it("shows restart only for an agent cell and disables its inputs while scheduled", async () => {
|
||
const setup = await makeSplitAgentGrid();
|
||
const restart = vi
|
||
.spyOn(setup.gateways.agent, "restartCellAgentRuntime")
|
||
.mockResolvedValue({
|
||
restartId: "restart-cell-1",
|
||
status: "scheduled",
|
||
agentId: setup.agentId,
|
||
nodeId: setup.bId,
|
||
kind: "pty",
|
||
presentation: "tui",
|
||
layoutId: null,
|
||
});
|
||
renderGrid(setup.gateways);
|
||
|
||
const button = await screen.findByRole("button", { name: "Redémarrer l’agent" });
|
||
expect(screen.getAllByRole("button", { name: "Redémarrer l’agent" })).toHaveLength(1);
|
||
fireEvent.click(button);
|
||
|
||
await waitFor(() =>
|
||
expect(restart).toHaveBeenCalledWith({
|
||
projectId: "p1",
|
||
nodeId: setup.bId,
|
||
agentId: setup.agentId,
|
||
presentation: "tui",
|
||
}),
|
||
);
|
||
expect(
|
||
(screen.getByRole("button", {
|
||
name: "Redémarrage de l’agent…",
|
||
}) as HTMLButtonElement).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByLabelText(`Sélecteur d’agent ${setup.bId}`) as HTMLSelectElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(screen.getByText("Redémarrage de l’agent…")).toBeTruthy();
|
||
|
||
await flush();
|
||
act(() => {
|
||
setup.system.emit({
|
||
type: "agentRestartCompleted",
|
||
agentId: setup.agentId,
|
||
restartId: "restart-cell-1",
|
||
});
|
||
});
|
||
await waitFor(() =>
|
||
expect(
|
||
(screen.getByRole("button", {
|
||
name: "Redémarrer l’agent",
|
||
}) as HTMLButtonElement).disabled,
|
||
).toBe(false),
|
||
);
|
||
});
|
||
|
||
it("confirms a busy restart and forwards the custom CLI presentation", 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 },
|
||
},
|
||
],
|
||
});
|
||
const restart = vi
|
||
.spyOn(setup.gateways.agent, "restartCellAgentRuntime")
|
||
.mockResolvedValue({
|
||
restartId: "restart-cell-2",
|
||
status: "scheduled",
|
||
agentId: setup.agentId,
|
||
nodeId: setup.bId,
|
||
kind: "structured",
|
||
presentation: "customCli",
|
||
layoutId: null,
|
||
});
|
||
renderGrid(setup.gateways);
|
||
|
||
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
|
||
const dialog = await screen.findByRole("alertdialog", {
|
||
name: "Redémarrer l’agent ?",
|
||
});
|
||
expect(restart).not.toHaveBeenCalled();
|
||
fireEvent.click(screen.getByRole("button", { name: "Interrompre et redémarrer" }));
|
||
|
||
await waitFor(() =>
|
||
expect(restart).toHaveBeenCalledWith({
|
||
projectId: "p1",
|
||
nodeId: setup.bId,
|
||
agentId: setup.agentId,
|
||
presentation: "customCli",
|
||
}),
|
||
);
|
||
expect(dialog.isConnected).toBe(false);
|
||
window.localStorage.removeItem(`idea.agent-cell-mode.p1.${setup.bId}`);
|
||
});
|
||
|
||
it("distinguishes an asynchronous relaunch failure and offers retry/close", async () => {
|
||
const setup = await makeSplitAgentGrid();
|
||
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({
|
||
restartId: "restart-cell-3",
|
||
status: "scheduled",
|
||
agentId: setup.agentId,
|
||
nodeId: setup.bId,
|
||
kind: "pty",
|
||
presentation: "tui",
|
||
layoutId: null,
|
||
});
|
||
renderGrid(setup.gateways);
|
||
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
|
||
await flush();
|
||
|
||
act(() => {
|
||
setup.system.emit({
|
||
type: "agentRestartFailed",
|
||
agentId: setup.agentId,
|
||
restartId: "restart-cell-3",
|
||
phase: "launching",
|
||
code: "LAUNCH_FAILED",
|
||
message: "provider unavailable",
|
||
});
|
||
});
|
||
|
||
expect(await screen.findByText("Impossible de relancer l’agent.")).toBeTruthy();
|
||
expect(screen.getByText("provider unavailable")).toBeTruthy();
|
||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||
fireEvent.click(screen.getByRole("button", { name: "Fermer" }));
|
||
expect(screen.queryByTestId("cell-agent-restart-state")).toBeNull();
|
||
});
|
||
|
||
it("distinguishes an asynchronous stop failure", async () => {
|
||
const setup = await makeSplitAgentGrid();
|
||
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({
|
||
restartId: "restart-cell-stop",
|
||
status: "scheduled",
|
||
agentId: setup.agentId,
|
||
nodeId: setup.bId,
|
||
kind: "pty",
|
||
presentation: "tui",
|
||
layoutId: null,
|
||
});
|
||
renderGrid(setup.gateways);
|
||
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
|
||
await flush();
|
||
|
||
act(() => {
|
||
setup.system.emit({
|
||
type: "agentRestartFailed",
|
||
agentId: setup.agentId,
|
||
restartId: "restart-cell-stop",
|
||
phase: "stopping",
|
||
code: "STOP_TIMEOUT",
|
||
message: "stop timed out",
|
||
});
|
||
});
|
||
|
||
expect(await screen.findByText("Impossible d’arrêter l’agent.")).toBeTruthy();
|
||
});
|
||
|
||
it("surfaces a synchronous typed binding error", async () => {
|
||
const setup = await makeSplitAgentGrid();
|
||
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockRejectedValue({
|
||
code: "AGENT_NOT_BOUND_TO_CELL",
|
||
message: "stale cell binding",
|
||
});
|
||
renderGrid(setup.gateways);
|
||
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
|
||
|
||
expect(
|
||
await screen.findByText("L’agent n’est plus associé à cette cellule."),
|
||
).toBeTruthy();
|
||
expect(screen.getByText("stale cell binding")).toBeTruthy();
|
||
});
|
||
|
||
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 de l’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(`Sélecteur d’agent ${setup.bId}`)).toBeTruthy();
|
||
expect(screen.getByRole("group", {
|
||
name: `Mode CLI de l’agent ${setup.bId}`,
|
||
})).toBeTruthy();
|
||
expect(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`)).toBeTruthy();
|
||
expect(screen.getByLabelText(`Diviser ${setup.bId} en lignes`)).toBeTruthy();
|
||
expect(screen.getByLabelText(`Fermer ${setup.bId}`)).toBeTruthy();
|
||
expect(screen.queryByRole("button", {
|
||
name: `cancel current turn ${setup.bId}`,
|
||
})).toBeNull();
|
||
expect(screen.queryByText("Busy")).toBeNull();
|
||
|
||
fireEvent.click(screen.getByLabelText(`Diviser ${setup.bId} en colonnes`));
|
||
await waitFor(() =>
|
||
expect(mutate).toHaveBeenCalledWith(
|
||
"p1",
|
||
expect.objectContaining({
|
||
type: "split",
|
||
target: setup.bId,
|
||
direction: "row",
|
||
}),
|
||
undefined,
|
||
),
|
||
);
|
||
|
||
const selector = screen.getByLabelText(`Sélecteur d’agent ${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,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
it("gives the native TUI restart overlay priority over other cell veils", async () => {
|
||
const setup = await makeSplitAgentGrid();
|
||
renderGrid(setup.gateways);
|
||
await flush();
|
||
|
||
act(() => {
|
||
setup.system.emit(busy(setup.agentId, true));
|
||
setup.system.emit({
|
||
type: "agentRestartRequested",
|
||
agentId: setup.agentId,
|
||
restartId: "restart-1",
|
||
reason: null,
|
||
});
|
||
});
|
||
|
||
const overlay = await screen.findByTestId("agent-restart-overlay");
|
||
expect(overlay.className).toContain("z-[4]");
|
||
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
|
||
expect(screen.queryByText("------- redemarrage de l'agent -------")).toBeNull();
|
||
expect(controlsFor(setup.bId).controls.style.zIndex).toBe("5");
|
||
});
|
||
});
|