fix(web): keep structured transcript pinned
This commit is contained in:
@@ -932,6 +932,226 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: transcript scrolling", () => {
|
||||
let frames: Map<number, FrameRequestCallback>;
|
||||
let nextFrame: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
frames = new Map();
|
||||
nextFrame = 1;
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
const id = nextFrame++;
|
||||
frames.set(id, cb);
|
||||
return id;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||
frames.delete(id);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function metrics(conn: ReturnType<typeof mount>, height: number, clientHeight: number, top = 0) {
|
||||
const scroll = conn.el.querySelector<HTMLElement>(".structured-scroll")!;
|
||||
const values = { height, clientHeight, top };
|
||||
Object.defineProperties(scroll, {
|
||||
scrollHeight: { configurable: true, get: () => values.height },
|
||||
clientHeight: { configurable: true, get: () => values.clientHeight },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => values.top,
|
||||
set: (value: number) => { values.top = value; },
|
||||
},
|
||||
});
|
||||
return { scroll, values };
|
||||
}
|
||||
|
||||
function runFrames(): void {
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
for (const [, cb] of pending) cb(0);
|
||||
}
|
||||
|
||||
it("shows the bottom after the initial transcript snapshot is laid out", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { values } = metrics(conn, 900, 300, 40);
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [
|
||||
row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(values.top).toBe(40);
|
||||
runFrames();
|
||||
expect(values.top).toBe(600);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps pinned appends and patches at the bottom after layout", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { values } = metrics(conn, 600, 200);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.height = 720;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(520);
|
||||
|
||||
values.height = 780;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("agent:1", "agent_text", {
|
||||
content: { type: "text", text: "a longer completion" },
|
||||
}),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(580);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
|
||||
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: 1, rows: [toolRow("tool:1", "output")] } });
|
||||
runFrames();
|
||||
|
||||
values.top = 180;
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
values.height = 1120;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) },
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(180);
|
||||
|
||||
values.height = 1180;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }),
|
||||
},
|
||||
state: {
|
||||
...idleState,
|
||||
pending_approval: {
|
||||
nonce: "n1",
|
||||
request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions },
|
||||
},
|
||||
},
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(180);
|
||||
|
||||
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
|
||||
values.height = 1260;
|
||||
runFrames();
|
||||
expect(values.top).toBe(180);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("resumes following when the user scrolls back near the bottom", () => {
|
||||
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 = 750;
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
values.height = 1100;
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(900);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("preserves a user-scrolled position across a reconnect snapshot", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
const { scroll, values } = metrics(conn, 1000, 200);
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
runFrames();
|
||||
|
||||
values.top = 160;
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
values.height = 1200;
|
||||
second.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [
|
||||
row("agent:1", "agent_text", {
|
||||
content: { type: "text", text: "reconnected" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
runFrames();
|
||||
expect(values.top).toBe(160);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("cancels a pending transcript frame when disposed or parked", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
const { values } = metrics(conn, 900, 300, 25);
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
expect(frames.size).toBe(1);
|
||||
|
||||
conn.onUnmount();
|
||||
expect(frames.size).toBe(0);
|
||||
conn.onMount({ focus: false });
|
||||
expect(frames.size).toBe(2);
|
||||
conn.dispose();
|
||||
expect(frames.size).toBe(0);
|
||||
runFrames();
|
||||
expect(values.top).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: pane lifecycle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user