Adds a transcript surface for a Structured-view session alongside the existing xterm attach view: a WS /api/structured/:id connection (structured.ts) mounted by ConnectionPool whenever a session's `view` is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts, structuredDiff.ts) covering all eight transcript row kinds. Hierarchy follows the design reference (artifact 88ecd690): the approval is the only lifted, card-like element; a user turn is a tinted block with an accent rail; agent prose is plain text; tool calls are compact bordered rows expanding to output or a coloured, horizontally-scrolling diff computed from ACP's full old/new text. The client renders `reduced_state` and rows exactly as received, with no raw-event folding or turn tracking of its own. Extracted attach.ts's pane-header title building into paneTitle.ts and its connection interface into pane.ts (PaneConnection) so both connection kinds share one contract and one header.
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
// @vitest-environment happy-dom
|
||
import { describe, expect, it, vi } from "vitest";
|
||
|
||
import {
|
||
branchFromWorktreePath,
|
||
buildTranscriptRow,
|
||
renderInlineMarkdown,
|
||
renderRail,
|
||
} from "./structuredRender.js";
|
||
import type { Session, TranscriptRow } from "./types.js";
|
||
|
||
function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow {
|
||
return { id, kind, data };
|
||
}
|
||
|
||
describe("buildTranscriptRow: user/agent/thought", () => {
|
||
it("renders a user row with the text content and no markdown", () => {
|
||
const el = buildTranscriptRow(
|
||
row("user", { content: { type: "text", text: "hi <b>there</b>" } }),
|
||
);
|
||
expect(el.className).toContain("st-row-user");
|
||
expect(el.textContent).toContain("hi <b>there</b>");
|
||
// never actual markup from user input
|
||
expect(el.querySelector("b")).toBeNull();
|
||
});
|
||
|
||
it("renders agent prose as paragraphs with inline markdown", () => {
|
||
const el = buildTranscriptRow(
|
||
row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }),
|
||
);
|
||
expect(el.className).toContain("st-row-agent");
|
||
const ps = el.querySelectorAll("p");
|
||
expect(ps).toHaveLength(2);
|
||
expect(ps[0].innerHTML).toContain("<strong>two</strong>");
|
||
expect(ps[0].innerHTML).toContain("<code>three</code>");
|
||
});
|
||
|
||
it("renders a thought row as plain text", () => {
|
||
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } }));
|
||
expect(el.className).toContain("st-row-thought");
|
||
expect(el.textContent).toBe("hmm");
|
||
});
|
||
|
||
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
|
||
const el = buildTranscriptRow(row("user", { content: { type: "image" } }));
|
||
expect(el.textContent).toContain("[image]");
|
||
});
|
||
|
||
it("never clips text: a very long user message is not truncated", () => {
|
||
const long = "x".repeat(5000);
|
||
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
|
||
expect(el.textContent).toContain(long);
|
||
});
|
||
});
|
||
|
||
describe("renderInlineMarkdown", () => {
|
||
it("escapes raw HTML before applying markdown", () => {
|
||
expect(renderInlineMarkdown("<script>alert(1)</script>")).not.toContain("<script>");
|
||
});
|
||
|
||
it("does not treat a single asterisk pair meant as bold as nested italics", () => {
|
||
expect(renderInlineMarkdown("**bold**")).toBe("<strong>bold</strong>");
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: tool calls", () => {
|
||
it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_call", {
|
||
toolCallId: "t1",
|
||
title: "Read(src/lib.rs)",
|
||
kind: "read",
|
||
status: "completed",
|
||
}),
|
||
);
|
||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read");
|
||
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs");
|
||
expect(head.querySelector(".st-tool-meta")).toBeNull();
|
||
});
|
||
|
||
it("falls back to the kind label and full title when the title has no Name(arg) shape", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" }));
|
||
const head = el.querySelector(".st-tool-head")!;
|
||
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute");
|
||
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd");
|
||
});
|
||
|
||
it("shows a running badge for an in-progress call", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" }));
|
||
const meta = el.querySelector(".st-tool-meta")!;
|
||
expect(meta.textContent).toBe("running");
|
||
expect(meta.className).toContain("st-tool-meta-running");
|
||
});
|
||
|
||
it("shows a failed badge for a failed call", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" }));
|
||
expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed");
|
||
});
|
||
|
||
it("starts collapsed and expands its text output on click", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_call", {
|
||
title: "Grep(x)",
|
||
status: "completed",
|
||
content: [{ type: "content", content: { type: "text", text: "match found" } }],
|
||
}),
|
||
);
|
||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||
const out = el.querySelector(".st-tool-out") as HTMLElement;
|
||
expect(out.hidden).toBe(true);
|
||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||
head.click();
|
||
expect(out.hidden).toBe(false);
|
||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||
expect(out.textContent).toContain("match found");
|
||
head.click();
|
||
expect(out.hidden).toBe(true);
|
||
});
|
||
|
||
it("does not expand when there is nothing to show", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" }));
|
||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||
head.click();
|
||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: tool diffs", () => {
|
||
it("shows +added -removed derived from the diff, and renders coloured hunks on expand", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_diff", {
|
||
title: "Edit(x.rs)",
|
||
status: "completed",
|
||
content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }],
|
||
}),
|
||
);
|
||
const meta = el.querySelector(".st-tool-meta")!;
|
||
expect(meta.textContent).toContain("+1");
|
||
expect(meta.textContent).toContain("−1");
|
||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||
const diff = el.querySelector(".st-diff") as HTMLElement;
|
||
expect(diff).not.toBeNull();
|
||
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
|
||
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
|
||
});
|
||
|
||
it("gives the diff its own horizontally-scrolling container", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_diff", {
|
||
title: "Edit(x.rs)",
|
||
content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }],
|
||
}),
|
||
);
|
||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
|
||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: approval", () => {
|
||
const approvalRow = () =>
|
||
row("approval", {
|
||
sessionId: "s1",
|
||
toolCall: { toolCallId: "t1", title: "Write outside worktree", locations: [{ path: "/etc/x" }] },
|
||
options: [],
|
||
});
|
||
|
||
it("shows the three decision buttons only when it is the pending approval", () => {
|
||
const el = buildTranscriptRow(approvalRow(), { pending: true, onDecide: () => {} });
|
||
const buttons = el.querySelectorAll(".st-approval-acts button");
|
||
expect(buttons).toHaveLength(3);
|
||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||
});
|
||
|
||
it("hides the buttons and shows an answered note once resolved", () => {
|
||
const el = buildTranscriptRow(approvalRow(), { pending: false, onDecide: () => {} });
|
||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
||
});
|
||
|
||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||
const onDecide = vi.fn();
|
||
const el = buildTranscriptRow(approvalRow(), { pending: true, onDecide });
|
||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||
buttons[2].click();
|
||
expect(onDecide).toHaveBeenCalledWith("deny");
|
||
expect(buttons[0].disabled).toBe(true);
|
||
});
|
||
|
||
it("treats a row with no approval state as not pending, defensively", () => {
|
||
const el = buildTranscriptRow(approvalRow());
|
||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||
it("renders a plan or notice row without crashing", () => {
|
||
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
|
||
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("branchFromWorktreePath", () => {
|
||
it("takes everything after the last /worktrees/ segment", () => {
|
||
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge");
|
||
});
|
||
|
||
it("keeps slashes in a branch name that contains them", () => {
|
||
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
|
||
});
|
||
|
||
it("falls back to the basename when there is no /worktrees/ marker", () => {
|
||
expect(branchFromWorktreePath("/some/other/path")).toBe("path");
|
||
});
|
||
});
|
||
|
||
function session(overrides: Partial<Session> = {}): Session {
|
||
return {
|
||
id: "s1",
|
||
cwd: "/repo",
|
||
command: "claude",
|
||
env: {},
|
||
metadata: {},
|
||
status: "Running",
|
||
created_at: "2026-01-01T00:00:00Z",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe("renderRail", () => {
|
||
it("omits every section that has nothing to show", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: null,
|
||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||
toolRowCount: 0,
|
||
usage: null,
|
||
session: session(),
|
||
});
|
||
// Turn always renders (it has a state and a count); the rest are absent.
|
||
expect(el.querySelectorAll("h3")).toHaveLength(1);
|
||
expect(el.querySelector("h3")!.textContent).toBe("Turn");
|
||
});
|
||
|
||
it("renders the plan with status marks", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: {
|
||
entries: [
|
||
{ content: "done thing", priority: "high", status: "completed" },
|
||
{ content: "now thing", priority: "high", status: "in_progress" },
|
||
{ content: "later thing", priority: "low", status: "pending" },
|
||
],
|
||
},
|
||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||
toolRowCount: 0,
|
||
usage: null,
|
||
session: session(),
|
||
});
|
||
const items = el.querySelectorAll(".st-plan li");
|
||
expect(items).toHaveLength(3);
|
||
expect(items[0].className).toBe("st-plan-done");
|
||
expect(items[1].className).toBe("st-plan-now");
|
||
expect(items[2].className).toBe("st-plan-todo");
|
||
});
|
||
|
||
it("renders context usage and a meter proportional to used/size", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: null,
|
||
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
|
||
toolRowCount: 3,
|
||
usage: { used: 50_000, size: 200_000 },
|
||
session: session(),
|
||
});
|
||
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
|
||
});
|
||
|
||
it("renders the worktree section only when the session has one", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: null,
|
||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||
toolRowCount: 0,
|
||
usage: null,
|
||
session: session({
|
||
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
|
||
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
|
||
}),
|
||
});
|
||
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
|
||
expect(headers).toContain("Worktree");
|
||
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
|
||
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
|
||
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
|
||
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
|
||
});
|
||
});
|