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.
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { computeDiffLines, diffStats, type DiffLine } from "./structuredDiff.js";
|
|
|
|
function texts(lines: DiffLine[]): Array<[string, string]> {
|
|
return lines.map((l) => [l.kind, l.text]);
|
|
}
|
|
|
|
describe("computeDiffLines", () => {
|
|
it("renders a brand new file (no oldText) as all additions", () => {
|
|
const lines = computeDiffLines(null, "a\nb\n");
|
|
expect(texts(lines)).toEqual([
|
|
["add", "a"],
|
|
["add", "b"],
|
|
]);
|
|
});
|
|
|
|
it("renders a full deletion as all removals", () => {
|
|
const lines = computeDiffLines("a\nb\n", "");
|
|
expect(texts(lines)).toEqual([
|
|
["del", "a"],
|
|
["del", "b"],
|
|
]);
|
|
});
|
|
|
|
it("shows a small replace as context around del/add", () => {
|
|
const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n");
|
|
expect(texts(lines)).toEqual([
|
|
["ctx", "one"],
|
|
["del", "two"],
|
|
["add", "twx"],
|
|
["ctx", "three"],
|
|
]);
|
|
});
|
|
|
|
it("leaves identical text as pure context, uncollapsed when short", () => {
|
|
const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n");
|
|
expect(texts(lines)).toEqual([
|
|
["ctx", "a"],
|
|
["ctx", "b"],
|
|
["ctx", "c"],
|
|
]);
|
|
});
|
|
|
|
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
|
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
|
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
|
const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n");
|
|
const lines = computeDiffLines(oldText, newText);
|
|
// The leading run keeps only its last 3 lines (adjacent to the change),
|
|
// behind a marker for the rest; the trailing run mirrors that.
|
|
expect(lines[0].kind).toBe("hunk");
|
|
expect(lines[1]).toEqual({ kind: "ctx", text: "line7" });
|
|
expect(lines[2]).toEqual({ kind: "ctx", text: "line8" });
|
|
expect(lines[3]).toEqual({ kind: "ctx", text: "line9" });
|
|
expect(lines[4].kind).toBe("del");
|
|
expect(lines[5].kind).toBe("add");
|
|
expect(lines[6]).toEqual({ kind: "ctx", text: "line10" });
|
|
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
|
});
|
|
|
|
it("produces one hunk for the long middle run, none for the short edges", () => {
|
|
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
|
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
|
const middle = Array.from({ length: 10 }, (_, i) => `c${i}`);
|
|
const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n");
|
|
const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n");
|
|
const lines = computeDiffLines(oldText, newText);
|
|
const dels = lines.filter((l) => l.kind === "del").map((l) => l.text);
|
|
const adds = lines.filter((l) => l.kind === "add").map((l) => l.text);
|
|
expect(dels).toEqual(["OLD1", "OLD2"]);
|
|
expect(adds).toEqual(["NEW1", "NEW2"]);
|
|
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1);
|
|
expect(texts(lines).slice(0, 2)).toEqual([
|
|
["ctx", "lead0"],
|
|
["ctx", "lead1"],
|
|
]);
|
|
expect(texts(lines).slice(-2)).toEqual([
|
|
["ctx", "trail0"],
|
|
["ctx", "trail1"],
|
|
]);
|
|
});
|
|
|
|
it("handles an old file with no trailing newline", () => {
|
|
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
|
expect(texts(lines)).toEqual([
|
|
["ctx", "a"],
|
|
["ctx", "b"],
|
|
["add", "c"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("diffStats", () => {
|
|
it("counts add/del lines, ignoring context and hunk markers", () => {
|
|
const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n");
|
|
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
|
|
});
|
|
|
|
it("is zero for identical text", () => {
|
|
expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 });
|
|
});
|
|
});
|