merge(projects): viewer de conversation persistant au changement layout/projet (#172, QA vert)

This commit is contained in:
2026-08-11 09:42:52 +02:00
6 changed files with 173 additions and 35 deletions

View File

@ -0,0 +1,15 @@
---
issueRef: "#172"
version: 6
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786433962330
---
2026-08-11 Main
- Signal utilisateur: en CLI custom, changer de layout ou de projet vide la conversation affichée alors que la session headless n'a pas ete reset/clean.
- Regle UX recue: la conversation visible doit rester synchronisee avec la session headless tant qu'elle existe; reset uniquement sur clean/reset explicite ou nouvelle session agent.
- Cadrage Architect: source de verite = session headless + conversation_id persistant; l'etat UI de visualisation ne doit pas etre purge arbitrairement. Suspicion concrete sur `ProjectsView.tsx` avec reset de `viewerConversationId` lors des changements de projet/layout.
- DevBackend: pas de backend requis pour #172; les contrats existants suffisent.
- Premiere passe DevFrontend: conservation de `{projectId, conversationId}` et suppression du reset brutal au changement de projet.
- QA (passe 1): tests/typecheck verts mais verdict ROUGE faute de preuve sur le changement de layout et la regle de reset explicite uniquement. Probe temporaire layout en echec car les `LayoutTabs` n'etaient plus rendus quand le viewer etait actif.
- Reprise DevFrontend: correction du vrai ecart UI en rendant toujours les `LayoutTabs` du projet actif meme quand le viewer est ouvert; suppression aussi du reset du viewer lors du clic sur un toast de background task sans `conversationId`. Commandes relancees par DevFrontend: `npm --prefix frontend test -- ProjectsView.ls7.test.tsx` vert (9 tests) et `npm --prefix frontend run typecheck` vert.
- Points a faire revalider par QA: persistance au changement de projet; persistance au changement de layout avec tabs actionnables; absence de reset implicite via toast sans conversation.

View File

@ -0,0 +1,17 @@
---
id: "d0b2146b-4a5a-489c-8f21-31bbdd8cd9a6"
number: 172
title: "CLI custom: la conversation affichée se vide au changement de layout/projet"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786432939692
updatedAt: 1786433962330
version: 6
---
En CLI custom, changer de layout ou de projet réinitialise l'affichage de la conversation alors que la session headless n'a pas été reset/clean. Attendu: la conversation reste persistante tant que la session existe, à la manière de la TUI native, et la surface reflète la conversation headless existante.

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 172 "nextNumber": 173
} }

View File

@ -2058,7 +2058,7 @@
"issueRef": "#157", "issueRef": "#157",
"path": "157", "path": "157",
"title": "CLI custom: surface all available agent progress, MCP activity, and inter-agent delegation flow", "title": "CLI custom: surface all available agent progress, MCP activity, and inter-agent delegation flow",
"status": "inProgress", "status": "qa",
"priority": "high", "priority": "high",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
@ -2068,7 +2068,7 @@
"kind": "agent", "kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
}, },
"updatedAt": 1786378849914 "updatedAt": 1786380032724
}, },
{ {
"issueRef": "#158", "issueRef": "#158",
@ -2282,7 +2282,7 @@
"issueRef": "#171", "issueRef": "#171",
"path": "171", "path": "171",
"title": "[OBS] Pipeline d'observabilité conversationnelle — ReplyEvent progress -> UI en temps réel", "title": "[OBS] Pipeline d'observabilité conversationnelle — ReplyEvent progress -> UI en temps réel",
"status": "inProgress", "status": "qa",
"priority": "high", "priority": "high",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
@ -2293,7 +2293,21 @@
"kind": "agent", "kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
}, },
"updatedAt": 1786378881038 "updatedAt": 1786380032763
},
{
"issueRef": "#172",
"path": "172",
"title": "CLI custom: la conversation affichée se vide au changement de layout/projet",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786433962330
} }
] ]
} }

View File

@ -1,8 +1,8 @@
/** /**
* 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 resets the viewer; and with no conversation open * to the grid; switching project preserves the viewer; and with no conversation
* the terminal grid is shown unchanged (non-regression). * 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";
@ -100,6 +100,8 @@ function renderView(
project: MockProjectGateway, project: MockProjectGateway,
overrides: { overrides: {
agent?: MockAgentGateway; agent?: MockAgentGateway;
conversation?: ConversationGateway;
layout?: MockLayoutGateway;
system?: MockSystemGateway; system?: MockSystemGateway;
} = {}, } = {},
) { ) {
@ -112,10 +114,10 @@ function renderView(
profile: new MockProfileGateway(), profile: new MockProfileGateway(),
template: new MockTemplateGateway(agent), template: new MockTemplateGateway(agent),
git: new MockGitGateway(), git: new MockGitGateway(),
layout: new MockLayoutGateway(), layout: overrides.layout ?? new MockLayoutGateway(),
terminal: new MockTerminalGateway(), terminal: new MockTerminalGateway(),
workState: fixedWorkState(), workState: fixedWorkState(),
conversation: fixedConversation(), conversation: overrides.conversation ?? fixedConversation(),
window: new MockWindowGateway(), window: new MockWindowGateway(),
} as unknown as Gateways; } as unknown as Gateways;
return render( return render(
@ -184,11 +186,31 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
).toBeNull(); ).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(); const project = new MockProjectGateway();
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");
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 // Open BOTH projects as tabs. Use the projects manager window so the list
// persists across activation (the inline welcome list is replaced by the // 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" }); 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" })); fireEvent.click(screen.getByRole("tab", { name: "beta" }));
await waitFor(() => expect(
expect( await screen.findByRole("button", { name: "← Retour aux terminaux" }),
screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeTruthy();
).toBeNull(), 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 () => { 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(); 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 () => { it("prefixes task toast context with a truncatable project-name line and falls back when unresolved", async () => {
const project = new MockProjectGateway(); const project = new MockProjectGateway();
const created = await project.createProject( const created = await project.createProject(

View File

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