Le toast de fin de tâche de fond liée à un rendez-vous inter-agent passe de « Main -> DevBackend completed » à « Appel Main -> DevBackend terminé » (idem en échec / annulé), pour nommer explicitement l'appel plutôt qu'un état brut. Fallback générique conservé quand requester/target sont absents. Validations obtenues avant commit : tests frontend annoncés verts pour #91. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
/**
|
|
* 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(
|
|
<DIProvider gateways={gateways}>
|
|
<ProjectsView />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
});
|