Files
IdeA/frontend/src/features/layout/LayoutGrid.chat.test.tsx
Blomios 76d91c3487 feat(frontend): queue custom CLI messages while agent is busy and add Final/latest-message navigation — #352 (QA verte)
Custom CLI composer stays active during busy turns: submitting while the
agent works enqueues the message instead of sending it. Local FIFO queue
scoped per cell (projectId+agentId+layoutId+nodeId), entries carry id,
text, attachments snapshot and createdAtMs; single in-flight
transmission, entry removed at send time, auto-drain only on final
chunk, queue preserved on error/Stop with 'Relancer l'agent' action on
stopped cells. 'En attente · N' panel anchored above the composer:
order, 2-line preview, per-entry edit/delete with transient undo, clear
all with confirmation; main button switches Send / 'Mettre en attente'
with live announcement; layout contract updated (CLI visible while
busy, textarea enabled, Stop available). Chat navigation: round up-arrow
under the agent bar jumps to previous Final, round down-arrow above the
composer appears when not at bottom to jump to latest message,
auto-scroll suspended while reading history, prefers-reduced-motion and
44px a11y targets respected.
Ports unchanged (AgentGateway send/cancel/reattach, restartCellAgentRuntime).
QA verte reconfirmée par Git: targeted suites 115/115
(CustomAgentChatView/LayoutGrid/LayoutGrid.chat), git diff --check;
QA: npm run typecheck OK, npm run build OK.
Réserves documentées: queue renderer-local (non persistée après restart
complet), validation visuelle cellules très étroites restante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 22:58:27 +02:00

933 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Ticket #147 — custom CLI mode for structured/headless agent cells.
*
* Plain cells and PTY-only profiles stay on the native TUI. Structured profiles
* get a per-cell toggle; switching a live session requires confirmation, then
* the custom chat view drives `ReplyChunk` streams defensively.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { 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 { AgentProfile } from "@/domain";
import type { Gateways } from "@/ports";
import {
MockAgentGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
MockTerminalGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
const structuredProfile: AgentProfile = {
id: "mock-structured",
name: "Structured Codex",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
};
const ptyProfile: AgentProfile = {
id: "mock-pty",
name: "Plain PTY",
command: "bash",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
};
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
beforeEach(() => {
window.localStorage.clear();
});
async function seeded(profile: AgentProfile): Promise<Gateways> {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
await profileGateway.configureProfiles([profile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: profile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
return {
layout,
agent,
profile: profileGateway,
terminal,
system,
workState,
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways, projectId = "p1") {
const cwd = projectId === "p1" ? "/home/me/proj" : `/home/me/${projectId}`;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId={projectId} cwd={cwd} />
</DIProvider>,
);
}
describe("LayoutGrid custom agent CLI (#147)", () => {
it("does not show the custom CLI toggle in a plain cell", async () => {
const gateways = {
layout: new MockLayoutGateway(),
agent: new MockAgentGateway(),
profile: new MockProfileGateway(),
terminal: new MockTerminalGateway(),
system: new MockSystemGateway(),
} as unknown as Gateways;
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.queryByText("CLI custom")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("does not show the custom CLI toggle for a PTY-only profile", async () => {
renderGrid(await seeded(ptyProfile));
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.queryByText("CLI custom")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("shows the custom CLI toggle for structured profiles and streams a final callout", async () => {
renderGrid(await seeded(structuredProfile));
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
fireEvent.click(screen.getByText("CLI custom"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
fireEvent.click(screen.getByText("Basculer"));
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
fireEvent.click(screen.getByText("Joindre"));
await waitFor(() =>
expect(screen.getByText(/mock-attachment.txt/)).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "hello there" },
});
fireEvent.click(screen.getByText("Envoyer"));
await waitFor(() => expect(screen.getByText("Terminé")).toBeTruthy());
expect(screen.getAllByText(/Agent: reçu/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/hello there/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
});
it("restores preferredView=chat ahead of stale localStorage without a resume warning", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellPreferredView",
target: leafId,
preferredView: "chat",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "tui");
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
.toBeNull();
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("restores a project custom CLI cell after switching away and back without rewriting durable ids", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
await profileGateway.configureProfiles([structuredProfile]);
const agentA = await agent.createAgent("p1", {
name: "Worker A",
profileId: structuredProfile.id,
});
const treeA = await layout.loadLayout("p1");
const leafA = leaves(treeA)[0];
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafA.id,
agent: agentA.id,
});
await layout.mutateLayout("p1", {
type: "setCellPreferredView",
target: leafA.id,
preferredView: "chat",
});
const agentB = await agent.createAgent("p2", {
name: "Worker B",
profileId: structuredProfile.id,
});
const treeB = await layout.loadLayout("p2");
const leafB = leaves(treeB)[0];
await layout.mutateLayout("p2", {
type: "setCellAgent",
target: leafB.id,
agent: agentB.id,
});
const gateways = {
layout,
agent,
profile: profileGateway,
terminal,
system,
workState,
} as unknown as Gateways;
const view = renderGrid(gateways, "p1");
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0];
expect(leaf.preferredView).toBe("chat");
expect(leaf.session).toMatch(/^mock-agent-chat-/);
expect(leaf.conversationId).toMatch(/^mock-conversation-mock-agent-chat-/);
});
const persistedA = leaves(await layout.loadLayout("p1"))[0];
view.rerender(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p2" cwd="/home/me/p2" />
</DIProvider>,
);
await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull());
view.rerender(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByTestId("terminal-view")).toBeNull();
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
.toBeNull();
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
const restoredA = leaves(await layout.loadLayout("p1"))[0];
expect(restoredA.preferredView).toBe("chat");
expect(restoredA.session).toBe(persistedA.session);
expect(restoredA.conversationId).toBe(persistedA.conversationId);
});
it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const agent = gateways.agent as MockAgentGateway;
const sendAgentChat = vi.spyOn(agent, "sendAgentChat");
const workState = gateways.workState as MockWorkStateGateway;
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
const agentId = leaf.agent;
if (!agentId) throw new Error("expected seeded agent");
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId,
name: "Worker",
profileId: structuredProfile.id,
busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-232",
conversationId: "conversation-232",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: "main" },
requesterLabel: "Main",
taskPreview: "Analyse",
taskLen: 7,
},
],
},
],
conversations: [],
});
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
expect(screen.queryByText("Busy · Main")).toBeNull();
expect(screen.getByLabelText(`Sélecteur d’agent ${leaf.id}`)).toBeTruthy();
expect(screen.queryByRole("button", {
name: `cancel current turn ${leaf.id}`,
})).toBeNull();
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
const queueAction = screen.getByRole("button", { name: "Mettre en attente" });
fireEvent.change(composer, { target: { value: "Question suivante" } });
fireEvent.click(queueAction);
expect(await screen.findByText("Message ajouté à la file d’attente")).toBeTruthy();
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
expect((composer as HTMLTextAreaElement).value).toBe("");
expect(sendAgentChat).not.toHaveBeenCalled();
});
it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const workState = gateways.workState as MockWorkStateGateway;
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
const requesterId = leaf.agent;
if (!requesterId) throw new Error("expected seeded requester");
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId: requesterId,
name: "Worker",
profileId: structuredProfile.id,
busy: { state: "idle" },
tickets: [],
},
{
agentId: "target-agent",
name: "Architect",
profileId: structuredProfile.id,
busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-232",
conversationId: "conversation-232",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: requesterId },
requesterLabel: "Worker",
taskPreview: "Répondre",
taskLen: 8,
},
],
},
],
conversations: [],
});
const view = renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Worker attend Architect")).toBeTruthy();
view.unmount();
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Worker attend Architect")).toBeTruthy();
});
it("persists custom CLI composer height per layout cell", async () => {
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({
x: 0,
y: 0,
width: 600,
height: 400,
top: 0,
right: 600,
bottom: 400,
left: 0,
toJSON: () => {},
} as DOMRect);
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
try {
renderGrid(gateways);
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
const handle = screen.getByRole("button", {
name: `Redimensionner l’éditeur ${leafId}`,
});
expect(composer.style.height).toBe("64px");
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
.toBeNull();
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(window, { clientY: 100 });
fireEvent.mouseUp(window);
await waitFor(() => expect(composer.style.height).toBe("164px"));
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
.toBe("164");
} finally {
rectSpy.mockRestore();
}
});
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
await agent.launchAgentChat("p1", created.id, {
cwd: "/home/me/proj",
rows: 24,
cols: 80,
nodeId: "structured-background",
});
const launchChat = vi.spyOn(agent, "launchAgentChat");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
await waitFor(() => expect(launchChat).toHaveBeenCalled());
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
});
it("restores a custom CLI transcript when returning with a conversation id but no session (#217)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafId,
conversationId: "conversation-known",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
sessionId: "chat-resumed",
cellKind: "chat" as const,
});
const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({
sessionId: "chat-resumed",
scrollback: [
{ kind: "userPrompt", text: "known prompt" },
{ kind: "final", content: "known answer" },
],
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
expect(await screen.findByText("known answer")).toBeTruthy();
expect(screen.getByText("known prompt")).toBeTruthy();
expect(launchChat).toHaveBeenCalledWith(
"p1",
created.id,
expect.objectContaining({
nodeId: leafId,
conversationId: "conversation-known",
}),
);
expect(reattachChat).toHaveBeenCalledWith("chat-resumed", expect.any(Function));
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0];
expect(leaf.conversationId).toBe("conversation-known");
expect(leaf.session).toBe("chat-resumed");
});
expect(screen.queryByText("Envoyez un message pour démarrer la conversation structurée."))
.toBeNull();
});
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "dead-structured-session",
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafId,
conversationId: "old-structured-conversation",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "Worker",
profileId: structuredProfile.id,
runtimeState: "stopped",
busy: { state: "idle" },
},
],
});
const launchChat = vi.spyOn(agent, "launchAgentChat");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
workState,
} as unknown as Gateways);
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
await waitFor(() => expect(launchChat).toHaveBeenCalledTimes(1));
expect(launchChat.mock.calls[0]?.[2]).toMatchObject({
cwd: "/home/me/proj",
nodeId: leafId,
freshStart: true,
});
expect(launchChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0];
expect(leaf.session).toMatch(/^mock-agent-chat-/);
expect(leaf.conversationId).toMatch(/^mock-conversation-/);
});
});
it("closes the structured chat session by id before switching back to native TUI (#170)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const closeChat = vi.spyOn(agent, "closeAgentChat");
const stopLive = vi.spyOn(agent, "stopLiveAgent");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
});
fireEvent.click(screen.getByText("TUI native"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
fireEvent.click(screen.getByText("Basculer"));
await waitFor(() =>
expect(closeChat).toHaveBeenCalledWith(expect.stringMatching(/^mock-agent-chat-/)),
);
expect(stopLive).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].preferredView).toBeUndefined();
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui");
});
it("switches a live native TUI session to custom CLI without stopping it first (#189)", async () => {
const gateways = await seeded(structuredProfile);
const agent = gateways.agent as MockAgentGateway;
const terminal = gateways.terminal as MockTerminalGateway;
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
const stopLive = vi.spyOn(agent, "stopLiveAgent");
const closeTerminal = vi.spyOn(terminal, "closeTerminal");
const launchChat = vi.spyOn(agent, "launchAgentChat");
renderGrid(gateways);
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toBeTruthy(),
);
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-session-/);
});
fireEvent.click(screen.getByText("CLI custom"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
expect(screen.getByText(/Une reprise est possible/)).toBeTruthy();
fireEvent.click(screen.getByText("Basculer"));
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
expect(stopLive).not.toHaveBeenCalled();
expect(closeTerminal).not.toHaveBeenCalled();
await waitFor(() => expect(launchChat).toHaveBeenCalled());
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
});
});
it("keeps a restored custom CLI mode while the agent/profile catalog is still loading (#149)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const agentsLoaded = deferred<void>();
const profilesLoaded = deferred<void>();
const originalListAgents = agent.listAgents.bind(agent);
const originalListProfiles = profileGateway.listProfiles.bind(profileGateway);
vi.spyOn(agent, "listAgents").mockImplementation(async (projectId) => {
await agentsLoaded.promise;
return originalListAgents(projectId);
});
vi.spyOn(profileGateway, "listProfiles").mockImplementation(async () => {
await profilesLoaded.promise;
return originalListProfiles();
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
agentsLoaded.resolve();
profilesLoaded.resolve();
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("keeps restored custom CLI mode until a stale catalog refresh sees the agent/profile (#149)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
let staleCatalog = true;
const originalListAgents = agent.listAgents.bind(agent);
const originalListProfiles = profileGateway.listProfiles.bind(profileGateway);
vi.spyOn(agent, "listAgents").mockImplementation(async (projectId) => {
if (staleCatalog) return [];
return originalListAgents(projectId);
});
vi.spyOn(profileGateway, "listProfiles").mockImplementation(async () => {
if (staleCatalog) return [];
return originalListProfiles();
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
await waitFor(() => expect(agent.listAgents).toHaveBeenCalled());
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy();
staleCatalog = false;
system.emit({
type: "agentProfileChanged",
agentId: created.id,
profileId: structuredProfile.id,
});
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("keeps the custom preference and shows a non-destructive state when the profile is incompatible", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([ptyProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: ptyProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const agentsLoaded = deferred<void>();
const profilesLoaded = deferred<void>();
const originalListAgents = agent.listAgents.bind(agent);
const originalListProfiles = profileGateway.listProfiles.bind(profileGateway);
vi.spyOn(agent, "listAgents").mockImplementation(async (projectId) => {
await agentsLoaded.promise;
return originalListAgents(projectId);
});
vi.spyOn(profileGateway, "listProfiles").mockImplementation(async () => {
await profilesLoaded.promise;
return originalListProfiles();
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
agentsLoaded.resolve();
profilesLoaded.resolve();
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].preferredView).toBe("chat");
expect(screen.queryByTestId("terminal-view")).toBeNull();
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
});
it("does not overwrite the custom preference after a profile-change downgrade", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile, ptyProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
await agent.changeAgentProfile("p1", created.id, ptyProfile.id, 24, 80);
system.emit({
type: "agentProfileChanged",
agentId: created.id,
profileId: ptyProfile.id,
});
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].preferredView).toBe("chat");
expect(screen.queryByTestId("terminal-view")).toBeNull();
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
});
});