feat(frontend): viewer humain fil-par-paire en lecture seule (LS7)
Frontend pur, lecture seule, consomme read_conversation_page (LS6) ; aucun changement backend. - adapters : conversation.ts (gateway) + conversationNormalization.ts. - features/conversations : useConversationThread, ConversationViewer, index. - domain (types LS7), ports (port + Gateways.conversation), adapters/index, mock (+ clé inventaire). - features/workstate : ProjectWorkStatePanel prop onOpenConversation. - features/projects : ProjectsView swap viewer. - tests (QA, verts) : conversationNormalization, mock/conversationGateway, useConversationThread, ConversationViewer, ProjectsView.ls7. tsc 0, vitest 443/443 (+36). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
208
frontend/src/features/projects/ProjectsView.ls7.test.tsx
Normal file
208
frontend/src/features/projects/ProjectsView.ls7.test.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockGitGateway,
|
||||
MockLayoutGateway,
|
||||
MockProfileGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
MockTerminalGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type {
|
||||
ConversationGateway,
|
||||
Gateways,
|
||||
WorkStateGateway,
|
||||
} from "@/ports";
|
||||
import type { ProjectWorkState } from "@/domain";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
const CONV_ID = "conversation-open-me";
|
||||
|
||||
/** A work-state gateway that returns a fixed, clickable conversation for any project. */
|
||||
function fixedWorkState(): WorkStateGateway {
|
||||
const state: ProjectWorkState = {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Worker",
|
||||
profileId: "codex",
|
||||
busy: { state: "idle" },
|
||||
tickets: [
|
||||
{
|
||||
ticketId: "ticket-1",
|
||||
conversationId: CONV_ID,
|
||||
position: 0,
|
||||
status: "inProgress",
|
||||
source: { kind: "human" },
|
||||
requesterLabel: "Anthony",
|
||||
taskPreview: "do the thing",
|
||||
taskLen: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
conversations: [
|
||||
{
|
||||
conversationId: CONV_ID,
|
||||
status: "ready",
|
||||
objectivePreview: "Objectif du fil",
|
||||
summaryPreview: "Résumé du fil",
|
||||
summaryLen: 13,
|
||||
upTo: "turn-1",
|
||||
recentTurns: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
return { getProjectWorkState: async () => structuredClone(state) };
|
||||
}
|
||||
|
||||
/** A conversation gateway that returns one identifiable turn for any thread. */
|
||||
function fixedConversation(): ConversationGateway {
|
||||
return {
|
||||
readPage: async () => ({
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
atMs: 1_700_000_000_000,
|
||||
role: "prompt",
|
||||
source: { kind: "human" },
|
||||
text: "contenu du fil ouvert",
|
||||
textLen: 21,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderView(project: MockProjectGateway) {
|
||||
const agent = new MockAgentGateway();
|
||||
const gateways = {
|
||||
system: new MockSystemGateway(),
|
||||
project,
|
||||
agent,
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agent),
|
||||
git: new MockGitGateway(),
|
||||
layout: new MockLayoutGateway(),
|
||||
terminal: new MockTerminalGateway(),
|
||||
workState: fixedWorkState(),
|
||||
conversation: fixedConversation(),
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectsView />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function openProjectAndWorkTab(label: string) {
|
||||
await screen.findByText(label);
|
||||
// Open the project whose root is `label`.
|
||||
const li = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((node) => within(node).queryByText(label));
|
||||
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
||||
await screen.findByRole("tab");
|
||||
// Switch the sidebar to the Work panel.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Work" }));
|
||||
}
|
||||
|
||||
describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
it("opens the viewer on a conversation click and returns to the grid on back", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
renderView(project);
|
||||
|
||||
await openProjectAndWorkTab("/p/a");
|
||||
|
||||
// Before: the terminal grid owns the main area (non-regression baseline).
|
||||
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeNull();
|
||||
|
||||
// Click the conversation row in the Work panel.
|
||||
const open = await screen.findByRole("button", {
|
||||
name: `open conversation ${CONV_ID}`,
|
||||
});
|
||||
fireEvent.click(open);
|
||||
|
||||
// After: the viewer replaced the grid in the main area.
|
||||
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();
|
||||
|
||||
// Back ⇒ the terminal grid is restored, the viewer is gone.
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "← Retour aux terminaux" }),
|
||||
);
|
||||
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resets the viewer when the active project changes", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
await project.createProject("beta", "/p/b");
|
||||
renderView(project);
|
||||
|
||||
// Open BOTH projects as tabs (while the Projects sidebar list is visible).
|
||||
await screen.findByText("/p/a");
|
||||
for (const root of ["/p/a", "/p/b"]) {
|
||||
const li = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((node) => within(node).queryByText(root));
|
||||
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
||||
}
|
||||
await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
|
||||
|
||||
// Make alpha the active tab, then open its conversation viewer.
|
||||
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Work" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
||||
);
|
||||
await screen.findByRole("button", { name: "← Retour aux terminaux" });
|
||||
|
||||
// Switch the active project (top tab) ⇒ viewer resets to the grid.
|
||||
fireEvent.click(screen.getByRole("tab", { name: "beta" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the terminal grid unchanged when no conversation is open (non-regression)", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
await screen.findByRole("tab");
|
||||
|
||||
// No conversation opened ⇒ the main area is the terminal grid, no viewer.
|
||||
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -39,6 +39,7 @@ import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { PermissionsPanel } from "@/features/permissions";
|
||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||
import { ConversationViewer } from "@/features/conversations";
|
||||
import { GitPanel, GitGraphView } from "@/features/git";
|
||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
@ -79,6 +80,12 @@ export function ProjectsView() {
|
||||
// truth. `kind` decides whether the main area is the terminal grid or the git
|
||||
// graph view.
|
||||
const [activeLayout, setActiveLayout] = useState<LayoutInfo | null>(null);
|
||||
// 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 active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||
|
||||
@ -89,6 +96,7 @@ export function ProjectsView() {
|
||||
// loading it against the new project's store fails with "not found: layout X".
|
||||
useEffect(() => {
|
||||
setActiveLayout(null);
|
||||
setViewerConversationId(null);
|
||||
}, [active?.id]);
|
||||
|
||||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||
@ -287,7 +295,10 @@ export function ProjectsView() {
|
||||
|
||||
{/* Work-state panel */}
|
||||
{sidebarTab === "work" && active && (
|
||||
<ProjectWorkStatePanel projectId={active.id} />
|
||||
<ProjectWorkStatePanel
|
||||
projectId={active.id}
|
||||
onOpenConversation={setViewerConversationId}
|
||||
/>
|
||||
)}
|
||||
{sidebarTab === "work" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to view work state.</p>
|
||||
@ -348,7 +359,14 @@ export function ProjectsView() {
|
||||
|
||||
{/* ── Main: terminal grid or git graph (fills remaining height) ── */}
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{active ? (
|
||||
{active && viewerConversationId ? (
|
||||
<ConversationViewer
|
||||
key={`${active.id}-${viewerConversationId}`}
|
||||
projectId={active.id}
|
||||
conversationId={viewerConversationId}
|
||||
onClose={() => setViewerConversationId(null)}
|
||||
/>
|
||||
) : active ? (
|
||||
<>
|
||||
<LayoutTabs
|
||||
projectId={active.id}
|
||||
|
||||
Reference in New Issue
Block a user