feat: streamline structured transcript

This commit is contained in:
Miguel Palhas
2026-09-06 15:17:40 +01:00
parent ccdfec8257
commit 22d740a36b
6 changed files with 462 additions and 531 deletions
+34 -82
View File
@@ -13,9 +13,9 @@ vi.mock("./api.js", async () => {
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
import { CatalogStore } from "./events.js";
import type { CatalogStore } from "./events.js";
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js";
import type { ReducedState, StructuredEvent, TranscriptRow } from "./types.js";
/** Just enough of a WebSocket for the connection to drive (mirrors
* events.test.ts's FakeSocket, adapted for JSON structured events). */
@@ -109,7 +109,7 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => {
it("keeps state-only plan rows out of the transcript", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
@@ -138,7 +138,7 @@ describe("structured connection: data flow", () => {
expect(transcript.textContent).toContain("before plan");
expect(transcript.textContent).toContain("after plan");
expect(transcript.textContent).not.toContain("[plan]");
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("ship it");
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
@@ -194,8 +194,7 @@ describe("structured connection: data flow", () => {
"user:1",
"agent:1",
]);
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated");
expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first");
expect(transcript.textContent).not.toContain("updated");
ws.emit({
type: "projection_update",
@@ -216,7 +215,7 @@ describe("structured connection: data flow", () => {
state: idleState,
});
expect(conn.el.querySelector(".structured-rail .st-plan")).toBeNull();
expect(conn.el.querySelector(".structured-rail")).toBeNull();
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"agent:1",
]);
@@ -247,6 +246,31 @@ describe("structured connection: data flow", () => {
conn.dispose();
});
it("groups multiple tools in one user turn but keeps separate turns separate", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 6,
rows: [
row("user:1", "user", { content: { type: "text", text: "first" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("user:2", "user", { content: { type: "text", text: "second" } }),
toolRow("tool:3", "three"),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.hidden).toBe(true);
expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -703,7 +727,7 @@ describe("structured connection: reconnection/error/loading", () => {
const transcript = conn.el.querySelector(".structured-rows")!;
expect(transcript.textContent).toBe("Younew");
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("reconnected");
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
@@ -935,7 +959,6 @@ describe("structured connection: reconnection/error/loading", () => {
describe("structured connection: transcript scrolling", () => {
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
let resizeCallback: ResizeObserverCallback;
beforeEach(() => {
vi.clearAllMocks();
@@ -950,14 +973,6 @@ describe("structured connection: transcript scrolling", () => {
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
frames.delete(id);
});
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
});
});
afterEach(() => {
@@ -986,13 +1001,6 @@ describe("structured connection: transcript scrolling", () => {
for (const [, cb] of pending) cb(0);
}
function resizeBody(width: number): void {
resizeCallback(
[{ contentRect: { width } } as ResizeObserverEntry],
{} as ResizeObserver,
);
}
it("shows the bottom after the initial transcript snapshot is laid out", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -1075,30 +1083,6 @@ describe("structured connection: transcript scrolling", () => {
conn.dispose();
});
it.each([
{ position: "pinned", top: 800, expected: 850, manuallyScrolled: false },
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
])("preserves the $position position across the narrow breakpoint", ({ top, expected, manuallyScrolled }) => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
const { scroll, values } = metrics(conn, 1000, 200);
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames();
values.top = top;
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
resizeBody(500);
values.clientHeight = 150;
runFrames();
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
expect(values.top).toBe(expected);
resizeBody(500);
expect(frames.size).toBe(0);
conn.dispose();
});
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -1211,14 +1195,11 @@ describe("structured connection: transcript scrolling", () => {
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.dispatchEvent(new Event("input"));
resizeBody(500);
expect(frames.size).toBe(0);
conn.onMount({ focus: false });
expect(frames.size).toBe(2);
conn.dispose();
expect(frames.size).toBe(0);
resizeBody(700);
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
runFrames();
expect(values.top).toBe(25);
});
@@ -1276,41 +1257,12 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose();
});
it("dispose closes the socket and unsubscribes from the store", () => {
const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket });
const unsubscribe = vi.spyOn(store, "subscribe");
it("dispose closes the socket", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store });
expect(unsubscribe).toHaveBeenCalled();
const conn = mount({ sockets: [ws] });
conn.dispose();
expect(ws.closed).toBe(true);
});
it("reflects live worktree/blocked-work data from the store in the rail", () => {
const catalogWs = new FakeSocket();
const store = new CatalogStore({ socket: () => catalogWs as unknown as WebSocket });
store.start();
const session: Session = {
id: "s1",
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
blocked_work: { uncommitted_files: 1, untracked_files: 0, unpushed_commits: 2 },
};
catalogWs.emit({ type: "Snapshot", sessions: [session] } as never);
const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.textContent).toContain("feat/x");
conn.dispose();
});
});
describe("structured connection: deferred mount focus", () => {