diff --git a/frontend/src/features/conversations/ConversationViewer.test.tsx b/frontend/src/features/conversations/ConversationViewer.test.tsx index ef387de3..2f66b353 100644 --- a/frontend/src/features/conversations/ConversationViewer.test.tsx +++ b/frontend/src/features/conversations/ConversationViewer.test.tsx @@ -4,7 +4,7 @@ * states, scroll-up "load older" trigger, and the back button (`onClose`). */ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { DIProvider } from "@/app/di"; @@ -256,7 +256,12 @@ describe("ConversationViewer", () => { }; renderViewer({ conversation: failing }); const alert = await screen.findByRole("alert"); - expect(alert.textContent).toContain("lecture impossible"); + expect(alert.textContent).toContain( + "Impossible de recharger la conversation.", + ); + expect( + within(alert).getByRole("button", { name: "Réessayer" }), + ).toBeTruthy(); }); it("offers a 'load older' control when more turns exist and triggers loadOlder", async () => { diff --git a/frontend/src/features/conversations/ConversationViewer.tsx b/frontend/src/features/conversations/ConversationViewer.tsx index ffd54f01..278863ff 100644 --- a/frontend/src/features/conversations/ConversationViewer.tsx +++ b/frontend/src/features/conversations/ConversationViewer.tsx @@ -288,12 +288,20 @@ export function ConversationViewer({ {/* ── Error ── */} {vm.error && ( -

- {vm.error} -

+ Impossible de recharger la conversation. + + )} {/* ── Thread ── */} diff --git a/frontend/src/features/conversations/useConversationThread.ts b/frontend/src/features/conversations/useConversationThread.ts index d68cbebe..1e1e1bc4 100644 --- a/frontend/src/features/conversations/useConversationThread.ts +++ b/frontend/src/features/conversations/useConversationThread.ts @@ -151,10 +151,9 @@ export function useConversationThread( // (Re)open whenever the target conversation changes. useEffect(() => { - setBuffer([]); setHasMore(false); void open(); - }, [open, setBuffer]); + }, [open]); return { turns, busy, error, hasMore, loadOlder, refreshTail }; } diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx index fe112205..9aa1d99b 100644 --- a/frontend/src/features/projects/ProjectsView.ls7.test.tsx +++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx @@ -1,8 +1,9 @@ /** * 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 preserves the viewer; and with no conversation - * open the terminal grid is shown unchanged (non-regression). + * to the grid; switching away hides the viewer and returning rehydrates it from the + * persisted transcript; 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"; @@ -186,15 +187,17 @@ describe("ProjectsView — LS7 conversation viewer integration", () => { ).toBeNull(); }); - it("keeps the viewer attached to its source project when the active project changes", async () => { + it("rehydrates the same conversation after an agent restart and a project roundtrip", async () => { const project = new MockProjectGateway(); const alpha = await project.createProject("alpha", "/p/a"); await project.createProject("beta", "/p/b"); - const reads: string[] = []; + const system = new MockSystemGateway(); + const reads: Array<{ projectId: string; conversationId: string }> = []; renderView(project, { + system, conversation: { - readPage: async (projectId) => { - reads.push(projectId); + readPage: async (projectId, conversationId) => { + reads.push({ projectId, conversationId }); return { turns: [ { @@ -225,24 +228,57 @@ describe("ProjectsView — LS7 conversation viewer integration", () => { } await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2)); - // Make alpha the active tab, then open its conversation viewer. + // Make alpha the active tab, then open its persisted conversation from a + // completion event carrying the stable conversation pointer. fireEvent.click(screen.getByRole("tab", { name: "alpha" })); - openPanel("Work state"); + system.emit({ + type: "backgroundTaskChanged", + projectId: alpha.id, + agentId: "agent-1", + taskId: "task-rehydrate-291", + state: "completed", + conversationId: CONV_ID, + }); fireEvent.click( - await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }), + await screen.findByRole("button", { name: /Background task completed/i }), ); await screen.findByRole("button", { name: "← Retour aux terminaux" }); + await screen.findByText(`contenu du fil ${alpha.id}`); - // Switch the active project (top tab) ⇒ the viewer remains visible and - // keeps reading the conversation from the project where it was opened. + // Simulate the successful self-restart lifecycle. It must not replace the + // persisted conversation pointer with a fresh live buffer. + system.emit({ + type: "agentRestartRequested", + agentId: "agent-1", + restartId: "restart-291", + reason: null, + }); + system.emit({ + type: "agentRestartCompleted", + agentId: "agent-1", + restartId: "restart-291", + }); + + // Switching away shows beta's own surface instead of leaking alpha's viewer. fireEvent.click(screen.getByRole("tab", { name: "beta" })); + expect(await screen.findByTestId("layout-grid")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "← Retour aux terminaux" }), + ).toBeNull(); + // Returning remounts the viewer and reloads the persisted transcript with + // exactly the same project/conversation identity. + fireEvent.click(screen.getByRole("tab", { name: "alpha" })); 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); + await waitFor(() => expect(reads).toHaveLength(2)); + expect(reads).toEqual([ + { projectId: alpha.id, conversationId: CONV_ID }, + { projectId: alpha.id, conversationId: CONV_ID }, + ]); }); it("keeps the viewer open when the active layout changes", async () => { diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 58abf9c8..a43c4476 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -247,6 +247,8 @@ export function ProjectsView() { const [taskToasts, setTaskToasts] = useState([]); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; + const activeViewerConversation = + viewerConversation?.projectId === active?.id ? viewerConversation : null; const projectNameById = useMemo(() => { const names = new Map(); for (const project of vm.projects) names.set(project.id, project.name); @@ -924,11 +926,11 @@ export function ProjectsView() { projectId={active.id} onActiveLayoutChange={setActiveLayout} /> - {viewerConversation ? ( + {activeViewerConversation ? ( setViewerConversation(null)} onOpenConversation={openConversation} /> @@ -953,14 +955,6 @@ export function ProjectsView() { )} - ) : viewerConversation ? ( - setViewerConversation(null)} - onOpenConversation={openConversation} - /> ) : (
diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index d4b16587..581416d7 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -279,9 +279,13 @@ function WebDesktopWorkspace({ useEffect(() => { setActiveLayout(null); - setViewerConversation(null); }, [openId]); + const activeViewerConversation = + viewerConversation?.projectId === openProject?.id + ? viewerConversation + : null; + function setPlacement(panel: WebDesktopPanelId, placement: Exclude) { setPlacements((prev) => { const next: WebDesktopPlacements = { ...prev }; @@ -535,11 +539,11 @@ function WebDesktopWorkspace({ projectId={openProject.id} onActiveLayoutChange={setActiveLayout} /> - {viewerConversation ? ( + {activeViewerConversation ? ( setViewerConversation(null)} onOpenConversation={openConversation} />