fix(projects): le viewer de conversation persiste au changement de layout/projet (#172)

Le viewer (CLI custom) se vidait au changement de layout/projet car l'etat viewerConversationId etait reset sur le changement de projet actif. Remplace par un etat viewerConversation {projectId, conversationId} lie a la conversation elle-meme, rendu contre le projectId de la conversation: la conversation persiste tant que la session headless existe, a l'instar de la TUI native.
This commit is contained in:
2026-08-11 09:42:23 +02:00
parent fa6136745d
commit a943f0cf8b
2 changed files with 122 additions and 30 deletions

View File

@ -1,8 +1,8 @@
/**
* 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).
* to the grid; switching project preserves 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";
@ -100,6 +100,8 @@ function renderView(
project: MockProjectGateway,
overrides: {
agent?: MockAgentGateway;
conversation?: ConversationGateway;
layout?: MockLayoutGateway;
system?: MockSystemGateway;
} = {},
) {
@ -112,10 +114,10 @@ function renderView(
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agent),
git: new MockGitGateway(),
layout: new MockLayoutGateway(),
layout: overrides.layout ?? new MockLayoutGateway(),
terminal: new MockTerminalGateway(),
workState: fixedWorkState(),
conversation: fixedConversation(),
conversation: overrides.conversation ?? fixedConversation(),
window: new MockWindowGateway(),
} as unknown as Gateways;
return render(
@ -184,11 +186,31 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
).toBeNull();
});
it("resets the viewer when the active project changes", async () => {
it("keeps the viewer attached to its source project when the active project changes", async () => {
const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a");
const alpha = await project.createProject("alpha", "/p/a");
await project.createProject("beta", "/p/b");
renderView(project);
const reads: string[] = [];
renderView(project, {
conversation: {
readPage: async (projectId) => {
reads.push(projectId);
return {
turns: [
{
id: "t1",
atMs: 1_700_000_000_000,
role: "prompt",
source: { kind: "human" },
text: `contenu du fil ${projectId}`,
textLen: 32,
},
],
hasMore: false,
};
},
},
});
// Open BOTH projects as tabs. Use the projects manager window so the list
// persists across activation (the inline welcome list is replaced by the
@ -211,15 +233,41 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
);
await screen.findByRole("button", { name: "← Retour aux terminaux" });
// Switch the active project (top tab) ⇒ viewer resets to the grid.
// Switch the active project (top tab) ⇒ the viewer remains visible and
// keeps reading the conversation from the project where it was opened.
fireEvent.click(screen.getByRole("tab", { name: "beta" }));
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
).toBeNull(),
expect(
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
).toBeTruthy();
expect(await screen.findByText(`contenu du fil ${alpha.id}`)).toBeTruthy();
expect(screen.queryByTestId("layout-grid")).toBeNull();
expect(reads.every((projectId) => projectId === alpha.id)).toBe(true);
});
it("keeps the viewer open when the active layout changes", async () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");
const layout = new MockLayoutGateway();
await layout.createLayout(alpha.id, "Beta");
renderView(project, { layout });
await openProjectAndWorkTab("/p/a");
fireEvent.click(
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
);
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
expect(
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
).toBeTruthy();
expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy();
fireEvent.click(await screen.findByRole("tab", { name: "Beta" }));
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();
});
it("shows the terminal grid unchanged when no conversation is open (non-regression)", async () => {
@ -356,6 +404,39 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
expect(await screen.findByText("Work")).toBeTruthy();
});
it("does not close an open viewer when a background-task toast has no conversation", async () => {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const system = new MockSystemGateway();
renderView(project, { system });
await openProjectAndWorkTab("/p/a");
fireEvent.click(
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
);
expect(
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
).toBeTruthy();
system.emit({
type: "backgroundTaskChanged",
projectId: created.id,
agentId: "agent-generic-1",
taskId: "task-generic-1",
state: "failed",
});
fireEvent.click(
await screen.findByRole("button", { name: /Background task failed/i }),
);
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();
});
it("prefixes task toast context with a truncatable project-name line and falls back when unresolved", async () => {
const project = new MockProjectGateway();
const created = await project.createProject(