merge(projects): viewer de conversation persistant au changement layout/projet (#172, QA vert)
This commit is contained in:
15
.ideai/tickets/172/carnet.md
Normal file
15
.ideai/tickets/172/carnet.md
Normal 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.
|
||||||
17
.ideai/tickets/172/issue.md
Normal file
17
.ideai/tickets/172/issue.md
Normal 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.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 172
|
"nextNumber": 173
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user