Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 | ||
|
|
31b429a840 | ||
|
|
12bd68a2af |
@@ -162,8 +162,10 @@ choices, not daemon ones.
|
|||||||
**A client decides:**
|
**A client decides:**
|
||||||
|
|
||||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||||
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for
|
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
||||||
`Plan`, and a floor presentation for `Notice`.
|
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||||
|
from the transcript entirely and render the current plan in a separate
|
||||||
|
plan rail (web) / status line (TUI) instead.
|
||||||
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
||||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript
|
|||||||
return { id, kind, data };
|
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 idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||||
|
|
||||||
const allApprovalOptions = [
|
const allApprovalOptions = [
|
||||||
@@ -93,6 +109,121 @@ describe("structured connection: data flow", () => {
|
|||||||
conn.dispose();
|
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", () => {
|
it("appends and patches rows via projection_update without duplicating them", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
@@ -535,6 +666,110 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
conn.dispose();
|
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", () => {
|
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
|
||||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||||
const first = new FakeSocket();
|
const first = new FakeSocket();
|
||||||
|
|||||||
+50
-8
@@ -106,6 +106,7 @@ export function createStructuredConnection(
|
|||||||
let rows: TranscriptRow[] = [];
|
let rows: TranscriptRow[] = [];
|
||||||
const rowIndex = new Map<string, number>();
|
const rowIndex = new Map<string, number>();
|
||||||
const rowEls = new Map<string, HTMLElement>();
|
const rowEls = new Map<string, HTMLElement>();
|
||||||
|
const expandedRowIds = new Set<string>();
|
||||||
let state: ReducedState = { turn: idleTurn() };
|
let state: ReducedState = { turn: idleTurn() };
|
||||||
let pendingApprovalRowId: string | null = null;
|
let pendingApprovalRowId: string | null = null;
|
||||||
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||||
@@ -151,27 +152,66 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountRow(row: TranscriptRow): void {
|
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
const el = buildTranscriptRow(
|
||||||
|
row,
|
||||||
|
approvalStateFor(row),
|
||||||
|
expandedRowIds.has(row.id),
|
||||||
|
(expanded) => {
|
||||||
|
if (expanded) expandedRowIds.add(row.id);
|
||||||
|
else expandedRowIds.delete(row.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!el) return null;
|
||||||
|
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||||
|
expandedRowIds.delete(row.id);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertRowElement(row: TranscriptRow, el: HTMLElement): void {
|
||||||
|
const index = rowIndex.get(row.id) ?? rows.length - 1;
|
||||||
|
let next: HTMLElement | null = null;
|
||||||
|
for (let i = index + 1; i < rows.length; i++) {
|
||||||
|
next = rowEls.get(rows[i].id) ?? null;
|
||||||
|
if (next) break;
|
||||||
|
}
|
||||||
rowEls.set(row.id, el);
|
rowEls.set(row.id, el);
|
||||||
rowsEl.appendChild(el);
|
rowsEl.insertBefore(el, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRow(row: TranscriptRow): void {
|
||||||
|
const el = buildRowElement(row);
|
||||||
|
if (el) insertRowElement(row, el);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remountRow(row: TranscriptRow): void {
|
function remountRow(row: TranscriptRow): void {
|
||||||
const old = rowEls.get(row.id);
|
const old = rowEls.get(row.id);
|
||||||
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||||
const wasExpanded = oldToolHead?.getAttribute("aria-expanded") === "true";
|
|
||||||
const restoreFocus = document.activeElement === oldToolHead;
|
const restoreFocus = document.activeElement === oldToolHead;
|
||||||
const el = buildTranscriptRow(row, approvalStateFor(row), wasExpanded);
|
const el = buildRowElement(row);
|
||||||
rowEls.set(row.id, el);
|
if (!el) {
|
||||||
if (old) old.replaceWith(el);
|
old?.remove();
|
||||||
else rowsEl.appendChild(el);
|
rowEls.delete(row.id);
|
||||||
|
expandedRowIds.delete(row.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (old) {
|
||||||
|
rowEls.set(row.id, el);
|
||||||
|
old.replaceWith(el);
|
||||||
|
} else {
|
||||||
|
insertRowElement(row, el);
|
||||||
|
}
|
||||||
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
||||||
if (restoreFocus) newToolHead?.focus();
|
if (restoreFocus) newToolHead?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||||
rows = [...snapshot.rows];
|
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();
|
rowIndex.clear();
|
||||||
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||||
rowEls.clear();
|
rowEls.clear();
|
||||||
@@ -199,6 +239,7 @@ export function createStructuredConnection(
|
|||||||
const i = rowIndex.get(delta.id);
|
const i = rowIndex.get(delta.id);
|
||||||
if (i === undefined) return;
|
if (i === undefined) return;
|
||||||
rows.splice(i, 1);
|
rows.splice(i, 1);
|
||||||
|
expandedRowIds.delete(delta.id);
|
||||||
rowIndex.clear();
|
rowIndex.clear();
|
||||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||||
rowEls.get(delta.id)?.remove();
|
rowEls.get(delta.id)?.remove();
|
||||||
@@ -482,6 +523,7 @@ export function createStructuredConnection(
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
unsubscribeStore?.();
|
unsubscribeStore?.();
|
||||||
closeCurrentSocket();
|
closeCurrentSocket();
|
||||||
|
expandedRowIds.clear();
|
||||||
el.remove();
|
el.remove();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
} from "./structuredRender.js";
|
} from "./structuredRender.js";
|
||||||
import type { Session, TranscriptRow } from "./types.js";
|
import type { Session, TranscriptRow } from "./types.js";
|
||||||
|
|
||||||
function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow {
|
function row<K extends TranscriptRow["kind"]>(
|
||||||
|
kind: K,
|
||||||
|
data: unknown,
|
||||||
|
id = "r1",
|
||||||
|
): TranscriptRow & { kind: K } {
|
||||||
return { id, kind, data };
|
return { id, kind, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +279,16 @@ describe("buildTranscriptRow: approval", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||||
it("renders a plan or notice row without crashing", () => {
|
it("renders notice and unknown row kinds safely", () => {
|
||||||
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
|
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
|
||||||
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
|
"[notice]",
|
||||||
|
);
|
||||||
|
const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow;
|
||||||
|
expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no transcript element for a plan row", () => {
|
||||||
|
expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,11 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
|
|||||||
return null;
|
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 tool = (row.data ?? {}) as AcpToolCallLike;
|
||||||
const content = tool.content ?? [];
|
const content = tool.content ?? [];
|
||||||
const hasDiff = content.some((c) => c.type === "diff");
|
const hasDiff = content.some((c) => c.type === "diff");
|
||||||
@@ -259,6 +263,7 @@ function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
|
|||||||
};
|
};
|
||||||
head.addEventListener("click", () => {
|
head.addEventListener("click", () => {
|
||||||
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||||
|
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
|
||||||
});
|
});
|
||||||
setExpanded(expanded);
|
setExpanded(expanded);
|
||||||
|
|
||||||
@@ -362,13 +367,33 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
/** Build the DOM for one transcript row, or `null` for a plan shown only in
|
||||||
* `approval` row; pass it whenever the row might be that kind. */
|
* the rail. `approval` is consulted only for an `approval` row; tool
|
||||||
|
* expansion is supplied and reported explicitly. */
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow & { kind: "plan" },
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded?: boolean,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): null;
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan"> },
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded?: boolean,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement;
|
||||||
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded?: boolean,
|
||||||
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement | null;
|
||||||
export function buildTranscriptRow(
|
export function buildTranscriptRow(
|
||||||
row: TranscriptRow,
|
row: TranscriptRow,
|
||||||
approval?: ApprovalRowState,
|
approval?: ApprovalRowState,
|
||||||
toolExpanded = false,
|
toolExpanded = false,
|
||||||
): HTMLElement {
|
onToolExpandedChange?: (expanded: boolean) => void,
|
||||||
|
): HTMLElement | null {
|
||||||
let built: HTMLElement;
|
let built: HTMLElement;
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
case "user":
|
case "user":
|
||||||
@@ -382,11 +407,13 @@ export function buildTranscriptRow(
|
|||||||
break;
|
break;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
case "tool_diff":
|
case "tool_diff":
|
||||||
built = buildToolRow(row, toolExpanded);
|
built = buildToolRow(row, toolExpanded, onToolExpandedChange);
|
||||||
break;
|
break;
|
||||||
case "approval":
|
case "approval":
|
||||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||||
break;
|
break;
|
||||||
|
case "plan":
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
built = buildPlainRow(row);
|
built = buildPlainRow(row);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user