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

@ -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(),
};
}