1372 lines
50 KiB
TypeScript
1372 lines
50 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("./api.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
|
|
return {
|
|
...actual,
|
|
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
|
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
|
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
|
|
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
|
import { CatalogStore } from "./events.js";
|
|
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
|
import type { ReducedState, Session, 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). */
|
|
class FakeSocket {
|
|
onopen: (() => void) | null = null;
|
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
|
closed = false;
|
|
|
|
open(): void {
|
|
this.onopen?.();
|
|
}
|
|
emit(event: StructuredEvent): void {
|
|
this.onmessage?.({ data: JSON.stringify(event) });
|
|
}
|
|
drop(code = 1006, reason = ""): void {
|
|
this.onclose?.({ code, reason });
|
|
}
|
|
close(): void {
|
|
this.closed = true;
|
|
}
|
|
}
|
|
|
|
function row(id: string, kind: TranscriptRow["kind"], data: unknown): TranscriptRow {
|
|
return { id, kind, data };
|
|
}
|
|
|
|
function toolRow(id: string, text: string): TranscriptRow {
|
|
return row(id, "tool_call", {
|
|
title: `Read(${id})`,
|
|
status: "completed",
|
|
content: [{ type: "content", content: { type: "text", text } }],
|
|
});
|
|
}
|
|
|
|
function diffRow(id: string, newText: string): TranscriptRow {
|
|
return row(id, "tool_diff", {
|
|
title: `Edit(${id})`,
|
|
status: "completed",
|
|
content: [{ type: "diff", path: "file.ts", oldText: "before", newText }],
|
|
});
|
|
}
|
|
|
|
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
|
|
|
const allApprovalOptions = [
|
|
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
|
|
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
|
|
{ optionId: "r1", name: "Reject once", kind: "reject_once" as const },
|
|
];
|
|
|
|
function mount(opts: {
|
|
sockets: FakeSocket[];
|
|
shouldReconnect?: (id: string, code: number) => boolean;
|
|
store?: CatalogStore;
|
|
}) {
|
|
let i = 0;
|
|
const factory: StructuredSocketFactory = () => opts.sockets[i++] as unknown as WebSocket;
|
|
const conn = createStructuredConnection(
|
|
"s1",
|
|
{ shouldReconnect: opts.shouldReconnect },
|
|
{ name: "s1", command: "claude", view: "Structured" },
|
|
opts.store,
|
|
factory,
|
|
);
|
|
document.body.appendChild(conn.el);
|
|
return conn;
|
|
}
|
|
|
|
describe("structured connection: data flow", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("renders every row from the initial snapshot", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 2,
|
|
rows: [
|
|
row("user:1", "user", { content: { type: "text", text: "hello" } }),
|
|
row("agent:1", "agent_text", { content: { type: "text", text: "hi there" } }),
|
|
],
|
|
},
|
|
});
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
expect(conn.el.textContent).toContain("hello");
|
|
expect(conn.el.textContent).toContain("hi there");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 3,
|
|
rows: [
|
|
row("user:1", "user", { content: { type: "text", text: "before plan" } }),
|
|
row("plan", "plan", { entries: [{ content: "ship it", status: "pending" }] }),
|
|
row("agent:1", "agent_text", { content: { type: "text", text: "after plan" } }),
|
|
],
|
|
},
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: {
|
|
...idleState,
|
|
plan: { entries: [{ content: "ship it", priority: "high", status: "pending" }] },
|
|
},
|
|
});
|
|
|
|
const transcript = conn.el.querySelector(".structured-rows")!;
|
|
expect(transcript.querySelectorAll(".st-row")).toHaveLength(2);
|
|
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
|
|
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");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 1,
|
|
rows: [row("user:1", "user", { content: { type: "text", text: "keep me" } })],
|
|
},
|
|
});
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "append",
|
|
row: row("plan", "plan", { entries: [{ content: "first", status: "pending" }] }),
|
|
},
|
|
state: {
|
|
...idleState,
|
|
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
|
|
},
|
|
});
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "append",
|
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "still here" } }),
|
|
},
|
|
state: {
|
|
...idleState,
|
|
plan: { entries: [{ content: "first", priority: "high", status: "pending" }] },
|
|
},
|
|
});
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "patch",
|
|
row: row("plan", "plan", { entries: [{ content: "updated", status: "completed" }] }),
|
|
},
|
|
state: {
|
|
...idleState,
|
|
plan: { entries: [{ content: "updated", priority: "high", status: "completed" }] },
|
|
},
|
|
});
|
|
|
|
const transcript = conn.el.querySelector(".structured-rows")!;
|
|
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
|
|
"user:1",
|
|
"agent:1",
|
|
]);
|
|
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated");
|
|
expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first");
|
|
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "remove", id: "plan" },
|
|
state: idleState,
|
|
});
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "patch",
|
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "patched neighbor" } }),
|
|
},
|
|
state: idleState,
|
|
});
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "remove", id: "user:1" },
|
|
state: idleState,
|
|
});
|
|
|
|
expect(conn.el.querySelector(".structured-rail .st-plan")).toBeNull();
|
|
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
|
|
"agent:1",
|
|
]);
|
|
expect(transcript.textContent).toContain("patched neighbor");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("appends and patches rows via projection_update without duplicating them", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
|
|
state: idleState,
|
|
});
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
|
|
state: idleState,
|
|
});
|
|
const rows = conn.el.querySelectorAll(".st-row-agent");
|
|
expect(rows).toHaveLength(1);
|
|
expect(conn.el.textContent).toContain("partial done");
|
|
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
|
conn.dispose();
|
|
});
|
|
|
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 1,
|
|
rows: [
|
|
row("tool:1", "tool_call", {
|
|
title: "Bash(pnpm test)",
|
|
status: "in_progress",
|
|
content: [{ type: "content", content: { type: "text", text: "starting" } }],
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
|
originalHead.click();
|
|
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
|
|
const clickListener = vi.fn();
|
|
window.addEventListener("click", clickListener, { capture: true, once: true });
|
|
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "patch",
|
|
row: row("tool:1", "tool_call", {
|
|
title: "Bash(pnpm test)",
|
|
status: "in_progress",
|
|
content: [{ type: "content", content: { type: "text", text: "still running" } }],
|
|
}),
|
|
},
|
|
state: idleState,
|
|
});
|
|
|
|
expect(clickListener).not.toHaveBeenCalled();
|
|
window.removeEventListener("click", clickListener, true);
|
|
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
|
expect(patchedHead).not.toBe(originalHead);
|
|
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
|
|
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
|
|
expect(conn.el.textContent).toContain("still running");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("restores focus to a patched tool header with the same stable row id", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 1,
|
|
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
|
},
|
|
});
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
|
originalHead.focus();
|
|
expect(document.activeElement).toBe(originalHead);
|
|
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "patch",
|
|
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
|
},
|
|
state: idleState,
|
|
});
|
|
|
|
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
|
expect(patchedHead).not.toBe(originalHead);
|
|
expect(document.activeElement).toBe(patchedHead);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not move unrelated focus when a tool row is patched", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 1,
|
|
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
|
},
|
|
});
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
|
input.focus();
|
|
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "patch",
|
|
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
|
},
|
|
state: idleState,
|
|
});
|
|
|
|
expect(document.activeElement).toBe(input);
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: turn/composer gating", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("disables the composer while a turn is active and re-enables when idle", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
expect(input.disabled).toBe(true);
|
|
expect(cancel.hidden).toBe(false);
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(false);
|
|
expect(cancel.hidden).toBe(true);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "do the thing";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing");
|
|
expect(input.value).toBe("");
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
|
input.value = "retry me";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(input.value).toBe("retry me");
|
|
expect(conn.el.textContent).toContain("boom");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
const send = conn.el.querySelector(".structured-send") as HTMLButtonElement;
|
|
const form = conn.el.querySelector(".structured-composer") as HTMLFormElement;
|
|
input.value = "only once";
|
|
|
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
|
|
|
expect(input.disabled).toBe(true);
|
|
expect(send.disabled).toBe(true);
|
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not overwrite a newer operator draft when the matching request fails", async () => {
|
|
let reject!: (error: unknown) => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((_, rejectPromise) => { reject = rejectPromise; }),
|
|
);
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "first prompt";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
input.value = "newer draft";
|
|
|
|
reject(new Error("late failure"));
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(input.value).toBe("newer draft");
|
|
expect(conn.el.textContent).toContain("late failure");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("ignores an older failure after a newer submission generation starts", async () => {
|
|
let rejectFirst!: (error: unknown) => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((_, rejectPromise) => { rejectFirst = rejectPromise; }),
|
|
);
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "first prompt";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
input.value = "second prompt";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
rejectFirst(new Error("stale failure"));
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
|
expect(input.value).toBe("");
|
|
expect(input.disabled).toBe(true);
|
|
expect(conn.el.textContent).not.toContain("stale failure");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("ignores a submission failure after disposal", async () => {
|
|
let reject!: (error: unknown) => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((_, rejectPromise) => { reject = rejectPromise; }),
|
|
);
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "do not restore";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
conn.dispose();
|
|
input.value = "detached draft";
|
|
reject(new Error("late failure"));
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(input.value).toBe("detached draft");
|
|
expect(conn.el.querySelector(".structured-error")).toBeNull();
|
|
});
|
|
|
|
it("cancels the active turn via the cancel button", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } });
|
|
(conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click();
|
|
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: approvals", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("shows decision buttons only for the currently pending approval, and answers with its nonce", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "append",
|
|
row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions }),
|
|
},
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
expect(buttons).toHaveLength(3);
|
|
buttons[1].click(); // "Allow for session"
|
|
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3);
|
|
// Resolved on another client: state clears with no transcript delta at all.
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
|
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not act on a stale click after the approval already moved on", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
// Approval is superseded before the click is handled (buttons already
|
|
// disabled by the click handler is not the path under test here — this
|
|
// simulates the row having already flipped to "answered" underneath it).
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
expect(resolveStructuredApproval).not.toHaveBeenCalled();
|
|
conn.dispose();
|
|
});
|
|
|
|
it("restores the buttons and surfaces an error when the POST rejects while still pending", async () => {
|
|
vi.mocked(resolveStructuredApproval).mockRejectedValueOnce(new Error("network down"));
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
buttons[0].click();
|
|
expect(buttons[0].disabled).toBe(true);
|
|
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
|
expect(buttons[0].disabled).toBe(false);
|
|
expect(buttons[1].disabled).toBe(false);
|
|
expect(conn.el.textContent).toContain("network down");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not resurrect buttons or show an error for a decision the approval already moved past", async () => {
|
|
let reject!: (e: unknown) => void;
|
|
vi.mocked(resolveStructuredApproval).mockImplementationOnce(
|
|
() => new Promise((_, r) => { reject = r; }),
|
|
);
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
buttons[0].click();
|
|
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once");
|
|
|
|
// Superseded by a new approval before the in-flight POST settles.
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "append", row: row("approval:2", "approval", { toolCall: {}, options: allApprovalOptions }) },
|
|
state: { ...idleState, pending_approval: { nonce: "n2", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
reject(new Error("too late"));
|
|
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
|
|
|
expect(conn.el.textContent).not.toContain("too late");
|
|
const newButtons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
expect(newButtons).toHaveLength(3);
|
|
expect(newButtons[0].disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: reconnection/error/loading", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => {
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
const state = () => (conn.el.querySelector(".attach-state") as HTMLElement).dataset.state;
|
|
expect(state()).toBe("connecting");
|
|
|
|
first.open();
|
|
expect(state()).toBe("open");
|
|
first.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] },
|
|
});
|
|
expect(conn.el.textContent).toContain("before");
|
|
|
|
first.drop(1006, "");
|
|
expect(state()).toBe("connecting"); // "reconnecting…"
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
second.open();
|
|
expect(state()).toBe("open");
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not mount plan rows from a reconnect snapshot", () => {
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 1,
|
|
rows: [row("agent:old", "agent_text", { content: { type: "text", text: "old" } })],
|
|
},
|
|
});
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 4,
|
|
rows: [
|
|
row("user:new", "user", { content: { type: "text", text: "new" } }),
|
|
row("plan", "plan", { entries: [{ content: "reconnected", status: "in_progress" }] }),
|
|
],
|
|
},
|
|
});
|
|
second.emit({
|
|
type: "reduced_state",
|
|
state: {
|
|
...idleState,
|
|
plan: { entries: [{ content: "reconnected", priority: "high", status: "in_progress" }] },
|
|
},
|
|
});
|
|
|
|
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");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("keeps surviving expanded rows open across a reconnect snapshot and leaves fresh rows collapsed", () => {
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] },
|
|
});
|
|
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 3,
|
|
rows: [toolRow("tool:1", "after"), diffRow("diff:1", "second"), toolRow("tool:2", "new")],
|
|
},
|
|
});
|
|
|
|
expect(
|
|
conn.el.querySelector('[data-row-id="tool:1"] .st-tool-head')?.getAttribute("aria-expanded"),
|
|
).toBe("true");
|
|
expect(
|
|
conn.el.querySelector('[data-row-id="diff:1"] .st-tool-head')?.getAttribute("aria-expanded"),
|
|
).toBe("true");
|
|
expect(
|
|
conn.el.querySelector('[data-row-id="tool:2"] .st-tool-head')?.getAttribute("aria-expanded"),
|
|
).toBe("false");
|
|
expect(conn.el.querySelector('[data-row-id="tool:1"] .st-tool-out')?.textContent).toContain("after");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("prunes expanded IDs removed or made non-expandable by a snapshot", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] },
|
|
});
|
|
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
|
|
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 3,
|
|
rows: [row("tool:plain", "agent_text", { content: { type: "text", text: "not a tool" } })],
|
|
},
|
|
});
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 5, rows: [toolRow("tool:gone", "returned"), toolRow("tool:plain", "returned")] },
|
|
});
|
|
|
|
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) {
|
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
|
}
|
|
conn.dispose();
|
|
});
|
|
|
|
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "survive reconnect";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(true);
|
|
|
|
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("releases an accepted submission from a post-acceptance idle snapshot", async () => {
|
|
vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined);
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "finishes while disconnected";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
|
|
expect(input.disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not release from reconnect idle cached before submission acceptance", async () => {
|
|
let accept!: () => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((resolve) => { accept = resolve; }),
|
|
);
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const third = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second, third], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "accepted after reconnect";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(true);
|
|
|
|
accept();
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(input.disabled).toBe(true);
|
|
expect(second.closed).toBe(true);
|
|
|
|
third.open();
|
|
third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
third.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("cancels pending backoff when acceptance forces reconciliation", async () => {
|
|
let accept!: () => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((resolve) => { accept = resolve; }),
|
|
);
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const reconciliation = new FakeSocket();
|
|
const duplicate = new FakeSocket();
|
|
const conn = mount({
|
|
sockets: [first, second, reconciliation, duplicate],
|
|
shouldReconnect: () => true,
|
|
});
|
|
first.open();
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "accepted during backoff";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.drop();
|
|
accept();
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(reconciliation.onopen).not.toBeNull();
|
|
vi.advanceTimersByTime(60_000);
|
|
expect(duplicate.onopen).toBeNull();
|
|
|
|
reconciliation.open();
|
|
reconciliation.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(false);
|
|
conn.dispose();
|
|
expect(reconciliation.closed).toBe(true);
|
|
});
|
|
|
|
it("does not reconcile when submission acceptance arrives after disposal", async () => {
|
|
let accept!: () => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((resolve) => { accept = resolve; }),
|
|
);
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const late = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second, late], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "accepted after disposal";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
conn.dispose();
|
|
accept();
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
vi.advanceTimersByTime(60_000);
|
|
|
|
expect(second.closed).toBe(true);
|
|
expect(late.onopen).toBeNull();
|
|
});
|
|
|
|
it("reports a daemon rejection reason and does not schedule a retry", () => {
|
|
const only = new FakeSocket();
|
|
const conn = mount({ sockets: [only], shouldReconnect: () => true });
|
|
only.open();
|
|
only.drop(4004, "session is not a live structured session");
|
|
const stateEl = conn.el.querySelector(".attach-state") as HTMLElement;
|
|
expect(stateEl.dataset.state).toBe("error");
|
|
expect(stateEl.textContent).toBe("session is not a live structured session");
|
|
vi.advanceTimersByTime(60_000);
|
|
// No second socket was ever asked for — only one factory call happened.
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: transcript scrolling", () => {
|
|
let frames: Map<number, FrameRequestCallback>;
|
|
let nextFrame: number;
|
|
let resizeCallback: ResizeObserverCallback;
|
|
|
|
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);
|
|
});
|
|
vi.stubGlobal("ResizeObserver", class {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
resizeCallback = callback;
|
|
}
|
|
observe(): void {}
|
|
unobserve(): void {}
|
|
disconnect(): void {}
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
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] });
|
|
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.each([
|
|
{ position: "pinned", top: 800, expected: 880, manuallyScrolled: false },
|
|
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
|
|
])("preserves the $position position when the composer grows", ({ 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"));
|
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
|
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
|
|
input.value = "one\ntwo\nthree";
|
|
input.dispatchEvent(new Event("input"));
|
|
values.height = 1080;
|
|
runFrames();
|
|
|
|
expect(input.style.height).toBe("120px");
|
|
expect(values.top).toBe(expected);
|
|
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] });
|
|
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);
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("structured connection: pane lifecycle", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("sendInput is a no-op — there is no PTY behind a structured session", () => {
|
|
const conn = mount({ sockets: [new FakeSocket()] });
|
|
expect(() => conn.sendInput("\x0b")).not.toThrow();
|
|
conn.dispose();
|
|
});
|
|
|
|
it("carries the same off-canvas sessions button as the terminal attach header", () => {
|
|
const conn = mount({ sockets: [new FakeSocket()] });
|
|
const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement;
|
|
expect(btn).not.toBeNull();
|
|
expect(btn.getAttribute("aria-label")).toBe("Show sessions");
|
|
expect(btn.type).toBe("button");
|
|
|
|
let toggled = 0;
|
|
const onToggle = () => toggled++;
|
|
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
|
btn.click();
|
|
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
|
expect(toggled).toBe(1);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("focusTerminal focuses the composer only when it is enabled", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
conn.focusTerminal();
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).not.toBe(input);
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
conn.focusTerminal();
|
|
expect(document.activeElement).toBe(input);
|
|
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");
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws], store });
|
|
expect(unsubscribe).toHaveBeenCalled();
|
|
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", () => {
|
|
let frames: FrameRequestCallback[];
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
frames = [];
|
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
frames.push(cb);
|
|
return frames.length;
|
|
});
|
|
});
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
function runFrames(): void {
|
|
const pending = frames;
|
|
frames = [];
|
|
for (const cb of pending) cb(0);
|
|
}
|
|
|
|
it("does not focus the composer if the pane was parked before its deferred frame ran", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
conn.onMount({ focus: true });
|
|
conn.onUnmount(); // parked again before the frame below runs
|
|
runFrames();
|
|
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).not.toBe(input);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not focus the composer if it was re-mounted into a different pane before the stale frame ran", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
conn.onMount({ focus: true }); // stale frame #1
|
|
conn.onUnmount();
|
|
conn.onMount({ focus: false }); // the pane that actually won focus, without it
|
|
runFrames(); // both frames fire; only the latest should have any say
|
|
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).not.toBe(input);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("still focuses normally when nothing raced the frame", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
conn.onMount({ focus: true });
|
|
runFrames();
|
|
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).toBe(input);
|
|
conn.dispose();
|
|
});
|
|
});
|