Compare commits

...
Author SHA1 Message Date
Miguel Palhas 85feb433ae fix(web): follow composer and rail layout 2026-09-05 11:09:42 +01:00
Miguel Palhas a11933b7bf fix(web): keep structured transcript pinned 2026-09-05 11:00:15 +01:00
Miguel Palhas fa40eb7378 Merge issue #194: omit plan transcript rows 2026-09-05 10:49:28 +01:00
Miguel Palhas b680266990 docs(maestro): fix Plan row rendering claim in STRUCTURED-VIEW
Plan rows are omitted from the transcript in both clients and rendered
in a separate plan rail/status line, not as a transcript checklist.
2026-09-05 10:48:15 +01:00
Miguel Palhas ded1f050b2 fix(web): omit plan transcript rows 2026-09-05 10:41:00 +01:00
Miguel Palhas a58ebfed93 Merge issue #193: preserve expanded cards on reconnect 2026-09-05 10:32:48 +01:00
5 changed files with 652 additions and 52 deletions
+4 -2
View File
@@ -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`),
+448
View File
@@ -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();
@@ -776,6 +932,298 @@ describe("structured connection: reconnection/error/loading", () => {
});
});
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());
+161 -43
View File
@@ -80,6 +80,7 @@ export function createStructuredConnection(
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
const railEl = el.querySelector(".structured-rail") as HTMLElement;
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
@@ -117,6 +118,65 @@ export function createStructuredConnection(
let stateSocketGeneration = 0;
let disposed = false;
// Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
let followingBottom = true;
let receivedSnapshot = false;
let scrollWorkActive = true;
let parkedScrollTop: number | null = null;
let scrollFrame: number | null = null;
let pendingScroll: { top: number; follow: boolean } | null = null;
function cancelScrollFrame(): void {
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
scrollFrame = null;
pendingScroll = null;
}
function beginTranscriptLayout(forceBottom = false): void {
if (!scrollWorkActive || disposed) return;
if (pendingScroll) {
if (forceBottom) pendingScroll.follow = true;
return;
}
pendingScroll = {
top: scrollEl.scrollTop,
follow: forceBottom || followingBottom,
};
}
function finishTranscriptLayout(): void {
if (!scrollWorkActive || disposed || !pendingScroll || scrollFrame !== null) return;
scrollFrame = window.requestAnimationFrame(() => {
scrollFrame = null;
const pending = pendingScroll;
pendingScroll = null;
if (!pending || disposed || !scrollWorkActive) return;
scrollEl.scrollTop = pending.follow
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
: pending.top;
});
}
function updateTranscriptLayout(update: () => void, forceBottom = false): void {
beginTranscriptLayout(forceBottom);
update();
finishTranscriptLayout();
}
function onTranscriptScroll(): void {
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
cancelScrollFrame();
}
function onTranscriptClick(event: Event): void {
if ((event.target as Element | null)?.closest(".st-tool-head")) beginTranscriptLayout();
}
scrollEl.addEventListener("scroll", onTranscriptScroll);
scrollEl.addEventListener("click", onTranscriptClick, true);
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
return null;
@@ -152,7 +212,7 @@ export function createStructuredConnection(
}
}
function buildRowElement(row: TranscriptRow): HTMLElement {
function buildRowElement(row: TranscriptRow): HTMLElement | null {
const el = buildTranscriptRow(
row,
approvalStateFor(row),
@@ -160,18 +220,30 @@ export function createStructuredConnection(
(expanded) => {
if (expanded) expandedRowIds.add(row.id);
else expandedRowIds.delete(row.id);
finishTranscriptLayout();
},
);
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,52 +251,67 @@ 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();
}
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();
rowsEl.replaceChildren();
for (const row of rows) mountRow(row);
const forceBottom = !receivedSnapshot;
updateTranscriptLayout(() => {
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();
rowsEl.replaceChildren();
for (const row of rows) mountRow(row);
}, forceBottom);
receivedSnapshot = true;
}
function applyDelta(delta: TranscriptDelta | null | undefined): void {
if (!delta) return;
if (delta.type === "append") {
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
mountRow(delta.row);
} else if (delta.type === "patch") {
const i = rowIndex.get(delta.row.id);
if (i === undefined) {
updateTranscriptLayout(() => {
if (delta.type === "append") {
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
mountRow(delta.row);
} else if (delta.type === "patch") {
const i = rowIndex.get(delta.row.id);
if (i === undefined) {
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
mountRow(delta.row);
} else {
rows[i] = delta.row;
remountRow(delta.row);
}
} else {
rows[i] = delta.row;
remountRow(delta.row);
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();
rowEls.delete(delta.id);
}
} else {
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();
rowEls.delete(delta.id);
}
});
}
function toolRowCount(): number {
@@ -273,10 +360,12 @@ export function createStructuredConnection(
reconcileAwaitingBegin();
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
if (newPendingId !== pendingApprovalRowId) {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId;
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
updateTranscriptLayout(() => {
const prev = pendingApprovalRowId;
pendingApprovalRowId = newPendingId;
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
});
}
renderPills();
renderRailNow();
@@ -300,8 +389,10 @@ export function createStructuredConnection(
// ---- composer ----
function autoResize(): void {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
updateTranscriptLayout(() => {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
});
}
input.addEventListener("input", autoResize);
input.addEventListener("keydown", (e) => {
@@ -357,8 +448,11 @@ export function createStructuredConnection(
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
const NARROW_BREAKPOINT = 560;
const resizeObserver = new ResizeObserver((entries) => {
if (disposed) return;
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT);
const narrow = width < NARROW_BREAKPOINT;
if (bodyEl.classList.contains("st-narrow") === narrow) return;
updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow));
});
resizeObserver.observe(bodyEl);
@@ -473,6 +567,12 @@ export function createStructuredConnection(
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
// it is no longer the most recent one.
let mountToken = 0;
let focusFrame: number | null = null;
function cancelFocusFrame(): void {
if (focusFrame !== null) window.cancelAnimationFrame(focusFrame);
focusFrame = null;
}
return {
sessionId,
@@ -480,13 +580,26 @@ export function createStructuredConnection(
onMount(mountOpts?: { focus?: boolean }) {
const focusOnMount = mountOpts?.focus !== false;
const token = ++mountToken;
requestAnimationFrame(() => {
scrollWorkActive = true;
pendingScroll = {
top: parkedScrollTop ?? scrollEl.scrollTop,
follow: followingBottom,
};
parkedScrollTop = null;
finishTranscriptLayout();
cancelFocusFrame();
focusFrame = window.requestAnimationFrame(() => {
focusFrame = null;
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
if (focusOnMount && !input.disabled) input.focus();
});
},
onUnmount() {
mountToken++;
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
scrollWorkActive = false;
cancelScrollFrame();
cancelFocusFrame();
},
focusTerminal() {
if (!input.disabled) input.focus();
@@ -499,11 +612,16 @@ export function createStructuredConnection(
if (disposed) return;
disposed = true;
mountToken++;
scrollWorkActive = false;
cancelScrollFrame();
cancelFocusFrame();
cancelReconnect();
resizeObserver.disconnect();
unsubscribeStore?.();
closeCurrentSocket();
expandedRowIds.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true);
el.remove();
},
};
+15 -4
View File
@@ -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();
});
});
+24 -3
View File
@@ -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;