/** * LS7 — `ProjectsView` integration: clicking a conversation in the Work panel swaps * the terminal grid for the read-only `ConversationViewer`; the back button returns * to the grid; switching project resets the viewer; and with no conversation open * the terminal grid is shown unchanged (non-regression). */ import { describe, it, expect } from "vitest"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { MockAgentGateway, MockGitGateway, MockLayoutGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockTerminalGateway, MockWindowGateway, } from "@/adapters/mock"; import type { ConversationGateway, Gateways, WorkStateGateway, } from "@/ports"; import type { ProjectWorkState } from "@/domain"; import { DIProvider } from "@/app/di"; import { ProjectsView } from "./ProjectsView"; const CONV_ID = "conversation-open-me"; /** A work-state gateway that returns a fixed, clickable conversation for any project. */ function fixedWorkState(): WorkStateGateway { const state: ProjectWorkState = { agents: [ { agentId: "agent-1", name: "Worker", profileId: "codex", busy: { state: "idle" }, tickets: [ { ticketId: "ticket-1", conversationId: CONV_ID, position: 0, status: "inProgress", source: { kind: "human" }, requesterLabel: "Anthony", taskPreview: "do the thing", taskLen: 12, }, ], }, ], conversations: [ { conversationId: CONV_ID, status: "ready", objectivePreview: "Objectif du fil", summaryPreview: "Résumé du fil", summaryLen: 13, upTo: "turn-1", recentTurns: [], }, ], }; return { getProjectWorkState: async () => structuredClone(state), attachBackgroundTask: async (taskId) => ({ taskId, scrollback: new Uint8Array(), live: false, detach: () => {}, }), cancelBackgroundTask: async () => {}, retryBackgroundTask: async () => {}, }; } /** A conversation gateway that returns one identifiable turn for any thread. */ function fixedConversation(): ConversationGateway { return { readPage: async () => ({ turns: [ { id: "t1", atMs: 1_700_000_000_000, role: "prompt", source: { kind: "human" }, text: "contenu du fil ouvert", textLen: 21, }, ], hasMore: false, }), }; } function renderView( project: MockProjectGateway, overrides: { agent?: MockAgentGateway; system?: MockSystemGateway; } = {}, ) { const agent = overrides.agent ?? new MockAgentGateway(); const system = overrides.system ?? new MockSystemGateway(); const gateways = { system, project, agent, profile: new MockProfileGateway(), template: new MockTemplateGateway(agent), git: new MockGitGateway(), layout: new MockLayoutGateway(), terminal: new MockTerminalGateway(), workState: fixedWorkState(), conversation: fixedConversation(), window: new MockWindowGateway(), } as unknown as Gateways; return render( , ); } /** * Opens a panel floating via the single « Panneaux » menu (#26): menu → panel * entry (opens its placement submenu) → « Flottant ». */ function openPanel(panelTitle: string) { fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); fireEvent.click(screen.getByRole("button", { name: panelTitle })); fireEvent.click(screen.getByRole("button", { name: "Flottant" })); } async function openProjectAndWorkTab(label: string) { await screen.findByText(label); // Open the project whose root is `label` from the welcome projects list. const li = screen .getAllByRole("listitem") .find((node) => within(node).queryByText(label)); fireEvent.click(within(li!).getByRole("button", { name: "Open" })); await screen.findByRole("tab"); // Open the Work panel window from the View menu. openPanel("Work state"); } describe("ProjectsView — LS7 conversation viewer integration", () => { it("opens the viewer on a conversation click and returns to the grid on back", async () => { const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); renderView(project); await openProjectAndWorkTab("/p/a"); // Before: the terminal grid owns the main area (non-regression baseline). expect(await screen.findByTestId("layout-grid")).toBeTruthy(); expect( screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeNull(); // Click the conversation row in the Work panel. const open = await screen.findByRole("button", { name: `open conversation ${CONV_ID}`, }); fireEvent.click(open); // After: the viewer replaced the grid in the main area. expect( await screen.findByRole("button", { name: "← Retour aux terminaux" }), ).toBeTruthy(); expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy(); expect(screen.queryByTestId("layout-grid")).toBeNull(); // Back ⇒ the terminal grid is restored, the viewer is gone. fireEvent.click( screen.getByRole("button", { name: "← Retour aux terminaux" }), ); expect(await screen.findByTestId("layout-grid")).toBeTruthy(); expect( screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeNull(); }); it("resets the viewer when the active project changes", async () => { const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); await project.createProject("beta", "/p/b"); renderView(project); // Open BOTH projects as tabs. Use the projects manager window so the list // persists across activation (the inline welcome list is replaced by the // grid once a project is active). await screen.findByText("/p/a"); openPanel("Projects"); for (const root of ["/p/a", "/p/b"]) { const li = screen .getAllByRole("listitem") .find((node) => within(node).queryByText(root)); fireEvent.click(within(li!).getByRole("button", { name: "Open" })); } await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2)); // Make alpha the active tab, then open its conversation viewer. fireEvent.click(screen.getByRole("tab", { name: "alpha" })); openPanel("Work state"); fireEvent.click( await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }), ); await screen.findByRole("button", { name: "← Retour aux terminaux" }); // Switch the active project (top tab) ⇒ viewer resets to the grid. fireEvent.click(screen.getByRole("tab", { name: "beta" })); await waitFor(() => expect( screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeNull(), ); expect(await screen.findByTestId("layout-grid")).toBeTruthy(); }); it("shows the terminal grid unchanged when no conversation is open (non-regression)", async () => { const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); renderView(project); await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); await screen.findByRole("tab"); // No conversation opened ⇒ the main area is the terminal grid, no viewer. expect(await screen.findByTestId("layout-grid")).toBeTruthy(); expect( screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeNull(); }); it("labels rendezvous completion toasts with requester and target, then opens the conversation", async () => { const project = new MockProjectGateway(); const created = await project.createProject("alpha", "/p/a"); const agent = new MockAgentGateway(); const system = new MockSystemGateway(); const requester = await agent.createAgent(created.id, { name: "Main", profileId: "codex", }); const target = await agent.createAgent(created.id, { name: "DevBackend", profileId: "codex", }); renderView(project, { agent, system }); await openProjectAndWorkTab("/p/a"); system.emit({ type: "backgroundTaskChanged", projectId: created.id, agentId: target.id, taskId: "task-rendezvous-91", state: "completed", requesterAgentId: requester.id, targetAgentId: target.id, conversationId: CONV_ID, }); const toast = await screen.findByRole("button", { name: /Appel Main -> DevBackend terminé/i, }); expect(within(toast).getByText("Task task-ren")).toBeTruthy(); fireEvent.click(toast); expect( await screen.findByRole("button", { name: "← Retour aux terminaux" }), ).toBeTruthy(); expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy(); expect( screen.queryByRole("button", { name: /Appel Main -> DevBackend terminé/i }), ).toBeNull(); }); it("labels rendezvous failure and cancellation toasts with explicit call attribution", async () => { const project = new MockProjectGateway(); const created = await project.createProject("alpha", "/p/a"); const agent = new MockAgentGateway(); const system = new MockSystemGateway(); const requester = await agent.createAgent(created.id, { name: "Main", profileId: "codex", }); const target = await agent.createAgent(created.id, { name: "DevBackend", profileId: "codex", }); renderView(project, { agent, system }); await openProjectAndWorkTab("/p/a"); system.emit({ type: "backgroundTaskChanged", projectId: created.id, agentId: target.id, taskId: "task-rendezvous-failed", state: "failed", requesterAgentId: requester.id, targetAgentId: target.id, }); system.emit({ type: "backgroundTaskChanged", projectId: created.id, agentId: target.id, taskId: "task-rendezvous-cancelled", state: "cancelled", requesterAgentId: requester.id, targetAgentId: target.id, }); expect( await screen.findByRole("button", { name: /Appel Main -> DevBackend en échec/i, }), ).toBeTruthy(); expect( await screen.findByRole("button", { name: /Appel Main -> DevBackend annulé/i, }), ).toBeTruthy(); }); it("keeps the generic background-task toast when rendezvous agent ids are absent", async () => { const project = new MockProjectGateway(); const created = await project.createProject("alpha", "/p/a"); const system = new MockSystemGateway(); renderView(project, { system }); await openProjectAndWorkTab("/p/a"); system.emit({ type: "backgroundTaskChanged", projectId: created.id, agentId: "agent-generic-1", taskId: "task-generic-1", state: "failed", }); const toast = await screen.findByRole("button", { name: /Background task failed/i, }); expect(within(toast).getByText("agent-ge · task-gen")).toBeTruthy(); fireEvent.click(toast); expect( screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeNull(); expect(await screen.findByText("Work")).toBeTruthy(); }); });