Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b429a840 | ||
|
|
12bd68a2af |
@@ -44,6 +44,22 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript
|
||||
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 = [
|
||||
@@ -535,6 +551,69 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
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();
|
||||
|
||||
+25
-3
@@ -106,6 +106,7 @@ export function createStructuredConnection(
|
||||
let rows: TranscriptRow[] = [];
|
||||
const rowIndex = new Map<string, number>();
|
||||
const rowEls = new Map<string, HTMLElement>();
|
||||
const expandedRowIds = new Set<string>();
|
||||
let state: ReducedState = { turn: idleTurn() };
|
||||
let pendingApprovalRowId: string | null = null;
|
||||
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||
@@ -151,8 +152,24 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
function buildRowElement(row: TranscriptRow): HTMLElement {
|
||||
const el = buildTranscriptRow(
|
||||
row,
|
||||
approvalStateFor(row),
|
||||
expandedRowIds.has(row.id),
|
||||
(expanded) => {
|
||||
if (expanded) expandedRowIds.add(row.id);
|
||||
else expandedRowIds.delete(row.id);
|
||||
},
|
||||
);
|
||||
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||
expandedRowIds.delete(row.id);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function mountRow(row: TranscriptRow): void {
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
||||
const el = buildRowElement(row);
|
||||
rowEls.set(row.id, el);
|
||||
rowsEl.appendChild(el);
|
||||
}
|
||||
@@ -160,9 +177,8 @@ export function createStructuredConnection(
|
||||
function remountRow(row: TranscriptRow): void {
|
||||
const old = rowEls.get(row.id);
|
||||
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||
const wasExpanded = oldToolHead?.getAttribute("aria-expanded") === "true";
|
||||
const restoreFocus = document.activeElement === oldToolHead;
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row), wasExpanded);
|
||||
const el = buildRowElement(row);
|
||||
rowEls.set(row.id, el);
|
||||
if (old) old.replaceWith(el);
|
||||
else rowsEl.appendChild(el);
|
||||
@@ -172,6 +188,10 @@ export function createStructuredConnection(
|
||||
|
||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||
rows = [...snapshot.rows];
|
||||
const expandableIds = new Set(
|
||||
rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id),
|
||||
);
|
||||
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||
rowEls.clear();
|
||||
@@ -199,6 +219,7 @@ export function createStructuredConnection(
|
||||
const i = rowIndex.get(delta.id);
|
||||
if (i === undefined) return;
|
||||
rows.splice(i, 1);
|
||||
expandedRowIds.delete(delta.id);
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||
rowEls.get(delta.id)?.remove();
|
||||
@@ -482,6 +503,7 @@ export function createStructuredConnection(
|
||||
resizeObserver.disconnect();
|
||||
unsubscribeStore?.();
|
||||
closeCurrentSocket();
|
||||
expandedRowIds.clear();
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -187,7 +187,11 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
|
||||
function buildToolRow(
|
||||
row: TranscriptRow,
|
||||
expanded: boolean,
|
||||
onExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement {
|
||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||
const content = tool.content ?? [];
|
||||
const hasDiff = content.some((c) => c.type === "diff");
|
||||
@@ -259,6 +263,7 @@ function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
|
||||
};
|
||||
head.addEventListener("click", () => {
|
||||
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
|
||||
});
|
||||
setExpanded(expanded);
|
||||
|
||||
@@ -363,11 +368,12 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
||||
}
|
||||
|
||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
||||
* `approval` row; pass it whenever the row might be that kind. */
|
||||
* `approval` row; tool expansion is supplied and reported explicitly. */
|
||||
export function buildTranscriptRow(
|
||||
row: TranscriptRow,
|
||||
approval?: ApprovalRowState,
|
||||
toolExpanded = false,
|
||||
onToolExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement {
|
||||
let built: HTMLElement;
|
||||
switch (row.kind) {
|
||||
@@ -382,7 +388,7 @@ export function buildTranscriptRow(
|
||||
break;
|
||||
case "tool_call":
|
||||
case "tool_diff":
|
||||
built = buildToolRow(row, toolExpanded);
|
||||
built = buildToolRow(row, toolExpanded, onToolExpandedChange);
|
||||
break;
|
||||
case "approval":
|
||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||
|
||||
Reference in New Issue
Block a user