diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx index 79e8da56..fe112205 100644 --- a/frontend/src/features/projects/ProjectsView.ls7.test.tsx +++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx @@ -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( diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 5a51c4e8..499ef15a 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -117,6 +117,11 @@ interface PendingConversationOpen { conversationId: string; } +interface OpenConversationViewer { + projectId: string; + conversationId: string; +} + interface PluginMenuRenderBoundaryProps { resetKey: string; fallbackMenus: MenuBarMenu[]; @@ -234,9 +239,8 @@ export function ProjectsView() { // When set, the main area swaps the terminal grid for the read-only // conversation viewer (LS7) — pure local UI state, same mechanic as the // terminal↔gitGraph swap; **not** a backend layout kind. - const [viewerConversationId, setViewerConversationId] = useState< - string | null - >(null); + const [viewerConversation, setViewerConversation] = + useState(null); const [pendingConversationOpen, setPendingConversationOpen] = useState(null); const [taskToasts, setTaskToasts] = useState([]); @@ -295,13 +299,12 @@ export function ProjectsView() { // loading it against the new project's store fails with "not found: layout X". useEffect(() => { setActiveLayout(null); - setViewerConversationId(null); }, [active?.id]); useEffect(() => { if (!active || pendingConversationOpen?.projectId !== active.id) return; setSettingsSection(null); - setViewerConversationId(pendingConversationOpen.conversationId); + setViewerConversation(pendingConversationOpen); dismissFloating(); setPendingConversationOpen(null); }, [active?.id, pendingConversationOpen]); @@ -499,10 +502,12 @@ export function ProjectsView() { }; }, [windowGateway]); - // Opening a conversation viewer takes over the main area — dismiss floating - // windows so they don't obscure the viewer (LS7). Docked views stay beside it. + // Opening a conversation viewer takes over the project content area — dismiss + // floating windows so they don't obscure the viewer (LS7). Docked views and + // the layout tabs stay available beside/above it. function openConversation(conversationId: string) { - setViewerConversationId(conversationId); + if (!active) return; + setViewerConversation({ projectId: active.id, conversationId }); dismissFloating(); } @@ -525,7 +530,6 @@ export function ProjectsView() { if (projectOpen) vm.activateTab(toast.projectId); setSettingsSection(null); - setViewerConversationId(null); setPlacement("work", "floating"); } @@ -901,20 +905,20 @@ export function ProjectsView() { onSectionChange={setSettingsSection} onClose={() => setSettingsSection(null)} /> - ) : active && viewerConversationId ? ( - setViewerConversationId(null)} - /> ) : active ? ( <> - {activeLayoutKind === "gitGraph" ? ( + {viewerConversation ? ( + setViewerConversation(null)} + /> + ) : activeLayoutKind === "gitGraph" ? ( )} + ) : viewerConversation ? ( + setViewerConversation(null)} + /> ) : (