Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 |
@@ -162,8 +162,10 @@ choices, not daemon ones.
|
||||
**A client decides:**
|
||||
|
||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||
diff view for `ToolDiff`, a tool card for `ToolCall`, a checklist for
|
||||
`Plan`, and a floor presentation for `Notice`.
|
||||
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
|
||||
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 the operator is asked to resolve an `Approval` row — the daemon only
|
||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||
|
||||
@@ -109,6 +109,121 @@ describe("structured connection: data flow", () => {
|
||||
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] });
|
||||
@@ -551,6 +666,47 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
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();
|
||||
|
||||
+26
-6
@@ -152,7 +152,7 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
function buildRowElement(row: TranscriptRow): HTMLElement {
|
||||
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||
const el = buildTranscriptRow(
|
||||
row,
|
||||
approvalStateFor(row),
|
||||
@@ -162,16 +162,27 @@ export function createStructuredConnection(
|
||||
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);
|
||||
rowsEl.insertBefore(el, next);
|
||||
}
|
||||
|
||||
function mountRow(row: TranscriptRow): void {
|
||||
const el = buildRowElement(row);
|
||||
rowEls.set(row.id, el);
|
||||
rowsEl.appendChild(el);
|
||||
if (el) insertRowElement(row, el);
|
||||
}
|
||||
|
||||
function remountRow(row: TranscriptRow): void {
|
||||
@@ -179,9 +190,18 @@ export function createStructuredConnection(
|
||||
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||
const restoreFocus = document.activeElement === oldToolHead;
|
||||
const el = buildRowElement(row);
|
||||
rowEls.set(row.id, el);
|
||||
if (old) old.replaceWith(el);
|
||||
else rowsEl.appendChild(el);
|
||||
if (!el) {
|
||||
old?.remove();
|
||||
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");
|
||||
if (restoreFocus) newToolHead?.focus();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
} from "./structuredRender.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 };
|
||||
}
|
||||
|
||||
@@ -275,9 +279,16 @@ describe("buildTranscriptRow: approval", () => {
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||
it("renders a plan or notice row without crashing", () => {
|
||||
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
|
||||
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
|
||||
it("renders notice and unknown row kinds safely", () => {
|
||||
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
|
||||
"[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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -367,14 +367,33 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
||||
* `approval` row; tool expansion is supplied and reported explicitly. */
|
||||
/** Build the DOM for one transcript row, or `null` for a plan shown only in
|
||||
* 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(
|
||||
row: TranscriptRow,
|
||||
approval?: ApprovalRowState,
|
||||
toolExpanded = false,
|
||||
onToolExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement {
|
||||
): HTMLElement | null {
|
||||
let built: HTMLElement;
|
||||
switch (row.kind) {
|
||||
case "user":
|
||||
@@ -393,6 +412,8 @@ export function buildTranscriptRow(
|
||||
case "approval":
|
||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||
break;
|
||||
case "plan":
|
||||
return null;
|
||||
default:
|
||||
built = buildPlainRow(row);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user