feat: streamline structured transcript
This commit is contained in:
+34
-82
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user