diff --git a/.ideai/tickets/172/carnet.md b/.ideai/tickets/172/carnet.md new file mode 100644 index 00000000..21c056e8 --- /dev/null +++ b/.ideai/tickets/172/carnet.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/172/issue.md b/.ideai/tickets/172/issue.md new file mode 100644 index 00000000..901d8641 --- /dev/null +++ b/.ideai/tickets/172/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index be9f2a83..3a00f7bf 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 172 + "nextNumber": 173 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index bad7d3c3..6b4fe63d 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -2058,7 +2058,7 @@ "issueRef": "#157", "path": "157", "title": "CLI custom: surface all available agent progress, MCP activity, and inter-agent delegation flow", - "status": "inProgress", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [ @@ -2068,7 +2068,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786378849914 + "updatedAt": 1786380032724 }, { "issueRef": "#158", @@ -2282,7 +2282,7 @@ "issueRef": "#171", "path": "171", "title": "[OBS] Pipeline d'observabilité conversationnelle — ReplyEvent progress -> UI en temps réel", - "status": "inProgress", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [ @@ -2293,7 +2293,21 @@ "kind": "agent", "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 } ] } \ No newline at end of file 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)} + /> ) : (