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:
2026-06-22 17:30:51 +02:00
parent 40ca3e522f
commit c19e375849
17 changed files with 1552 additions and 5 deletions

View File

@ -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"]);
});
});

View File

@ -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<string, TurnView[]>();
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<TurnPage> {
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(),
};
}

View File

@ -15,6 +15,7 @@ describe("createMockGateways", () => {
it("exposes all gateways", () => {
expect(Object.keys(gateways).sort()).toEqual([
"agent",
"conversation",
"embedder",
"git",
"input",