fix(projects): le viewer de conversation persiste au changement de layout/projet (#172)
Le viewer (CLI custom) se vidait au changement de layout/projet car l'etat viewerConversationId etait reset sur le changement de projet actif. Remplace par un etat viewerConversation {projectId, conversationId} lie a la conversation elle-meme, rendu contre le projectId de la conversation: la conversation persiste tant que la session headless existe, a l'instar de la TUI native.
This commit is contained in:
@ -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(
|
||||
|
||||
@ -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<OpenConversationViewer | null>(null);
|
||||
const [pendingConversationOpen, setPendingConversationOpen] =
|
||||
useState<PendingConversationOpen | null>(null);
|
||||
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".
|
||||
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 ? (
|
||||
<ConversationViewer
|
||||
key={`${active.id}-${viewerConversationId}`}
|
||||
projectId={active.id}
|
||||
conversationId={viewerConversationId}
|
||||
onClose={() => setViewerConversationId(null)}
|
||||
/>
|
||||
) : active ? (
|
||||
<>
|
||||
<LayoutTabs
|
||||
projectId={active.id}
|
||||
onActiveLayoutChange={setActiveLayout}
|
||||
/>
|
||||
{activeLayoutKind === "gitGraph" ? (
|
||||
{viewerConversation ? (
|
||||
<ConversationViewer
|
||||
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`}
|
||||
projectId={viewerConversation.projectId}
|
||||
conversationId={viewerConversation.conversationId}
|
||||
onClose={() => setViewerConversation(null)}
|
||||
/>
|
||||
) : activeLayoutKind === "gitGraph" ? (
|
||||
<GitGraphView
|
||||
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
|
||||
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="w-full max-w-xl">
|
||||
|
||||
Reference in New Issue
Block a user