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:
42
frontend/src/adapters/conversation.ts
Normal file
42
frontend/src/adapters/conversation.ts
Normal file
@ -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<TurnPage> {
|
||||
const page = await invoke<unknown>("read_conversation_page", {
|
||||
request: {
|
||||
projectId,
|
||||
conversationId,
|
||||
anchor: request?.anchor,
|
||||
direction: request?.direction ?? "backward",
|
||||
limit: request?.limit,
|
||||
},
|
||||
});
|
||||
return normalizeTurnPage(page);
|
||||
}
|
||||
}
|
||||
164
frontend/src/adapters/conversationNormalization.test.ts
Normal file
164
frontend/src/adapters/conversationNormalization.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
67
frontend/src/adapters/conversationNormalization.ts
Normal file
67
frontend/src/adapters/conversationNormalization.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
144
frontend/src/adapters/mock/conversationGateway.test.ts
Normal file
144
frontend/src/adapters/mock/conversationGateway.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ describe("createMockGateways", () => {
|
||||
it("exposes all gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"conversation",
|
||||
"embedder",
|
||||
"git",
|
||||
"input",
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
175
frontend/src/features/conversations/ConversationViewer.test.tsx
Normal file
175
frontend/src/features/conversations/ConversationViewer.test.tsx
Normal file
@ -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) => (
|
||||
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||
);
|
||||
return {
|
||||
onClose,
|
||||
...render(
|
||||
ui(
|
||||
<ConversationViewer
|
||||
projectId={P}
|
||||
conversationId={C}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
232
frontend/src/features/conversations/ConversationViewer.tsx
Normal file
232
frontend/src/features/conversations/ConversationViewer.tsx
Normal file
@ -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 (
|
||||
<div className={cn("flex flex-col", sideClass)}>
|
||||
<div className="max-w-[80%] rounded-md border border-border bg-surface/60 px-3 py-1.5 text-xs text-muted">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 font-medium hover:text-content"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span aria-hidden>{open ? "▾" : "▸"}</span>
|
||||
Tool activity
|
||||
<span className="text-faint">· {turn.textLen} chars</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="mt-1.5 whitespace-pre-wrap break-words font-mono text-[11px] text-muted">
|
||||
{turn.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
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<string, PartyMeta>();
|
||||
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 (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border bg-surface px-4 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-content">
|
||||
{title}
|
||||
</span>
|
||||
<code
|
||||
className="truncate text-xs text-muted"
|
||||
title={conversationId}
|
||||
>
|
||||
{shortId(conversationId)}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.refreshTail()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
← Retour aux terminaux
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Error ── */}
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Thread ── */}
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-auto px-4 py-3">
|
||||
{vm.hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.loadOlder()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Charger les tours précédents
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vm.busy && vm.turns.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Chargement du fil…</span>
|
||||
</div>
|
||||
) : vm.turns.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun tour pour le moment.</p>
|
||||
) : (
|
||||
vm.turns.map((turn) => {
|
||||
const side = sideOf(turn.source);
|
||||
const sideClass = side === "right" ? "items-end" : "items-start";
|
||||
if (turn.role === "toolActivity") {
|
||||
return (
|
||||
<ToolTurn key={turn.id} turn={turn} sideClass={sideClass} />
|
||||
);
|
||||
}
|
||||
const party = parties.meta.get(partyKey(turn.source));
|
||||
const time = formatTime(turn.atMs);
|
||||
return (
|
||||
<div key={turn.id} className={cn("flex flex-col", sideClass)}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-lg border px-3 py-2 text-sm",
|
||||
side === "right"
|
||||
? "border-primary/20 bg-primary/10 text-content"
|
||||
: "border-border bg-raised text-content",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-baseline gap-2 text-xs text-muted">
|
||||
<span className="font-medium text-content">
|
||||
{party?.label ?? "?"}
|
||||
</span>
|
||||
<span>· {roleLabel(turn.role)}</span>
|
||||
{time && <span className="text-faint">· {time}</span>}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
frontend/src/features/conversations/index.ts
Normal file
6
frontend/src/features/conversations/index.ts
Normal file
@ -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";
|
||||
@ -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 <DIProvider gateways={gateways}>{children}</DIProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
160
frontend/src/features/conversations/useConversationThread.ts
Normal file
160
frontend/src/features/conversations/useConversationThread.ts
Normal file
@ -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<void>;
|
||||
/** Catches the buffer up to the newest turns of the thread (append). */
|
||||
refreshTail: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<TurnView[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<TurnView[]>([]);
|
||||
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 };
|
||||
}
|
||||
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}
|
||||
|
||||
@ -21,6 +21,8 @@ import { useProjectWorkState } from "./useProjectWorkState";
|
||||
|
||||
export interface ProjectWorkStatePanelProps {
|
||||
projectId: string;
|
||||
/** Opens the read-only transcript viewer for a conversation pair (LS7). */
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
}
|
||||
|
||||
interface WorkStatePanelErrorBoundaryProps {
|
||||
@ -133,8 +135,10 @@ async function copyToClipboard(text: string): Promise<void> {
|
||||
|
||||
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)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words">{text}</span>
|
||||
{onOpenConversation ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`open conversation ${summary.conversationId}`}
|
||||
className="min-w-0 flex-1 break-words text-left hover:text-content hover:underline"
|
||||
onClick={() => onOpenConversation(summary.conversationId)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 break-words">{text}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content"
|
||||
@ -211,9 +226,11 @@ function ConversationSummaryLine({
|
||||
function TicketRow({
|
||||
ticket,
|
||||
summary,
|
||||
onOpenConversation,
|
||||
}: {
|
||||
ticket: AgentTicketState;
|
||||
summary?: ConversationWorkSummary;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
}) {
|
||||
const truncated = ticket.taskLen > ticket.taskPreview.length;
|
||||
return (
|
||||
@ -251,7 +268,12 @@ function TicketRow({
|
||||
{shortTicket(ticket.ticketId)}
|
||||
</code>
|
||||
</div>
|
||||
{summary && <ConversationSummaryLine summary={summary} />}
|
||||
{summary && (
|
||||
<ConversationSummaryLine
|
||||
summary={summary}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@ -263,6 +285,7 @@ function AgentRow({
|
||||
layout,
|
||||
projectId,
|
||||
onRefresh,
|
||||
onOpenConversation,
|
||||
}: {
|
||||
agent: AgentWorkState;
|
||||
conversations: Map<string, ConversationWorkSummary>;
|
||||
@ -270,6 +293,7 @@ function AgentRow({
|
||||
layout: LayoutViewModel;
|
||||
projectId: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
}) {
|
||||
const { agent: agentGateway } = useGateways();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@ -431,6 +455,7 @@ function AgentRow({
|
||||
key={ticket.ticketId}
|
||||
ticket={ticket}
|
||||
summary={conversations.get(ticket.conversationId)}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -439,7 +464,10 @@ function AgentRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectWorkStatePanelContent({ projectId }: ProjectWorkStatePanelProps) {
|
||||
function ProjectWorkStatePanelContent({
|
||||
projectId,
|
||||
onOpenConversation,
|
||||
}: ProjectWorkStatePanelProps) {
|
||||
const vm = useProjectWorkState(projectId);
|
||||
const layoutVm = useLayout(projectId);
|
||||
const agents = vm.state?.agents ?? [];
|
||||
@ -491,6 +519,7 @@ function ProjectWorkStatePanelContent({ projectId }: ProjectWorkStatePanelProps)
|
||||
layout={layoutVm}
|
||||
projectId={projectId}
|
||||
onRefresh={vm.refresh}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -31,6 +31,7 @@ import type {
|
||||
MemoryType,
|
||||
EffectivePermissions,
|
||||
PermissionSet,
|
||||
PageDirection,
|
||||
Project,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
@ -40,6 +41,7 @@ import type {
|
||||
SkillScope,
|
||||
Template,
|
||||
TerminalSession,
|
||||
TurnPage,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
|
||||
@ -660,6 +662,26 @@ export interface WorkStateGateway {
|
||||
getProjectWorkState(projectId: string): Promise<ProjectWorkState>;
|
||||
}
|
||||
|
||||
/** One paginated page request over a conversation transcript (LS7). */
|
||||
export interface ConversationPageRequest {
|
||||
/** Turn id to paginate around; omit to start from a thread end. */
|
||||
anchor?: string;
|
||||
/** Travel direction; defaults to `"backward"` (latest page first). */
|
||||
direction?: PageDirection;
|
||||
/** Requested page size (clamped by the backend to `[1, 200]`). */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Read-only, paginated access to a conversation's full transcript (LS7). */
|
||||
export interface ConversationGateway {
|
||||
/** Reads one page of a conversation's transcript (full text, never truncated). */
|
||||
readPage(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
request?: ConversationPageRequest,
|
||||
): Promise<TurnPage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of gateways the app depends on, injected via the DI provider.
|
||||
* The composition (real vs mock) is chosen in `app/`.
|
||||
@ -680,4 +702,5 @@ export interface Gateways {
|
||||
embedder: EmbedderGateway;
|
||||
permission: PermissionGateway;
|
||||
workState: WorkStateGateway;
|
||||
conversation: ConversationGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user