fix(frontend): la conversation survit au redemarrage agent et a l'aller-retour projet — #291 (ProjectsView/WebWorkspace conservent le pointeur viewerConversation mais ne rendent le viewer que si sa conversation appartient au projet actif: le projet intermediaire affiche sa propre surface sans fuite, le retour au projet source remonte la vue avec la meme cle project/conversation et relit le transcript persiste; useConversationThread ne vide plus le buffer au changement de conversation cible, un contenu deja visible reste affiche pendant relecture ou echec; l'erreur de lecture devient "Impossible de recharger la conversation." avec bouton Reessayer branchant refreshTail sans retirer les tours visibles; QA verte reconfirmee par Git: ProjectsView.ls7 scenario "rehydrates the same conversation" (restart agent, switch alpha->beta->alpha, deux readPage identiques sur {projectId, conversationId}), ConversationViewer + useConversationThread 19/19, npm run build, git diff --check; reserves hors perimetre preexistantes: 3 echecs historiques ProjectsView.ls7 (helper panneau Work/open conversation) + 1 echec WebDesktopWorkspace (libelle anglais Project context); validation runtime AppImage/live restante; aucun push)

This commit is contained in:
2026-09-09 09:54:52 +02:00
parent 162de27b1b
commit 2c72205937
6 changed files with 83 additions and 37 deletions

View File

@ -4,7 +4,7 @@
* states, scroll-up "load older" trigger, and the back button (`onClose`). * states, scroll-up "load older" trigger, and the back button (`onClose`).
*/ */
import { describe, it, expect, vi } from "vitest"; 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 type { ReactNode } from "react";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
@ -256,7 +256,12 @@ describe("ConversationViewer", () => {
}; };
renderViewer({ conversation: failing }); renderViewer({ conversation: failing });
const alert = await screen.findByRole("alert"); 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 () => { it("offers a 'load older' control when more turns exist and triggers loadOlder", async () => {

View File

@ -288,12 +288,20 @@ export function ConversationViewer({
{/* ── Error ── */} {/* ── Error ── */}
{vm.error && ( {vm.error && (
<p <div
role="alert" role="alert"
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger" className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
> >
{vm.error} <span>Impossible de recharger la conversation.</span>
</p> <Button
size="sm"
variant="ghost"
onClick={() => void vm.refreshTail()}
loading={vm.busy}
>
Réessayer
</Button>
</div>
)} )}
{/* ── Thread ── */} {/* ── Thread ── */}

View File

@ -151,10 +151,9 @@ export function useConversationThread(
// (Re)open whenever the target conversation changes. // (Re)open whenever the target conversation changes.
useEffect(() => { useEffect(() => {
setBuffer([]);
setHasMore(false); setHasMore(false);
void open(); void open();
}, [open, setBuffer]); }, [open]);
return { turns, busy, error, hasMore, loadOlder, refreshTail }; return { turns, busy, error, hasMore, loadOlder, refreshTail };
} }

View File

@ -1,8 +1,9 @@
/** /**
* LS7 — `ProjectsView` integration: clicking a conversation in the Work panel swaps * LS7 — `ProjectsView` integration: clicking a conversation in the Work panel swaps
* the terminal grid for the read-only `ConversationViewer`; the back button returns * the terminal grid for the read-only `ConversationViewer`; the back button returns
* to the grid; switching project preserves the viewer; and with no conversation * to the grid; switching away hides the viewer and returning rehydrates it from the
* open the terminal grid is shown unchanged (non-regression). * persisted transcript; and with no conversation open the terminal grid is shown
* unchanged (non-regression).
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
@ -186,15 +187,17 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
).toBeNull(); ).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 project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a"); const alpha = await project.createProject("alpha", "/p/a");
await project.createProject("beta", "/p/b"); await project.createProject("beta", "/p/b");
const reads: string[] = []; const system = new MockSystemGateway();
const reads: Array<{ projectId: string; conversationId: string }> = [];
renderView(project, { renderView(project, {
system,
conversation: { conversation: {
readPage: async (projectId) => { readPage: async (projectId, conversationId) => {
reads.push(projectId); reads.push({ projectId, conversationId });
return { return {
turns: [ turns: [
{ {
@ -225,24 +228,57 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
} }
await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2)); 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" })); 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( 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.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 // Simulate the successful self-restart lifecycle. It must not replace the
// keeps reading the conversation from the project where it was opened. // 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" })); 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( expect(
await screen.findByRole("button", { name: "← Retour aux terminaux" }), await screen.findByRole("button", { name: "← Retour aux terminaux" }),
).toBeTruthy(); ).toBeTruthy();
expect(await screen.findByText(`contenu du fil ${alpha.id}`)).toBeTruthy(); expect(await screen.findByText(`contenu du fil ${alpha.id}`)).toBeTruthy();
expect(screen.queryByTestId("layout-grid")).toBeNull(); 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 () => { it("keeps the viewer open when the active layout changes", async () => {

View File

@ -247,6 +247,8 @@ export function ProjectsView() {
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]); const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
const activeViewerConversation =
viewerConversation?.projectId === active?.id ? viewerConversation : null;
const projectNameById = useMemo(() => { const projectNameById = useMemo(() => {
const names = new Map<string, string>(); const names = new Map<string, string>();
for (const project of vm.projects) names.set(project.id, project.name); for (const project of vm.projects) names.set(project.id, project.name);
@ -924,11 +926,11 @@ export function ProjectsView() {
projectId={active.id} projectId={active.id}
onActiveLayoutChange={setActiveLayout} onActiveLayoutChange={setActiveLayout}
/> />
{viewerConversation ? ( {activeViewerConversation ? (
<ConversationViewer <ConversationViewer
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`} key={`${activeViewerConversation.projectId}-${activeViewerConversation.conversationId}`}
projectId={viewerConversation.projectId} projectId={activeViewerConversation.projectId}
conversationId={viewerConversation.conversationId} conversationId={activeViewerConversation.conversationId}
onClose={() => setViewerConversation(null)} onClose={() => setViewerConversation(null)}
onOpenConversation={openConversation} onOpenConversation={openConversation}
/> />
@ -953,14 +955,6 @@ export function ProjectsView() {
</AnnouncementsProvider> </AnnouncementsProvider>
)} )}
</> </>
) : viewerConversation ? (
<ConversationViewer
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`}
projectId={viewerConversation.projectId}
conversationId={viewerConversation.conversationId}
onClose={() => setViewerConversation(null)}
onOpenConversation={openConversation}
/>
) : ( ) : (
<div className="flex flex-1 justify-center overflow-y-auto p-6"> <div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-xl"> <div className="w-full max-w-xl">

View File

@ -279,9 +279,13 @@ function WebDesktopWorkspace({
useEffect(() => { useEffect(() => {
setActiveLayout(null); setActiveLayout(null);
setViewerConversation(null);
}, [openId]); }, [openId]);
const activeViewerConversation =
viewerConversation?.projectId === openProject?.id
? viewerConversation
: null;
function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) { function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) {
setPlacements((prev) => { setPlacements((prev) => {
const next: WebDesktopPlacements = { ...prev }; const next: WebDesktopPlacements = { ...prev };
@ -535,11 +539,11 @@ function WebDesktopWorkspace({
projectId={openProject.id} projectId={openProject.id}
onActiveLayoutChange={setActiveLayout} onActiveLayoutChange={setActiveLayout}
/> />
{viewerConversation ? ( {activeViewerConversation ? (
<ConversationViewer <ConversationViewer
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`} key={`${activeViewerConversation.projectId}-${activeViewerConversation.conversationId}`}
projectId={viewerConversation.projectId} projectId={activeViewerConversation.projectId}
conversationId={viewerConversation.conversationId} conversationId={activeViewerConversation.conversationId}
onClose={() => setViewerConversation(null)} onClose={() => setViewerConversation(null)}
onOpenConversation={openConversation} onOpenConversation={openConversation}
/> />