diff --git a/frontend/src/adapters/conversation.ts b/frontend/src/adapters/conversation.ts new file mode 100644 index 0000000..52d93c7 --- /dev/null +++ b/frontend/src/adapters/conversation.ts @@ -0,0 +1,42 @@ +/** + * Tauri adapter for {@link ConversationGateway} (LS7 human transcript viewer). + * + * This is the only frontend place that knows the `read_conversation_page` + * command name and its argument shape; features consume the gateway port + * through DI. + * + * **Wire contract (verified against LS6 — `crates/app-tauri/src/commands.rs` + * + `dto.rs`):** the command takes a *single* `request` argument deserialized + * into `ReadConversationPageRequestDto` (`#[serde(rename_all = "camelCase")]`) + * with flat fields `projectId`, `conversationId`, `anchor` (optional turn id), + * `direction` (`"forward"`/`"backward"`, default backward) and `limit` + * (optional, `0`/omit ⇒ default 50, clamped to `[1, 200]`). It returns a + * `TurnPageDto` (`turns` / `hasMore` / `nextAnchor`). Note: there is **no** + * nested `cursor` object on the wire — the backend builds the domain + * `PageCursor` itself from the flat `anchor`/`direction` fields. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { TurnPage } from "@/domain"; +import type { ConversationGateway, ConversationPageRequest } from "@/ports"; +import { normalizeTurnPage } from "./conversationNormalization"; + +export class TauriConversationGateway implements ConversationGateway { + async readPage( + projectId: string, + conversationId: string, + request?: ConversationPageRequest, + ): Promise { + const page = await invoke("read_conversation_page", { + request: { + projectId, + conversationId, + anchor: request?.anchor, + direction: request?.direction ?? "backward", + limit: request?.limit, + }, + }); + return normalizeTurnPage(page); + } +} diff --git a/frontend/src/adapters/conversationNormalization.test.ts b/frontend/src/adapters/conversationNormalization.test.ts new file mode 100644 index 0000000..741d294 --- /dev/null +++ b/frontend/src/adapters/conversationNormalization.test.ts @@ -0,0 +1,164 @@ +/** + * LS7 — wire → domain normalization for the conversation transcript read, and the + * `TauriConversationGateway.readPage` argument shape (single flat `request`). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { normalizeTurnPage } from "./conversationNormalization"; + +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +describe("normalizeTurnPage", () => { + it("maps a full camelCase TurnPageDto to the domain TurnPage", () => { + const page = normalizeTurnPage({ + turns: [ + { + id: "t1", + atMs: 1_700_000_000_000, + role: "prompt", + source: { kind: "human" }, + text: "bonjour", + textLen: 7, + }, + { + id: "t2", + atMs: 1_700_000_000_500, + role: "response", + source: { kind: "agent", agentId: "agent-7" }, + text: "réponse complète", + textLen: 16, + }, + { + id: "t3", + atMs: 1_700_000_001_000, + role: "toolActivity", + source: { kind: "agent", agentId: "agent-7" }, + text: "tool run", + textLen: 8, + }, + ], + hasMore: true, + nextAnchor: "t3", + }); + + expect(page.hasMore).toBe(true); + expect(page.nextAnchor).toBe("t3"); + expect(page.turns).toHaveLength(3); + expect(page.turns[0]).toEqual({ + id: "t1", + atMs: 1_700_000_000_000, + role: "prompt", + source: { kind: "human" }, + text: "bonjour", + textLen: 7, + }); + // source.kind mapping: agent carries the agentId. + expect(page.turns[1].source).toEqual({ kind: "agent", agentId: "agent-7" }); + // The three roles round-trip verbatim. + expect(page.turns.map((t) => t.role)).toEqual([ + "prompt", + "response", + "toolActivity", + ]); + }); + + it("preserves the full (unbounded) turn text", () => { + const big = "Z".repeat(40_000); + const page = normalizeTurnPage({ + turns: [{ id: "t1", atMs: 1, role: "response", source: { kind: "human" }, text: big, textLen: 40_000 }], + hasMore: false, + }); + expect(page.turns[0].text).toHaveLength(40_000); + expect(page.turns[0].textLen).toBe(40_000); + }); + + it("omits nextAnchor when absent and defaults hasMore to false", () => { + const page = normalizeTurnPage({ turns: [] }); + expect(page.turns).toEqual([]); + expect(page.hasMore).toBe(false); + expect("nextAnchor" in page).toBe(false); + }); + + it("degrades defensively on a malformed payload (never throws)", () => { + const page = normalizeTurnPage({ + turns: [{ id: 42, role: "weird", source: { kind: "ghost" } }, null, "nope"], + hasMore: "yes", + nextAnchor: 99, + }); + // hasMore only true on a strict boolean; non-string nextAnchor dropped. + expect(page.hasMore).toBe(false); + expect("nextAnchor" in page).toBe(false); + // Unknown role ⇒ "prompt"; non-record source ⇒ human; missing text ⇒ "". + expect(page.turns[0].role).toBe("prompt"); + expect(page.turns[1].source).toEqual({ kind: "human" }); + expect(page.turns[0].text).toBe(""); + // An unknown source.kind degrades to human. + expect(page.turns[0].source).toEqual({ kind: "human" }); + }); + + it("returns an empty page for a non-object payload", () => { + expect(normalizeTurnPage(null)).toEqual({ turns: [], hasMore: false }); + expect(normalizeTurnPage(undefined)).toEqual({ turns: [], hasMore: false }); + }); +}); + +describe("TauriConversationGateway.readPage", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("invokes read_conversation_page with a single flat request, default direction backward", async () => { + invokeMock.mockResolvedValue({ turns: [], hasMore: false }); + const { TauriConversationGateway } = await import("./conversation"); + const gw = new TauriConversationGateway(); + + await gw.readPage("proj-1", "conv-1"); + + expect(invokeMock).toHaveBeenCalledTimes(1); + const [command, args] = invokeMock.mock.calls[0]; + expect(command).toBe("read_conversation_page"); + // Single `request` argument with FLAT fields (no nested `cursor`). + expect(args).toEqual({ + request: { + projectId: "proj-1", + conversationId: "conv-1", + anchor: undefined, + direction: "backward", + limit: undefined, + }, + }); + }); + + it("forwards anchor/direction/limit and normalizes the result", async () => { + invokeMock.mockResolvedValue({ + turns: [{ id: "t9", atMs: 5, role: "response", source: { kind: "human" }, text: "ok", textLen: 2 }], + hasMore: true, + nextAnchor: "t9", + }); + const { TauriConversationGateway } = await import("./conversation"); + const gw = new TauriConversationGateway(); + + const page = await gw.readPage("p", "c", { + anchor: "t5", + direction: "forward", + limit: 25, + }); + + expect(invokeMock.mock.calls[0][1]).toEqual({ + request: { + projectId: "p", + conversationId: "c", + anchor: "t5", + direction: "forward", + limit: 25, + }, + }); + // Result is normalized to the domain TurnPage. + expect(page.turns[0].id).toBe("t9"); + expect(page.nextAnchor).toBe("t9"); + expect(page.hasMore).toBe(true); + }); +}); diff --git a/frontend/src/adapters/conversationNormalization.ts b/frontend/src/adapters/conversationNormalization.ts new file mode 100644 index 0000000..4374a7d --- /dev/null +++ b/frontend/src/adapters/conversationNormalization.ts @@ -0,0 +1,67 @@ +/** + * Wire → domain normalization for the conversation transcript read (LS7). + * + * Mirrors the LS6 backend DTO (`crates/app-tauri/src/dto.rs`): `TurnPageDto` + * (camelCase `turns` / `hasMore` / `nextAnchor`), `TurnViewDto` + * (`id` / `atMs` / `role` / `source` / `text` / `textLen`) and the serde-tagged + * `TurnSourceDto` (`{ kind: "human" }` | `{ kind: "agent", agentId }`). The + * `source` mapping is calqued on `workStateNormalization.ts`. + */ + +import type { TurnPage, TurnRole, TurnSource, TurnView } from "@/domain"; + +type RecordLike = Record; + +function isRecord(value: unknown): value is RecordLike { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function numberValue(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +/** Mirror of LS6 `TurnSourceDto` (serde tag `kind`, camelCase `agentId`). */ +function normalizeSource(value: unknown): TurnSource { + if (!isRecord(value)) return { kind: "human" }; + if (value.kind === "agent") { + return { kind: "agent", agentId: stringValue(value.agentId, "unknown") }; + } + return { kind: "human" }; +} + +/** Mirror of the camelCase-serialized `TurnRole` (`prompt`/`response`/`toolActivity`). */ +function normalizeRole(value: unknown): TurnRole { + return value === "response" || value === "toolActivity" ? value : "prompt"; +} + +function normalizeTurn(value: unknown, index: number): TurnView { + const turn = isRecord(value) ? value : {}; + const text = stringValue(turn.text); + return { + id: stringValue(turn.id, `turn-${index}`), + atMs: numberValue(turn.atMs, index), + role: normalizeRole(turn.role), + source: normalizeSource(turn.source), + text, + textLen: numberValue(turn.textLen, text.length), + }; +} + +/** Maps a raw `TurnPageDto` payload to the domain {@link TurnPage}. */ +export function normalizeTurnPage(value: unknown): TurnPage { + const page = isRecord(value) ? value : {}; + const nextAnchor = page.nextAnchor; + return { + turns: arrayValue(page.turns).map(normalizeTurn), + hasMore: page.hasMore === true, + ...(typeof nextAnchor === "string" ? { nextAnchor } : {}), + }; +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 3763702..80715b3 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -26,6 +26,7 @@ import { TauriEmbedderGateway } from "./embedder"; import { TauriGitGateway } from "./git"; import { TauriPermissionGateway } from "./permission"; import { TauriWorkStateGateway } from "./workState"; +import { TauriConversationGateway } from "./conversation"; function notImplemented(what: string): never { const err: GatewayError = { @@ -59,6 +60,7 @@ export function createTauriGateways(): Gateways { embedder: new TauriEmbedderGateway(), permission: new TauriPermissionGateway(), workState: new TauriWorkStateGateway(), + conversation: new TauriConversationGateway(), }; } @@ -77,4 +79,5 @@ export { TauriGitGateway, TauriPermissionGateway, TauriWorkStateGateway, + TauriConversationGateway, }; diff --git a/frontend/src/adapters/mock/conversationGateway.test.ts b/frontend/src/adapters/mock/conversationGateway.test.ts new file mode 100644 index 0000000..8511a42 --- /dev/null +++ b/frontend/src/adapters/mock/conversationGateway.test.ts @@ -0,0 +1,144 @@ +/** + * LS7 — `MockConversationGateway` real pagination, mirroring the LS6 backend + * slicing (strict anchor, clamp `[1,200]`/default 50, `hasMore`, `nextAnchor` = + * last/most-recent turn of the page, full text preserved). + */ +import { describe, it, expect, beforeEach } from "vitest"; + +import type { TurnView } from "@/domain"; +import { MockConversationGateway } from "./index"; + +const P = "proj"; +const C = "conv"; + +function turn(n: number, text = `texte ${n}`): TurnView { + return { + id: `t${n}`, + atMs: 1_700_000_000_000 + n, + role: n % 2 === 0 ? "response" : "prompt", + source: { kind: "human" }, + text, + textLen: text.length, + }; +} + +function seed(gw: MockConversationGateway, count: number): void { + gw._setTurns( + P, + C, + Array.from({ length: count }, (_, i) => turn(i + 1)), + ); +} + +let gw: MockConversationGateway; +beforeEach(() => { + gw = new MockConversationGateway(); +}); + +describe("MockConversationGateway.readPage", () => { + it("backward with no anchor returns the LAST page (most recent), oldest→newest", async () => { + seed(gw, 10); + const page = await gw.readPage(P, C, { direction: "backward", limit: 4 }); + expect(page.turns.map((t) => t.id)).toEqual(["t7", "t8", "t9", "t10"]); + expect(page.hasMore).toBe(true); // older turns exist + expect(page.nextAnchor).toBe("t10"); // last/most-recent of the page + }); + + it("backward anchored returns the strictly-older turns, ascending", async () => { + seed(gw, 10); + const page = await gw.readPage(P, C, { + direction: "backward", + anchor: "t7", + limit: 3, + }); + // Strictly before t7, closest three: t4,t5,t6 — ascending. + expect(page.turns.map((t) => t.id)).toEqual(["t4", "t5", "t6"]); + expect(page.hasMore).toBe(true); // t1..t3 remain + expect(page.nextAnchor).toBe("t6"); + }); + + it("forward anchored returns the strictly-newer turns, ascending", async () => { + seed(gw, 10); + const page = await gw.readPage(P, C, { + direction: "forward", + anchor: "t4", + limit: 3, + }); + expect(page.turns.map((t) => t.id)).toEqual(["t5", "t6", "t7"]); + expect(page.hasMore).toBe(true); + expect(page.nextAnchor).toBe("t7"); + }); + + it("forward with no anchor returns the FIRST page (oldest)", async () => { + seed(gw, 10); + const page = await gw.readPage(P, C, { direction: "forward", limit: 4 }); + expect(page.turns.map((t) => t.id)).toEqual(["t1", "t2", "t3", "t4"]); + expect(page.hasMore).toBe(true); + }); + + it("clamps the limit to [1, 200] and defaults to 50", async () => { + seed(gw, 60); + // Default (no limit) ⇒ 50. + const def = await gw.readPage(P, C, { direction: "backward" }); + expect(def.turns).toHaveLength(50); + // 0 ⇒ default 50. + const zero = await gw.readPage(P, C, { direction: "backward", limit: 0 }); + expect(zero.turns).toHaveLength(50); + // Over 200 ⇒ clamped (only 60 exist, so all 60 here, proving > limit never throws). + const big = await gw.readPage(P, C, { direction: "backward", limit: 999 }); + expect(big.turns).toHaveLength(60); + // A non-positive (negative) limit is treated as "unset" ⇒ default 50. + const neg = await gw.readPage(P, C, { direction: "backward", limit: -5 }); + expect(neg.turns).toHaveLength(50); + // An explicit positive 1 is honoured (lower bound of the clamp). + const one = await gw.readPage(P, C, { direction: "forward", limit: 1 }); + expect(one.turns).toHaveLength(1); + }); + + it("clamps a limit above 200 down to 200", async () => { + seed(gw, 250); + const page = await gw.readPage(P, C, { direction: "forward", limit: 500 }); + expect(page.turns).toHaveLength(200); + expect(page.hasMore).toBe(true); + }); + + it("returns an empty page for an empty conversation", async () => { + const page = await gw.readPage(P, C, { direction: "backward" }); + expect(page.turns).toEqual([]); + expect(page.hasMore).toBe(false); + expect(page.nextAnchor).toBeUndefined(); + }); + + it("returns an empty page for an unknown anchor", async () => { + seed(gw, 5); + const page = await gw.readPage(P, C, { + direction: "forward", + anchor: "does-not-exist", + }); + expect(page.turns).toEqual([]); + expect(page.hasMore).toBe(false); + }); + + it("hasMore is false when the page reaches the thread end", async () => { + seed(gw, 3); + const page = await gw.readPage(P, C, { direction: "backward", limit: 50 }); + expect(page.turns.map((t) => t.id)).toEqual(["t1", "t2", "t3"]); + expect(page.hasMore).toBe(false); + }); + + it("preserves the full (unbounded) turn text", async () => { + const big = "Z".repeat(30_000); + gw._setTurns(P, C, [turn(1, big)]); + const page = await gw.readPage(P, C, { direction: "backward" }); + expect(page.turns[0].text).toHaveLength(30_000); + }); + + it("isolates threads per (project, conversation)", async () => { + gw._setTurns(P, C, [turn(1)]); + gw._setTurns(P, "other", [turn(2), turn(3)]); + const a = await gw.readPage(P, C, { direction: "backward" }); + const b = await gw.readPage(P, "other", { direction: "backward" }); + expect(a.turns.map((t) => t.id)).toEqual(["t1"]); + expect(b.turns.map((t) => t.id)).toEqual(["t2", "t3"]); + }); +}); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 6dc3d8b..4229da3 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -38,10 +38,14 @@ import type { SkillScope, Template, TerminalSession, + TurnPage, + TurnView, Unsubscribe, } from "@/domain"; import type { AgentGateway, + ConversationGateway, + ConversationPageRequest, ConversationDetails, CreateAgentInput, CreateMemoryInput, @@ -1693,6 +1697,86 @@ export class MockWorkStateGateway implements WorkStateGateway { } } +/** + * In-memory {@link ConversationGateway} with **real** pagination, mirroring the + * LS6 backend slicing (`crates/infrastructure/.../conversation_log/mod.rs::page` + * + `domain::clamp_page_limit`): turns stored oldest→newest; anchor pagination + * is **strict** (turns strictly before/after the anchor); `limit` defaults to 50 + * and is clamped to `[1, 200]`; `hasMore` is true when more turns exist in the + * travel direction; `nextAnchor` is the **last** (most recent) turn of the page. + * Full turn text is preserved (never truncated). + */ +export class MockConversationGateway implements ConversationGateway { + private threads = new Map(); + + private key(projectId: string, conversationId: string): string { + return `${projectId}::${conversationId}`; + } + + /** Seeds the turns of a conversation (oldest→newest) for deterministic tests. */ + _setTurns( + projectId: string, + conversationId: string, + turns: TurnView[], + ): void { + this.threads.set( + this.key(projectId, conversationId), + turns.map((t) => structuredClone(t)), + ); + } + + async readPage( + projectId: string, + conversationId: string, + request?: ConversationPageRequest, + ): Promise { + const all = this.threads.get(this.key(projectId, conversationId)) ?? []; + const len = all.length; + const raw = request?.limit; + const limit = Math.min(Math.max(raw && raw > 0 ? raw : 50, 1), 200); + const direction = request?.direction ?? "backward"; + const anchor = request?.anchor; + + let start: number; + let end: number; + let hasMore: boolean; + if (anchor === undefined) { + if (direction === "forward") { + start = 0; + end = Math.min(limit, len); + hasMore = end < len; + } else { + start = Math.max(len - limit, 0); + end = len; + hasMore = start > 0; + } + } else { + const pos = all.findIndex((t) => t.id === anchor); + if (pos === -1) { + start = 0; + end = 0; + hasMore = false; + } else if (direction === "forward") { + start = pos + 1; + end = Math.min(start + limit, len); + hasMore = end < len; + } else { + end = pos; + start = Math.max(end - limit, 0); + hasMore = start > 0; + } + } + + const turns = all.slice(start, end).map((t) => structuredClone(t)); + const last = turns.at(-1); + return { + turns, + hasMore, + ...(last ? { nextAnchor: last.id } : {}), + }; + } +} + function mostRestrictive( project?: PermissionSet["fallback"], agent?: PermissionSet["fallback"], @@ -1722,6 +1806,7 @@ export function createMockGateways(): Gateways { embedder: new MockEmbedderGateway(), permission: new MockPermissionGateway(), workState: new MockWorkStateGateway(), + conversation: new MockConversationGateway(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index e8c66a7..dd3062a 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -15,6 +15,7 @@ describe("createMockGateways", () => { it("exposes all gateways", () => { expect(Object.keys(gateways).sort()).toEqual([ "agent", + "conversation", "embedder", "git", "input", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 085e123..ae67206 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -213,6 +213,36 @@ export interface ProjectWorkState { conversations: ConversationWorkSummary[]; } +// --------------------------------------------------------------------------- +// Conversation transcript (LS7 — human thread-per-pair viewer, mirror of LS6 DTO) +// --------------------------------------------------------------------------- + +/** Nature of a transcript turn (mirror of the backend `TurnRole`). */ +export type TurnRole = "prompt" | "response" | "toolActivity"; + +/** Origin of a transcript turn (mirror of the backend `TurnSource`). */ +export type TurnSource = { kind: "human" } | { kind: "agent"; agentId: string }; + +/** One turn of the human transcript — full text, never truncated. */ +export interface TurnView { + id: string; + atMs: number; + role: TurnRole; + source: TurnSource; + text: string; + textLen: number; +} + +/** Pagination travel direction (`"backward"` = towards older turns). */ +export type PageDirection = "forward" | "backward"; + +/** A page of the human transcript, oldest-to-newest. */ +export interface TurnPage { + turns: TurnView[]; + hasMore: boolean; + nextAnchor?: string; +} + // --------------------------------------------------------------------------- // Permissions (LP1) // --------------------------------------------------------------------------- diff --git a/frontend/src/features/conversations/ConversationViewer.test.tsx b/frontend/src/features/conversations/ConversationViewer.test.tsx new file mode 100644 index 0000000..e3ae617 --- /dev/null +++ b/frontend/src/features/conversations/ConversationViewer.test.tsx @@ -0,0 +1,175 @@ +/** + * LS7 — `ConversationViewer`: chronological turns, role/source labels, header + * User↔Agent vs Agent↔Agent, collapsible tool activity, empty/error/loading + * states, scroll-up "load older" trigger, and the back button (`onClose`). + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { DIProvider } from "@/app/di"; +import { MockConversationGateway, MockWorkStateGateway } from "@/adapters/mock"; +import type { ConversationGateway, Gateways } from "@/ports"; +import type { TurnView } from "@/domain"; +import { ConversationViewer } from "./ConversationViewer"; + +const P = "proj"; +const C = "conv-abcdef123456"; + +function human(n: number, text = `humain ${n}`): TurnView { + return { + id: `t${n}`, + atMs: 1_700_000_000_000 + n, + role: "prompt", + source: { kind: "human" }, + text, + textLen: text.length, + }; +} +function agent(n: number, agentId: string, text = `agent ${n}`): TurnView { + return { + id: `t${n}`, + atMs: 1_700_000_000_000 + n, + role: "response", + source: { kind: "agent", agentId }, + text, + textLen: text.length, + }; +} + +function renderViewer(opts: { + conversation: ConversationGateway; + workState?: MockWorkStateGateway; + onClose?: () => void; +}) { + const workState = opts.workState ?? new MockWorkStateGateway(); + const gateways = { + conversation: opts.conversation, + workState, + } as unknown as Gateways; + const onClose = opts.onClose ?? vi.fn(); + const ui = (children: ReactNode) => ( + {children} + ); + return { + onClose, + ...render( + ui( + , + ), + ), + }; +} + +function seeded(turns: TurnView[]): MockConversationGateway { + const gw = new MockConversationGateway(); + gw._setTurns(P, C, turns); + return gw; +} + +describe("ConversationViewer", () => { + it("renders turns chronologically with their full text", async () => { + const gw = seeded([human(1, "première"), agent(2, "a7", "deuxième")]); + renderViewer({ conversation: gw }); + + expect(await screen.findByText("première")).toBeTruthy(); + expect(screen.getByText("deuxième")).toBeTruthy(); + // Role labels are rendered. + expect(screen.getAllByText(/· Prompt/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/· Response/).length).toBeGreaterThan(0); + }); + + it("titles the header User ↔ Agent and resolves the agent name from work-state", async () => { + const gw = seeded([human(1), agent(2, "agent-7")]); + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { agentId: "agent-7", name: "Architecte", profileId: "x", busy: { state: "idle" } }, + ], + }); + renderViewer({ conversation: gw, workState }); + + expect(await screen.findByText("User ↔ Architecte")).toBeTruthy(); + }); + + it("titles the header Agent ↔ Agent for a delegation thread", async () => { + const gw = seeded([agent(1, "agent-a"), agent(2, "agent-b")]); + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { agentId: "agent-a", name: "Alpha", profileId: "x", busy: { state: "idle" } }, + { agentId: "agent-b", name: "Bravo", profileId: "x", busy: { state: "idle" } }, + ], + }); + renderViewer({ conversation: gw, workState }); + + expect(await screen.findByText("Alpha ↔ Bravo")).toBeTruthy(); + }); + + it("dims and collapses tool activity, expanding on click", async () => { + const gw = seeded([ + human(1), + { ...agent(2, "a7"), role: "toolActivity", text: "détails outillés secrets" }, + ]); + renderViewer({ conversation: gw }); + + const toggle = await screen.findByRole("button", { name: /Tool activity/ }); + // Collapsed by default ⇒ the tool text is hidden. + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByText("détails outillés secrets")).toBeNull(); + + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByText("détails outillés secrets")).toBeTruthy(); + }); + + it("shows the empty state for a conversation with no turns", async () => { + renderViewer({ conversation: seeded([]) }); + expect(await screen.findByText("Aucun tour pour le moment.")).toBeTruthy(); + }); + + it("shows the loading state while the first page is in flight", async () => { + const gated: ConversationGateway = { readPage: () => new Promise(() => {}) }; + renderViewer({ conversation: gated }); + expect(await screen.findByText("Chargement du fil…")).toBeTruthy(); + }); + + it("shows an error alert when the gateway rejects", async () => { + const failing: ConversationGateway = { + readPage: () => Promise.reject(new Error("lecture impossible")), + }; + renderViewer({ conversation: failing }); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("lecture impossible"); + }); + + it("offers a 'load older' control when more turns exist and triggers loadOlder", async () => { + // 120 turns ⇒ the first page (latest 50) has older turns ⇒ the button appears. + const gw = seeded( + Array.from({ length: 120 }, (_, i) => human(i + 1, `tour ${i + 1}`)), + ); + renderViewer({ conversation: gw }); + + const older = await screen.findByRole("button", { + name: "Charger les tours précédents", + }); + // Before: the oldest visible is t71. + expect(screen.queryByText("tour 21")).toBeNull(); + fireEvent.click(older); + // After loadOlder: older turns (e.g. tour 21) are prepended into the view. + expect(await screen.findByText("tour 21")).toBeTruthy(); + }); + + it("calls onClose when the back button is clicked", async () => { + const gw = seeded([human(1)]); + const { onClose } = renderViewer({ conversation: gw }); + fireEvent.click( + await screen.findByRole("button", { name: "← Retour aux terminaux" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/features/conversations/ConversationViewer.tsx b/frontend/src/features/conversations/ConversationViewer.tsx new file mode 100644 index 0000000..7068b6c --- /dev/null +++ b/frontend/src/features/conversations/ConversationViewer.tsx @@ -0,0 +1,232 @@ +/** + * `ConversationViewer` — human, read-only transcript of one conversation pair + * (LS7). Sober by directive: one bubble per turn, full text, oldest→newest, with + * a discreet User↔Agent / Agent↔Agent distinction (two neutral tints, no + * avatars). Tool activity is dimmed and collapsed by default. + * + * Cold human surface: it lives behind a click in the Work panel and swaps the + * terminal grid in local state — zero impact on the agent hot path. + */ + +import { useMemo, useState } from "react"; + +import type { TurnSource, TurnView } from "@/domain"; +import { Button, Spinner, cn } from "@/shared"; +import { useProjectWorkState } from "@/features/workstate"; +import { useConversationThread } from "./useConversationThread"; + +export interface ConversationViewerProps { + projectId: string; + conversationId: string; + onClose: () => void; +} + +function shortId(id: string): string { + return id.length <= 8 ? id : id.slice(0, 8); +} + +/** Stable key for a turn's party (the conversation has at most two). */ +function partyKey(source: TurnSource): string { + return source.kind === "human" ? "human" : `agent:${source.agentId}`; +} + +function formatTime(atMs: number): string { + if (!Number.isFinite(atMs) || atMs <= 0) return ""; + try { + return new Date(atMs).toLocaleString(); + } catch { + return ""; + } +} + +function roleLabel(role: TurnView["role"]): string { + switch (role) { + case "prompt": + return "Prompt"; + case "response": + return "Response"; + case "toolActivity": + return "Tool"; + } +} + +/** One collapsible tool-activity turn (dimmed, folded by default). */ +function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) { + const [open, setOpen] = useState(false); + return ( +
+
+ + {open && ( +
+            {turn.text}
+          
+ )} +
+
+ ); +} + +interface PartyMeta { + /** "User" or the resolved agent name / short id. */ + label: string; + /** Visual side: first party seen is left, second is right. */ + side: "left" | "right"; +} + +export function ConversationViewer({ + projectId, + conversationId, + onClose, +}: ConversationViewerProps) { + const vm = useConversationThread(projectId, conversationId); + // Reuse the already-loaded work-state inventory to resolve agent names. + const work = useProjectWorkState(projectId); + + const agentNames = useMemo(() => { + const map = new Map(); + for (const agent of work.state?.agents ?? []) { + if (agent.name.trim()) map.set(agent.agentId, agent.name.trim()); + } + return map; + }, [work.state]); + + // Derive the (at most two) parties from the turns, in first-appearance order. + const parties = useMemo(() => { + const order: string[] = []; + const meta = new Map(); + for (const turn of vm.turns) { + const key = partyKey(turn.source); + if (meta.has(key)) continue; + const side: PartyMeta["side"] = order.length === 0 ? "left" : "right"; + const label = + turn.source.kind === "human" + ? "User" + : agentNames.get(turn.source.agentId) ?? + `Agent ${shortId(turn.source.agentId)}`; + meta.set(key, { label, side }); + order.push(key); + } + return { order, meta }; + }, [vm.turns, agentNames]); + + const title = useMemo(() => { + const labels = parties.order.map((k) => parties.meta.get(k)?.label ?? "?"); + if (labels.length >= 2) return `${labels[0]} ↔ ${labels[1]}`; + if (labels.length === 1) return labels[0]; + return "Conversation"; + }, [parties]); + + function sideOf(source: TurnSource): "left" | "right" { + return parties.meta.get(partyKey(source))?.side ?? "left"; + } + + return ( +
+ {/* ── Header ── */} +
+
+ + {title} + + + {shortId(conversationId)} + +
+
+ + +
+
+ + {/* ── Error ── */} + {vm.error && ( +

+ {vm.error} +

+ )} + + {/* ── Thread ── */} +
+ {vm.hasMore && ( +
+ +
+ )} + + {vm.busy && vm.turns.length === 0 ? ( +
+ + Chargement du fil… +
+ ) : vm.turns.length === 0 ? ( +

Aucun tour pour le moment.

+ ) : ( + vm.turns.map((turn) => { + const side = sideOf(turn.source); + const sideClass = side === "right" ? "items-end" : "items-start"; + if (turn.role === "toolActivity") { + return ( + + ); + } + const party = parties.meta.get(partyKey(turn.source)); + const time = formatTime(turn.atMs); + return ( +
+
+
+ + {party?.label ?? "?"} + + · {roleLabel(turn.role)} + {time && · {time}} +
+

{turn.text}

+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/frontend/src/features/conversations/index.ts b/frontend/src/features/conversations/index.ts new file mode 100644 index 0000000..5758905 --- /dev/null +++ b/frontend/src/features/conversations/index.ts @@ -0,0 +1,6 @@ +/** Conversations feature: read-only human transcript viewer (LS7). */ + +export { ConversationViewer } from "./ConversationViewer"; +export type { ConversationViewerProps } from "./ConversationViewer"; +export { useConversationThread } from "./useConversationThread"; +export type { ConversationThreadViewModel } from "./useConversationThread"; diff --git a/frontend/src/features/conversations/useConversationThread.test.tsx b/frontend/src/features/conversations/useConversationThread.test.tsx new file mode 100644 index 0000000..ff2d4ee --- /dev/null +++ b/frontend/src/features/conversations/useConversationThread.test.tsx @@ -0,0 +1,160 @@ +/** + * LS7 — `useConversationThread`: initial load = latest page, `loadOlder` prepends + * (no dup, chronological), `hasMore=false` stops, `refreshTail` appends new turns + * (no dup), and `busy`/`error` states. + */ +import { describe, it, expect } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { DIProvider } from "@/app/di"; +import { MockConversationGateway } from "@/adapters/mock"; +import type { ConversationGateway, Gateways } from "@/ports"; +import type { TurnView } from "@/domain"; +import { useConversationThread } from "./useConversationThread"; + +const P = "proj"; +const C = "conv"; + +function turn(n: number): TurnView { + return { + id: `t${n}`, + atMs: 1_700_000_000_000 + n, + role: n % 2 === 0 ? "response" : "prompt", + source: { kind: "human" }, + text: `texte ${n}`, + textLen: 8, + }; +} + +function wrapperFor(conversation: ConversationGateway) { + const gateways = { conversation } as unknown as Gateways; + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function seededGateway(count: number): MockConversationGateway { + const gw = new MockConversationGateway(); + gw._setTurns( + P, + C, + Array.from({ length: count }, (_, i) => turn(i + 1)), + ); + return gw; +} + +describe("useConversationThread", () => { + it("loads the latest page on open (backward, most recent)", async () => { + const gw = seededGateway(120); + const { result } = renderHook(() => useConversationThread(P, C), { + wrapper: wrapperFor(gw), + }); + + await waitFor(() => expect(result.current.turns.length).toBe(50)); + // Most recent 50 (t71..t120), ascending, ending on the newest. + expect(result.current.turns[0].id).toBe("t71"); + expect(result.current.turns.at(-1)?.id).toBe("t120"); + expect(result.current.hasMore).toBe(true); + expect(result.current.busy).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("loadOlder prepends older turns without duplicates, preserving order", async () => { + const gw = seededGateway(120); + const { result } = renderHook(() => useConversationThread(P, C), { + wrapper: wrapperFor(gw), + }); + await waitFor(() => expect(result.current.turns.length).toBe(50)); + + await act(async () => { + await result.current.loadOlder(); + }); + + // t21..t70 prepended before t71..t120 ⇒ t21..t120 (100), strictly ascending. + expect(result.current.turns.length).toBe(100); + expect(result.current.turns[0].id).toBe("t21"); + expect(result.current.turns.at(-1)?.id).toBe("t120"); + // No duplicate ids, order is the numeric order. + const ids = result.current.turns.map((t) => t.id); + expect(new Set(ids).size).toBe(100); + const nums = ids.map((id) => Number(id.slice(1))); + expect(nums).toEqual([...nums].sort((a, b) => a - b)); + }); + + it("loadOlder is a no-op (no growth) when there is nothing older", async () => { + const gw = seededGateway(30); // fits in one default page (50) + const { result } = renderHook(() => useConversationThread(P, C), { + wrapper: wrapperFor(gw), + }); + await waitFor(() => expect(result.current.turns.length).toBe(30)); + expect(result.current.hasMore).toBe(false); + + await act(async () => { + await result.current.loadOlder(); + }); + + // Nothing strictly before the oldest ⇒ buffer unchanged, hasMore stays false. + expect(result.current.turns.length).toBe(30); + expect(result.current.hasMore).toBe(false); + }); + + it("refreshTail appends newly-arrived turns without duplicates", async () => { + const gw = seededGateway(10); + const { result } = renderHook(() => useConversationThread(P, C), { + wrapper: wrapperFor(gw), + }); + await waitFor(() => expect(result.current.turns.length).toBe(10)); + + // Five new turns arrive at the tail of the thread. + gw._setTurns( + P, + C, + Array.from({ length: 15 }, (_, i) => turn(i + 1)), + ); + + await act(async () => { + await result.current.refreshTail(); + }); + + // t11..t15 appended after t1..t10 ⇒ t1..t15, no duplicate. + expect(result.current.turns.map((t) => t.id)).toEqual( + Array.from({ length: 15 }, (_, i) => `t${i + 1}`), + ); + }); + + it("surfaces an error and clears busy when the gateway rejects", async () => { + const failing: ConversationGateway = { + readPage: () => Promise.reject(new Error("boom-store")), + }; + const { result } = renderHook(() => useConversationThread(P, C), { + wrapper: wrapperFor(failing), + }); + + await waitFor(() => expect(result.current.error).toContain("boom-store")); + expect(result.current.busy).toBe(false); + expect(result.current.turns).toEqual([]); + }); + + it("toggles busy while a page request is in flight", async () => { + let release!: (page: { turns: TurnView[]; hasMore: boolean }) => void; + const gated: ConversationGateway = { + readPage: () => + new Promise((resolve) => { + release = resolve; + }), + }; + const { result } = renderHook(() => useConversationThread(P, C), { + wrapper: wrapperFor(gated), + }); + + // The open() call is pending ⇒ busy is true. + await waitFor(() => expect(result.current.busy).toBe(true)); + + await act(async () => { + release({ turns: [turn(1)], hasMore: false }); + }); + await waitFor(() => expect(result.current.busy).toBe(false)); + expect(result.current.turns.map((t) => t.id)).toEqual(["t1"]); + }); +}); diff --git a/frontend/src/features/conversations/useConversationThread.ts b/frontend/src/features/conversations/useConversationThread.ts new file mode 100644 index 0000000..d68cbeb --- /dev/null +++ b/frontend/src/features/conversations/useConversationThread.ts @@ -0,0 +1,160 @@ +/** + * View-model hook for the human conversation transcript viewer (LS7). + * + * Read-only and cold (a human action, never on the agent hot path): it consumes + * the {@link ConversationGateway} port only. The buffer is kept in chronological + * ascending order and deduplicated by turn id: + * + * - **open** → `readPage({ direction: "backward" })` yields the latest page + * (bottom of the thread); + * - **loadOlder** → `readPage` backward anchored on the *oldest* buffered turn, + * **prepended** without duplicates (scroll-up); + * - **refreshTail** → `readPage` forward anchored on the *newest* buffered turn, + * appended; loops until the tail is exhausted. Triggered manually and on the + * same domain events the Work panel already listens to. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { GatewayError, TurnView } from "@/domain"; +import { useGateways } from "@/app/di"; + +export interface ConversationThreadViewModel { + /** Buffered turns, oldest → newest. */ + turns: TurnView[]; + /** A page request is in flight. */ + busy: boolean; + /** Last error message, or `null`. */ + error: string | null; + /** Whether older turns exist before the buffer (scroll-up sentinel). */ + hasMore: boolean; + /** Loads the page of turns just before the oldest buffered one (prepend). */ + loadOlder: () => Promise; + /** Catches the buffer up to the newest turns of the thread (append). */ + refreshTail: () => Promise; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +/** Dedup by id, preserving order (incoming pages never reorder the thread). */ +function dedupById(turns: TurnView[]): TurnView[] { + const seen = new Set(); + const out: TurnView[] = []; + for (const turn of turns) { + if (seen.has(turn.id)) continue; + seen.add(turn.id); + out.push(turn); + } + return out; +} + +export function useConversationThread( + projectId: string, + conversationId: string, +): ConversationThreadViewModel { + const { conversation } = useGateways(); + const [turns, setTurns] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + // Mirror the buffer in a ref so callbacks can anchor without re-creating on + // every turn change (and without racing concurrent loads). + const turnsRef = useRef([]); + const inFlight = useRef(false); + + const setBuffer = useCallback((next: TurnView[]) => { + turnsRef.current = next; + setTurns(next); + }, []); + + const open = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + setError(null); + try { + const page = await conversation.readPage(projectId, conversationId, { + direction: "backward", + }); + setBuffer(dedupById(page.turns)); + setHasMore(page.hasMore); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + inFlight.current = false; + } + }, [conversation, projectId, conversationId, setBuffer]); + + const loadOlder = useCallback(async () => { + if (inFlight.current) return; + const oldest = turnsRef.current[0]; + if (!oldest) { + await open(); + return; + } + inFlight.current = true; + setBusy(true); + setError(null); + try { + const page = await conversation.readPage(projectId, conversationId, { + direction: "backward", + anchor: oldest.id, + }); + if (page.turns.length > 0) { + setBuffer(dedupById([...page.turns, ...turnsRef.current])); + } + setHasMore(page.hasMore); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + inFlight.current = false; + } + }, [conversation, projectId, conversationId, open, setBuffer]); + + const refreshTail = useCallback(async () => { + if (inFlight.current) return; + if (turnsRef.current.length === 0) { + await open(); + return; + } + inFlight.current = true; + setBusy(true); + setError(null); + try { + // Walk forward until the tail is exhausted (bounded by the thread length). + // `hasMore` (older side) is intentionally left untouched by a tail refresh. + for (;;) { + const newest = turnsRef.current.at(-1); + if (!newest) break; + const page = await conversation.readPage(projectId, conversationId, { + direction: "forward", + anchor: newest.id, + }); + if (page.turns.length === 0) break; + setBuffer(dedupById([...turnsRef.current, ...page.turns])); + if (!page.hasMore) break; + } + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + inFlight.current = false; + } + }, [conversation, projectId, conversationId, open, setBuffer]); + + // (Re)open whenever the target conversation changes. + useEffect(() => { + setBuffer([]); + setHasMore(false); + void open(); + }, [open, setBuffer]); + + return { turns, busy, error, hasMore, loadOlder, refreshTail }; +} diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx new file mode 100644 index 0000000..7c2db68 --- /dev/null +++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 0e3478e..8dda41a 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -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(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 && ( - + )} {sidebarTab === "work" && !active && (

Open a project to view work state.

@@ -348,7 +359,14 @@ export function ProjectsView() { {/* ── Main: terminal grid or git graph (fills remaining height) ── */}
- {active ? ( + {active && viewerConversationId ? ( + setViewerConversationId(null)} + /> + ) : active ? ( <> void; } interface WorkStatePanelErrorBoundaryProps { @@ -133,8 +135,10 @@ async function copyToClipboard(text: string): Promise { function ConversationSummaryLine({ summary, + onOpenConversation, }: { summary: ConversationWorkSummary; + onOpenConversation?: (conversationId: string) => void; }) { const text = summaryText(summary); const [expanded, setExpanded] = useState(false); @@ -153,7 +157,18 @@ function ConversationSummaryLine({ > {summaryLabel(summary.status)} - {text} + {onOpenConversation ? ( + + ) : ( + {text} + )}