fix(application,frontend): restauration correcte d'une cellule agent en CLI custom après changement de projet — #322 (QA verte)

Symptôme: après changement de projet puis retour, une cellule en CLI
custom retombait en TUI native et un nouveau switch CLI custom repartait
à neuf (aucune conversation reprenable) car le mode préféré de la
cellule n'était pas persisté dans l'arbre de layout.

- domaine frontend: type PreferredView (tui | chat) et champ
  preferredView optionnel sur LeafCell; opération de layout
  setCellPreferredView persistant la surface préférée (tui = défaut,
  champ omis pour garder les arbres existants compacts)
- LayoutGrid: le mode de présentation d'une cellule agent dérive de
  preferredView persisté; le cache localStorage du mode custom n'est
  plus qu'un cache de migration secondaire; remount par clé
  projectId:layoutId:nodeId à chaque changement de projet pour éviter
  tout état résiduel inter-projets; la conversation/engine session/
  session/agentWasRunning de la cellule sont préservés par les
  bascules chat <-> TUI
- useLayout: setCellPreferredView exposé au view model
- tests: layout_usecases (persistance chat puis retour TUI en
  conservant session/conversation/engine/running), layout.test
  (setCellPreferredView chat persiste, tui omet le champ),
  LayoutGrid.chat et cellControlsLayering alignés

QA verte sur fix/batch-322-323-session-restore-prompt-history. Aucun push.
This commit is contained in:
2026-09-11 18:22:57 +02:00
parent 791e068d2e
commit b0d68c2d14
8 changed files with 322 additions and 134 deletions

View File

@ -55,7 +55,7 @@ import {
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid, shouldFallbackCustomCliMode } from "./LayoutGrid";
import { LayoutGrid } from "./LayoutGrid";
const structuredProfile: AgentProfile = {
id: "mock-structured",
@ -119,10 +119,11 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways) {
function renderGrid(gateways: Gateways, projectId = "p1") {
const cwd = projectId === "p1" ? "/home/me/proj" : `/home/me/${projectId}`;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
<LayoutGrid projectId={projectId} cwd={cwd} />
</DIProvider>,
);
}
@ -182,6 +183,109 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
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 visible while busy and locks only the composer (#247)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
@ -553,6 +657,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
);
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 () => {
@ -695,7 +802,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
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("terminal-view")).toBeTruthy();
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy();
staleCatalog = false;
system.emit({
@ -710,7 +817,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("falls back to native TUI once the catalog confirms the pinned profile is incompatible", async () => {
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();
@ -758,29 +865,16 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
profilesLoaded.resolve();
await waitFor(() =>
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui"),
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(),
);
expect(screen.getByTestId("terminal-view")).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 downgrade a validated custom CLI on a contradictory refetch without a profile-change event (#149)", () => {
expect(
shouldFallbackCustomCliMode({
agentsLoaded: true,
profilesLoaded: true,
cellMode: "custom",
hasPinnedAgent: true,
hasPinnedProfile: true,
customCliAvailable: false,
effectiveCustomCliAvailable: true,
hasTrustedCustomCli: true,
hasLegitProfileChange: false,
}),
).toBe(false);
});
it("falls back to native TUI after a legitimate profile-change event downgrades the profile (#149)", async () => {
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();
@ -820,9 +914,12 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
});
await waitFor(() =>
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui"),
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(),
);
expect(screen.getByTestId("terminal-view")).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();
});
});