feat: streamline structured transcript
This commit is contained in:
@@ -164,8 +164,14 @@ choices, not daemon ones.
|
||||
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
|
||||
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.
|
||||
from the transcript; the TUI may show the current plan in its status line,
|
||||
while the web client deliberately has no stats or plan rail.
|
||||
- The web client groups two or more tool rows after the same `User` row into
|
||||
one collapsed turn activity block. Opening it reveals independently
|
||||
expandable tool calls, including any raw command, input, output, and diff
|
||||
content the adapter supplied.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- 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`),
|
||||
|
||||
+34
-82
@@ -13,9 +13,9 @@ vi.mock("./api.js", async () => {
|
||||
|
||||
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
|
||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { CatalogStore } from "./events.js";
|
||||
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
||||
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js";
|
||||
import type { ReducedState, 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). */
|
||||
@@ -109,7 +109,7 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => {
|
||||
it("keeps state-only plan rows out of the transcript", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
@@ -138,7 +138,7 @@ describe("structured connection: data flow", () => {
|
||||
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");
|
||||
expect(conn.el.querySelector(".structured-rail")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -194,8 +194,7 @@ describe("structured connection: data flow", () => {
|
||||
"user:1",
|
||||
"agent:1",
|
||||
]);
|
||||
expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated");
|
||||
expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first");
|
||||
expect(transcript.textContent).not.toContain("updated");
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
@@ -216,7 +215,7 @@ describe("structured connection: data flow", () => {
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
expect(conn.el.querySelector(".structured-rail .st-plan")).toBeNull();
|
||||
expect(conn.el.querySelector(".structured-rail")).toBeNull();
|
||||
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
|
||||
"agent:1",
|
||||
]);
|
||||
@@ -247,6 +246,31 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("groups multiple tools in one user turn but keeps separate turns separate", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 6,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "first" } }),
|
||||
toolRow("tool:1", "one"),
|
||||
toolRow("tool:2", "two"),
|
||||
row("user:2", "user", { content: { type: "text", text: "second" } }),
|
||||
toolRow("tool:3", "three"),
|
||||
],
|
||||
},
|
||||
});
|
||||
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
|
||||
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.hidden).toBe(true);
|
||||
expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -703,7 +727,7 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
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");
|
||||
expect(conn.el.querySelector(".structured-rail")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -935,7 +959,6 @@ 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();
|
||||
@@ -950,14 +973,6 @@ describe("structured connection: transcript scrolling", () => {
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||
frames.delete(id);
|
||||
});
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -986,13 +1001,6 @@ describe("structured connection: transcript scrolling", () => {
|
||||
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] });
|
||||
@@ -1075,30 +1083,6 @@ describe("structured connection: transcript scrolling", () => {
|
||||
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] });
|
||||
@@ -1211,14 +1195,11 @@ describe("structured connection: transcript scrolling", () => {
|
||||
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);
|
||||
});
|
||||
@@ -1276,41 +1257,12 @@ describe("structured connection: pane lifecycle", () => {
|
||||
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");
|
||||
it("dispose closes the socket", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws], store });
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
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", () => {
|
||||
|
||||
+75
-86
@@ -1,4 +1,4 @@
|
||||
// A single live structured session: a transcript + rail bound to the
|
||||
// A single live structured session: a transcript bound to the
|
||||
// daemon's `WS /api/structured/:id` (#140). Mirrors attach.ts's contract —
|
||||
// decoupled from the UI, streaming whether or not mounted, torn down only by
|
||||
// `dispose()` — but the wire is JSON rows, not PTY bytes, and every connect
|
||||
@@ -13,16 +13,13 @@ import {
|
||||
structuredWsUrl,
|
||||
} from "./api.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import type { CatalogState, CatalogStore } from "./events.js";
|
||||
import type { CatalogStore } from "./events.js";
|
||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { buildTranscriptRow, renderRail } from "./structuredRender.js";
|
||||
import { buildToolGroup, buildTranscriptRow } from "./structuredRender.js";
|
||||
import type {
|
||||
AcpPlan,
|
||||
AcpUsage,
|
||||
ReducedState,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredEvent,
|
||||
TranscriptDelta,
|
||||
@@ -51,7 +48,7 @@ export function createStructuredConnection(
|
||||
sessionId: Uuid,
|
||||
opts: StructuredOptions = {},
|
||||
info?: AttachInfo,
|
||||
store?: CatalogStore,
|
||||
_store?: CatalogStore,
|
||||
socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)),
|
||||
): PaneConnection {
|
||||
const el = document.createElement("div");
|
||||
@@ -75,7 +72,6 @@ export function createStructuredConnection(
|
||||
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
|
||||
</form>
|
||||
</div>
|
||||
<aside class="structured-rail"></aside>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -84,8 +80,6 @@ export function createStructuredConnection(
|
||||
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;
|
||||
const composerForm = el.querySelector(".structured-composer") as HTMLFormElement;
|
||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
@@ -110,9 +104,10 @@ export function createStructuredConnection(
|
||||
const rowIndex = new Map<string, number>();
|
||||
const rowEls = new Map<string, HTMLElement>();
|
||||
const expandedRowIds = new Set<string>();
|
||||
const expandedGroupIds = new Set<string>();
|
||||
const groupEls = new Map<string, HTMLElement>();
|
||||
let state: ReducedState = { turn: idleTurn() };
|
||||
let pendingApprovalRowId: string | null = null;
|
||||
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||
let submissionGeneration = 0;
|
||||
let awaitingBeginGeneration: number | null = null;
|
||||
let acceptedSubmission: { generation: number; socketGeneration: number } | null = null;
|
||||
@@ -173,7 +168,9 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function onTranscriptClick(event: Event): void {
|
||||
if ((event.target as Element | null)?.closest(".st-tool-head")) beginTranscriptLayout();
|
||||
if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) {
|
||||
beginTranscriptLayout();
|
||||
}
|
||||
}
|
||||
|
||||
scrollEl.addEventListener("scroll", onTranscriptScroll);
|
||||
@@ -232,41 +229,74 @@ export function createStructuredConnection(
|
||||
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;
|
||||
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
|
||||
const groups = new Map<string, TranscriptRow[]>();
|
||||
let turnId = "before-first-message";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||
const id = `turn:${turnId}`;
|
||||
const group = groups.get(id) ?? [];
|
||||
group.push(row);
|
||||
groups.set(id, group);
|
||||
}
|
||||
rowEls.set(row.id, el);
|
||||
rowsEl.insertBefore(el, next);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function mountRow(row: TranscriptRow): void {
|
||||
const el = buildRowElement(row);
|
||||
if (el) insertRowElement(row, el);
|
||||
}
|
||||
function rebuildTranscript(): void {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const focusedRowId = active?.closest<HTMLElement>("[data-row-id]")?.dataset.rowId;
|
||||
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
||||
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
||||
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
||||
const groups = toolGroupsByTurn();
|
||||
const groupedRowIds = new Set<string>();
|
||||
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
|
||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||
|
||||
function remountRow(row: TranscriptRow): void {
|
||||
const old = rowEls.get(row.id);
|
||||
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||
const restoreFocus = document.activeElement === oldToolHead;
|
||||
const el = buildRowElement(row);
|
||||
if (!el) {
|
||||
old?.remove();
|
||||
rowEls.delete(row.id);
|
||||
expandedRowIds.delete(row.id);
|
||||
return;
|
||||
rowEls.clear();
|
||||
groupEls.clear();
|
||||
const children: HTMLElement[] = [];
|
||||
let turnId = "before-first-message";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
const groupId = `turn:${turnId}`;
|
||||
const groupRows = groups.get(groupId) ?? [];
|
||||
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
|
||||
if (groupedRowIds.has(row.id)) continue;
|
||||
const toolEls = groupRows.flatMap((toolRow) => {
|
||||
groupedRowIds.add(toolRow.id);
|
||||
const toolEl = buildRowElement(toolRow);
|
||||
if (!toolEl) return [];
|
||||
rowEls.set(toolRow.id, toolEl);
|
||||
return [toolEl];
|
||||
});
|
||||
const groupEl = buildToolGroup(
|
||||
groupId,
|
||||
groupRows,
|
||||
toolEls,
|
||||
expandedGroupIds.has(groupId),
|
||||
(expanded) => {
|
||||
if (expanded) expandedGroupIds.add(groupId);
|
||||
else expandedGroupIds.delete(groupId);
|
||||
finishTranscriptLayout();
|
||||
},
|
||||
);
|
||||
groupEls.set(groupId, groupEl);
|
||||
children.push(groupEl);
|
||||
continue;
|
||||
}
|
||||
const rowEl = buildRowElement(row);
|
||||
if (!rowEl) continue;
|
||||
rowEls.set(row.id, rowEl);
|
||||
children.push(rowEl);
|
||||
}
|
||||
if (old) {
|
||||
rowEls.set(row.id, el);
|
||||
old.replaceWith(el);
|
||||
} else {
|
||||
insertRowElement(row, el);
|
||||
rowsEl.replaceChildren(...children);
|
||||
if (restoreToolFocus && focusedRowId) {
|
||||
rowEls.get(focusedRowId)?.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
|
||||
} else if (restoreGroupFocus && focusedGroupId) {
|
||||
groupEls.get(focusedGroupId)?.querySelector<HTMLButtonElement>(".st-tool-group-head")?.focus();
|
||||
}
|
||||
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
||||
if (restoreFocus) newToolHead?.focus();
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||
@@ -279,9 +309,7 @@ export function createStructuredConnection(
|
||||
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);
|
||||
rebuildTranscript();
|
||||
}, forceBottom);
|
||||
receivedSnapshot = true;
|
||||
}
|
||||
@@ -292,16 +320,13 @@ export function createStructuredConnection(
|
||||
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 {
|
||||
const i = rowIndex.get(delta.id);
|
||||
@@ -310,16 +335,11 @@ export function createStructuredConnection(
|
||||
expandedRowIds.delete(delta.id);
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||
rowEls.get(delta.id)?.remove();
|
||||
rowEls.delete(delta.id);
|
||||
}
|
||||
rebuildTranscript();
|
||||
});
|
||||
}
|
||||
|
||||
function toolRowCount(): number {
|
||||
return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function renderPills(): void {
|
||||
const parts: string[] = [];
|
||||
if (state.model) parts.push(state.model);
|
||||
@@ -327,16 +347,6 @@ export function createStructuredConnection(
|
||||
pillsEl.textContent = parts.join(" · ");
|
||||
}
|
||||
|
||||
function renderRailNow(): void {
|
||||
renderRail(railEl, {
|
||||
plan: (state.plan ?? null) as AcpPlan | null,
|
||||
turn: state.turn,
|
||||
toolRowCount: toolRowCount(),
|
||||
usage: (state.usage ?? null) as AcpUsage | null,
|
||||
session: latestSession,
|
||||
});
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
input.disabled = active;
|
||||
@@ -365,12 +375,10 @@ export function createStructuredConnection(
|
||||
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)!]);
|
||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
||||
});
|
||||
}
|
||||
renderPills();
|
||||
renderRailNow();
|
||||
renderComposerState();
|
||||
}
|
||||
|
||||
@@ -447,24 +455,6 @@ 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;
|
||||
const narrow = width < NARROW_BREAKPOINT;
|
||||
if (bodyEl.classList.contains("st-narrow") === narrow) return;
|
||||
updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow));
|
||||
});
|
||||
resizeObserver.observe(bodyEl);
|
||||
|
||||
// ---- live worktree/git-status rail data ----
|
||||
const unsubscribeStore = store?.subscribe((catalog: CatalogState) => {
|
||||
if (disposed) return;
|
||||
latestSession = catalog.sessions.get(sessionId);
|
||||
renderRailNow();
|
||||
});
|
||||
|
||||
// ---- socket lifecycle ----
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
@@ -618,10 +608,9 @@ export function createStructuredConnection(
|
||||
cancelScrollFrame();
|
||||
cancelFocusFrame();
|
||||
cancelReconnect();
|
||||
resizeObserver.disconnect();
|
||||
unsubscribeStore?.();
|
||||
closeCurrentSocket();
|
||||
expandedRowIds.clear();
|
||||
expandedGroupIds.clear();
|
||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||
el.remove();
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
branchFromWorktreePath,
|
||||
buildToolGroup,
|
||||
buildTranscriptRow,
|
||||
renderInlineMarkdown,
|
||||
renderRail,
|
||||
} from "./structuredRender.js";
|
||||
import type { Session, TranscriptRow } from "./types.js";
|
||||
import type { TranscriptRow } from "./types.js";
|
||||
|
||||
function row<K extends TranscriptRow["kind"]>(
|
||||
kind: K,
|
||||
@@ -55,6 +54,22 @@ describe("buildTranscriptRow: user/agent/thought", () => {
|
||||
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
|
||||
expect(el.textContent).toContain(long);
|
||||
});
|
||||
|
||||
it("copies message blocks on click and supports keyboard activation", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||
const el = buildTranscriptRow(
|
||||
row("agent_text", { content: { type: "text", text: "copy **this** exactly" } }),
|
||||
);
|
||||
const body = el.querySelector<HTMLElement>(".st-body")!;
|
||||
expect(body.getAttribute("role")).toBe("button");
|
||||
body.click();
|
||||
await Promise.resolve();
|
||||
expect(writeText).toHaveBeenCalledWith("copy **this** exactly");
|
||||
body.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await Promise.resolve();
|
||||
expect(writeText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderInlineMarkdown", () => {
|
||||
@@ -128,6 +143,44 @@ describe("buildTranscriptRow: tool calls", () => {
|
||||
head.click();
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows raw command, input, and output as separately copyable details", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
title: "Bash(test)",
|
||||
rawInput: { command: "printf ok", timeout_ms: 1000 },
|
||||
rawOutput: { stdout: "ok", exit_code: 0 },
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
const labels = [...el.querySelectorAll(".st-tool-detail-label")].map((node) => node.textContent);
|
||||
expect(labels).toEqual(["Command", "Input", "Output"]);
|
||||
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
|
||||
expect(el.querySelectorAll(".st-tool-detail pre[role=button]")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildToolGroup", () => {
|
||||
it("starts collapsed, then reveals independently expandable tool calls", () => {
|
||||
const rows = [
|
||||
row("tool_call", { title: "Read(a.ts)", content: [{ type: "content", content: { type: "text", text: "a" } }] }, "t1"),
|
||||
row("tool_call", { title: "Bash(test)", content: [{ type: "content", content: { type: "text", text: "ok" } }] }, "t2"),
|
||||
];
|
||||
const children = rows.map((tool) => buildTranscriptRow(tool));
|
||||
const onExpanded = vi.fn();
|
||||
const group = buildToolGroup("turn:u1", rows, children, false, onExpanded);
|
||||
const head = group.querySelector<HTMLButtonElement>(".st-tool-group-head")!;
|
||||
const body = group.querySelector<HTMLElement>(".st-tool-group-body")!;
|
||||
expect(head.textContent).toContain("2 tool calls");
|
||||
expect(body.hidden).toBe(true);
|
||||
head.click();
|
||||
expect(body.hidden).toBe(false);
|
||||
expect(onExpanded).toHaveBeenCalledWith(true);
|
||||
const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head");
|
||||
toolHeads[1].click();
|
||||
expect(toolHeads[0].getAttribute("aria-expanded")).toBe("false");
|
||||
expect(toolHeads[1].getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: tool diffs", () => {
|
||||
@@ -159,7 +212,7 @@ describe("buildTranscriptRow: tool diffs", () => {
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
|
||||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||||
expect(el.querySelector(".st-diff")!.classList).toContain("st-diff");
|
||||
});
|
||||
|
||||
it("renders a visible marker for a removed final newline", () => {
|
||||
@@ -291,100 +344,3 @@ describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||
expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("branchFromWorktreePath", () => {
|
||||
it("takes everything after the last /worktrees/ segment", () => {
|
||||
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge");
|
||||
});
|
||||
|
||||
it("keeps slashes in a branch name that contains them", () => {
|
||||
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
|
||||
});
|
||||
|
||||
it("falls back to the basename when there is no /worktrees/ marker", () => {
|
||||
expect(branchFromWorktreePath("/some/other/path")).toBe("path");
|
||||
});
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/repo",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderRail", () => {
|
||||
it("omits every section that has nothing to show", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: null,
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
toolRowCount: 0,
|
||||
usage: null,
|
||||
session: session(),
|
||||
});
|
||||
// Turn always renders (it has a state and a count); the rest are absent.
|
||||
expect(el.querySelectorAll("h3")).toHaveLength(1);
|
||||
expect(el.querySelector("h3")!.textContent).toBe("Turn");
|
||||
});
|
||||
|
||||
it("renders the plan with status marks", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: {
|
||||
entries: [
|
||||
{ content: "done thing", priority: "high", status: "completed" },
|
||||
{ content: "now thing", priority: "high", status: "in_progress" },
|
||||
{ content: "later thing", priority: "low", status: "pending" },
|
||||
],
|
||||
},
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
toolRowCount: 0,
|
||||
usage: null,
|
||||
session: session(),
|
||||
});
|
||||
const items = el.querySelectorAll(".st-plan li");
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].className).toBe("st-plan-done");
|
||||
expect(items[1].className).toBe("st-plan-now");
|
||||
expect(items[2].className).toBe("st-plan-todo");
|
||||
});
|
||||
|
||||
it("renders context usage and a meter proportional to used/size", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: null,
|
||||
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
|
||||
toolRowCount: 3,
|
||||
usage: { used: 50_000, size: 200_000 },
|
||||
session: session(),
|
||||
});
|
||||
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
|
||||
});
|
||||
|
||||
it("renders the worktree section only when the session has one", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: null,
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
toolRowCount: 0,
|
||||
usage: null,
|
||||
session: session({
|
||||
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
|
||||
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
|
||||
}),
|
||||
});
|
||||
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
|
||||
expect(headers).toContain("Worktree");
|
||||
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
|
||||
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
|
||||
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
|
||||
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
|
||||
});
|
||||
});
|
||||
|
||||
+139
-138
@@ -8,12 +8,8 @@ import { computeDiffLines, diffStats } from "./structuredDiff.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpPlan,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
AcpUsage,
|
||||
InTheWay,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow,
|
||||
} from "./types.js";
|
||||
@@ -121,6 +117,54 @@ function renderProse(container: HTMLElement, text: string): void {
|
||||
if (container.children.length === 0) container.textContent = "";
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand?.("copy") ?? false;
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error("clipboard unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function makeCopyable(node: HTMLElement, text: string, label: string): void {
|
||||
if (!text) return;
|
||||
node.classList.add("st-copyable");
|
||||
node.tabIndex = 0;
|
||||
node.setAttribute("role", "button");
|
||||
node.setAttribute("aria-label", `Copy ${label}`);
|
||||
node.title = `Copy ${label}`;
|
||||
|
||||
const copy = () => {
|
||||
void writeClipboard(text).then(
|
||||
() => {
|
||||
node.dataset.copyState = "copied";
|
||||
node.setAttribute("aria-label", `${label} copied`);
|
||||
window.setTimeout(() => {
|
||||
delete node.dataset.copyState;
|
||||
node.setAttribute("aria-label", `Copy ${label}`);
|
||||
}, 1200);
|
||||
},
|
||||
() => {
|
||||
node.dataset.copyState = "error";
|
||||
},
|
||||
);
|
||||
};
|
||||
node.addEventListener("click", copy);
|
||||
node.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
copy();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// row builders
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -136,11 +180,14 @@ function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "though
|
||||
body.appendChild(who);
|
||||
const line = el("div", "st-user-text");
|
||||
line.textContent = text;
|
||||
makeCopyable(line, text, "message");
|
||||
body.appendChild(line);
|
||||
} else if (variant === "agent") {
|
||||
renderProse(body, text);
|
||||
makeCopyable(body, text, "response");
|
||||
} else {
|
||||
body.textContent = text;
|
||||
makeCopyable(body, text, "thought");
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
@@ -151,6 +198,7 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
|
||||
const label = el("div", "st-diff-path");
|
||||
label.textContent = diff.path;
|
||||
label.title = diff.path;
|
||||
makeCopyable(label, diff.path, "path");
|
||||
wrap.appendChild(label);
|
||||
const scroller = el("div", "st-diff");
|
||||
const pre = document.createElement("pre");
|
||||
@@ -162,10 +210,44 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
|
||||
pre.appendChild(document.createTextNode("\n"));
|
||||
}
|
||||
scroller.appendChild(pre);
|
||||
makeCopyable(scroller, pre.textContent ?? "", "diff");
|
||||
wrap.appendChild(scroller);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function printable(value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function toolInput(tool: AcpToolCallLike): { command: string | null; input: string | null } {
|
||||
const raw = tool.rawInput;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { command: null, input: printable(raw) };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const command = typeof record.command === "string" ? record.command : null;
|
||||
if (!command) return { command: null, input: printable(raw) };
|
||||
const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== "command"));
|
||||
return { command, input: Object.keys(rest).length > 0 ? printable(rest) : null };
|
||||
}
|
||||
|
||||
function appendToolDetail(container: HTMLElement, label: string, text: string): void {
|
||||
const detail = el("div", "st-tool-detail");
|
||||
const heading = el("div", "st-tool-detail-label");
|
||||
heading.textContent = label;
|
||||
const value = document.createElement("pre");
|
||||
value.textContent = text;
|
||||
makeCopyable(value, text, label.toLowerCase());
|
||||
detail.append(heading, value);
|
||||
container.appendChild(detail);
|
||||
}
|
||||
|
||||
/** `+added -removed` across every diff block in a tool call's content —
|
||||
* real derived data, never a fabricated duration. */
|
||||
function toolDiffMeta(content: AcpToolCallContent[]): string {
|
||||
@@ -234,13 +316,20 @@ function buildToolRow(
|
||||
const buildOut = () => {
|
||||
if (outBuilt) return;
|
||||
outBuilt = true;
|
||||
const { command, input } = toolInput(tool);
|
||||
if (command) appendToolDetail(out, "Command", command);
|
||||
if (input !== null) appendToolDetail(out, "Input", input);
|
||||
const rawOutput = printable(tool.rawOutput);
|
||||
if (rawOutput !== null) appendToolDetail(out, "Output", rawOutput);
|
||||
const texts = content
|
||||
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
|
||||
.map((c) => contentBlockText(c.content))
|
||||
.filter(Boolean);
|
||||
if (texts.length > 0) {
|
||||
const contentText = texts.join("\n");
|
||||
if (contentText && contentText !== rawOutput) {
|
||||
const textPanel = el("div", "st-tool-text");
|
||||
textPanel.textContent = texts.join("\n");
|
||||
textPanel.textContent = contentText;
|
||||
makeCopyable(textPanel, contentText, "output");
|
||||
out.appendChild(textPanel);
|
||||
}
|
||||
for (const block of content) {
|
||||
@@ -270,10 +359,52 @@ function buildToolRow(
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function buildToolGroup(
|
||||
id: string,
|
||||
rows: TranscriptRow[],
|
||||
children: HTMLElement[],
|
||||
expanded: boolean,
|
||||
onExpandedChange?: (expanded: boolean) => void,
|
||||
): HTMLElement {
|
||||
const wrap = el("div", "st-tool-group");
|
||||
wrap.dataset.toolGroupId = id;
|
||||
const head = document.createElement("button");
|
||||
head.type = "button";
|
||||
head.className = "st-tool-group-head";
|
||||
head.setAttribute("aria-expanded", String(expanded));
|
||||
|
||||
const glyph = el("span", "st-tool-group-glyph");
|
||||
glyph.textContent = "›";
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
const title = el("span", "st-tool-group-title");
|
||||
title.textContent = `${rows.length} tool calls`;
|
||||
const names = el("span", "st-tool-group-names");
|
||||
names.textContent = rows
|
||||
.map((row) => {
|
||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||
return splitToolTitle(tool.title, tool.name, tool.kind).label;
|
||||
})
|
||||
.join(" · ");
|
||||
head.append(glyph, title, names);
|
||||
|
||||
const body = el("div", "st-tool-group-body");
|
||||
body.hidden = !expanded;
|
||||
body.append(...children);
|
||||
head.addEventListener("click", () => {
|
||||
expanded = !expanded;
|
||||
head.setAttribute("aria-expanded", String(expanded));
|
||||
body.hidden = !expanded;
|
||||
onExpandedChange?.(expanded);
|
||||
});
|
||||
wrap.append(head, body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildPlainRow(row: TranscriptRow): HTMLElement {
|
||||
const wrap = el("div", "st-row st-row-notice");
|
||||
const body = el("div", "st-body");
|
||||
body.textContent = `[${row.kind}]`;
|
||||
makeCopyable(body, body.textContent, "notice");
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
@@ -367,8 +498,8 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Build the DOM for one transcript row, or `null` for state-only plan rows.
|
||||
* `approval` is consulted only for an `approval` row; tool
|
||||
* expansion is supplied and reported explicitly. */
|
||||
export function buildTranscriptRow(
|
||||
row: TranscriptRow & { kind: "plan" },
|
||||
@@ -421,133 +552,3 @@ export function buildTranscriptRow(
|
||||
built.dataset.rowId = row.id;
|
||||
return built;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PLAN_MARK: Record<string, string> = { completed: "✓", in_progress: "▸", pending: "·" };
|
||||
const PLAN_CLASS: Record<string, string> = { completed: "done", in_progress: "now", pending: "todo" };
|
||||
|
||||
function renderPlanSection(plan: AcpPlan | null | undefined): HTMLElement | null {
|
||||
if (!plan || plan.entries.length === 0) return null;
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Plan";
|
||||
section.appendChild(h);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "st-plan";
|
||||
for (const entry of plan.entries) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `st-plan-${PLAN_CLASS[entry.status] ?? "todo"}`;
|
||||
li.setAttribute("aria-label", `${entry.status.replace("_", " ")}: ${entry.content}`);
|
||||
const mark = el("span", "st-plan-mark");
|
||||
mark.textContent = PLAN_MARK[entry.status] ?? "·";
|
||||
mark.setAttribute("aria-hidden", "true");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = entry.content;
|
||||
li.append(mark, text);
|
||||
list.appendChild(li);
|
||||
}
|
||||
section.appendChild(list);
|
||||
return section;
|
||||
}
|
||||
|
||||
function kv(label: string, value: string): HTMLElement {
|
||||
const row = el("div", "st-kv-row");
|
||||
const k = el("span", "st-kv-k");
|
||||
k.textContent = label;
|
||||
const v = el("span", "st-kv-v");
|
||||
v.textContent = value;
|
||||
row.append(k, v);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderTurnSection(
|
||||
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] },
|
||||
toolRowCount: number,
|
||||
usage: AcpUsage | null | undefined,
|
||||
): HTMLElement {
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Turn";
|
||||
section.appendChild(h);
|
||||
const box = el("div", "st-kv");
|
||||
const activity = turn.thinking ? "thinking" : turn.responding ? "responding" : "idle";
|
||||
box.appendChild(kv("state", activity));
|
||||
box.appendChild(kv("active tools", String(turn.active_tool_calls.length)));
|
||||
box.appendChild(kv("tool calls", String(toolRowCount)));
|
||||
if (usage?.cost) box.appendChild(kv("cost", `$${usage.cost.amount.toFixed(2)}`));
|
||||
section.appendChild(box);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderContextSection(usage: AcpUsage | null | undefined): HTMLElement | null {
|
||||
if (!usage) return null;
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Context";
|
||||
section.appendChild(h);
|
||||
const box = el("div", "st-kv");
|
||||
box.appendChild(kv("used", formatTokenCount(usage.used)));
|
||||
box.appendChild(kv("window", formatTokenCount(usage.size)));
|
||||
section.appendChild(box);
|
||||
const meter = el("div", "st-meter");
|
||||
const fill = el("i");
|
||||
const pct = usage.size > 0 ? Math.min(100, (usage.used / usage.size) * 100) : 0;
|
||||
fill.style.width = `${pct}%`;
|
||||
meter.appendChild(fill);
|
||||
section.appendChild(meter);
|
||||
return section;
|
||||
}
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
|
||||
/** `SessionWorktree.path` is `<repo>/worktrees/<branch>` (docs/WORKTREES.md);
|
||||
* the branch is not carried anywhere else on the wire, so derive it here. */
|
||||
export function branchFromWorktreePath(path: string): string {
|
||||
const marker = "/worktrees/";
|
||||
const i = path.indexOf(marker);
|
||||
if (i >= 0) return path.slice(i + marker.length);
|
||||
return path.split("/").filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
function renderWorktreeSection(session: Session | undefined): HTMLElement | null {
|
||||
const worktree = session?.worktree;
|
||||
if (!worktree) return null;
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Worktree";
|
||||
section.appendChild(h);
|
||||
const box = el("div", "st-kv");
|
||||
box.appendChild(kv("branch", branchFromWorktreePath(worktree.path)));
|
||||
const blocked: InTheWay | null | undefined = session?.blocked_work;
|
||||
box.appendChild(kv("ahead", String(blocked?.unpushed_commits ?? 0)));
|
||||
const dirty = (blocked?.uncommitted_files ?? 0) + (blocked?.untracked_files ?? 0);
|
||||
box.appendChild(kv("dirty", String(dirty)));
|
||||
section.appendChild(box);
|
||||
return section;
|
||||
}
|
||||
|
||||
export interface RailContext {
|
||||
plan: AcpPlan | null | undefined;
|
||||
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] };
|
||||
toolRowCount: number;
|
||||
usage: AcpUsage | null | undefined;
|
||||
session: Session | undefined;
|
||||
}
|
||||
|
||||
/** Rebuild the rail wholesale from the latest state — rails are small, so a
|
||||
* full rebuild per update is simpler and cheap enough that no incremental
|
||||
* patching is worth the complexity. */
|
||||
export function renderRail(container: HTMLElement, ctx: RailContext): void {
|
||||
const sections = [
|
||||
renderPlanSection(ctx.plan),
|
||||
renderTurnSection(ctx.turn, ctx.toolRowCount, ctx.usage),
|
||||
renderContextSection(ctx.usage),
|
||||
renderWorktreeSection(ctx.session),
|
||||
].filter((s): s is HTMLElement => s !== null);
|
||||
container.replaceChildren(...sections);
|
||||
}
|
||||
|
||||
+149
-122
@@ -2168,8 +2168,8 @@ input.leaf-rename:focus {
|
||||
/* ---------- structured view (#140) ----------
|
||||
`.attach.structured` reuses `.attach`'s flex column, `.attach-header` and
|
||||
`.attach-state` verbatim (attach.ts and structured.ts share paneTitle.ts's
|
||||
header markup) — only the body differs: a transcript column + rail grid
|
||||
instead of an xterm surface. Hierarchy is deliberate: the approval is the
|
||||
header markup) — only the body differs: a transcript and composer instead
|
||||
of an xterm surface. Hierarchy is deliberate: the approval is the
|
||||
only lifted, card-like element on the page; everything else is either a
|
||||
tinted block (user), plain text (agent), or a thin bordered row (tool). */
|
||||
|
||||
@@ -2184,24 +2184,7 @@ input.leaf-rename:focus {
|
||||
.structured-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 232px;
|
||||
}
|
||||
|
||||
.structured-body.st-narrow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.structured-body.st-narrow .structured-main {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.structured-body.st-narrow .structured-rail {
|
||||
grid-row: 1;
|
||||
border-left: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 40%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.structured-main {
|
||||
@@ -2209,6 +2192,7 @@ input.leaf-rename:focus {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.structured-scroll {
|
||||
@@ -2226,105 +2210,6 @@ input.leaf-rename:focus {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.structured-rail {
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
padding: var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.structured-rail h3 {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.st-plan {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-plan li {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.st-plan-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.st-plan-done {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.st-plan-now {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.st-plan-now .st-plan-mark,
|
||||
.st-plan-done .st-plan-mark {
|
||||
color: var(--live);
|
||||
}
|
||||
|
||||
.st-plan-todo {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-plan-todo .st-plan-mark {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
|
||||
.st-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.st-kv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-kv-k {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.st-kv-v {
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.st-meter {
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: var(--r-1);
|
||||
overflow: hidden;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-meter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--live);
|
||||
}
|
||||
|
||||
/* every transcript row carries its own layout; none of them are cards */
|
||||
.st-row {
|
||||
display: flex;
|
||||
@@ -2333,6 +2218,7 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
.st-body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -2387,6 +2273,53 @@ input.leaf-rename:focus {
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.st-copyable {
|
||||
position: relative;
|
||||
border-radius: var(--r-1);
|
||||
cursor: copy;
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-copyable::after {
|
||||
content: "copy";
|
||||
position: absolute;
|
||||
top: var(--sp-1);
|
||||
right: var(--sp-1);
|
||||
padding: 1px var(--sp-1);
|
||||
border-radius: var(--r-1);
|
||||
background: var(--surface-3);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
font-style: normal;
|
||||
line-height: 1.4;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-copyable:hover,
|
||||
.st-copyable:focus-visible {
|
||||
background: var(--surface-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.st-copyable:hover::after,
|
||||
.st-copyable:focus-visible::after,
|
||||
.st-copyable[data-copy-state="copied"]::after,
|
||||
.st-copyable[data-copy-state="error"]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.st-copyable[data-copy-state="copied"]::after {
|
||||
content: "copied";
|
||||
color: var(--live);
|
||||
}
|
||||
|
||||
.st-copyable[data-copy-state="error"]::after {
|
||||
content: "copy failed";
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* tool rows: bordered, compact, deliberately not lifted */
|
||||
.st-tool {
|
||||
border: 1px solid var(--border);
|
||||
@@ -2472,6 +2405,92 @@ input.leaf-rename:focus {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.st-tool-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-tool-detail-label {
|
||||
margin-bottom: var(--sp-1);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.st-tool-detail pre {
|
||||
margin: 0;
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--fs-12);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.st-tool-group {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
background: color-mix(in oklab, var(--surface-2) 55%, transparent);
|
||||
}
|
||||
|
||||
.st-tool-group-head {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-tool-group-head:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.st-tool-group-glyph {
|
||||
color: var(--faint);
|
||||
transition: transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.st-tool-group-head[aria-expanded="true"] .st-tool-group-glyph {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.st-tool-group-title {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.st-tool-group-names {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.st-tool-group-body {
|
||||
padding: 0 var(--sp-2) var(--sp-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-tool-group-body[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.st-diff-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2530,7 +2549,6 @@ input.leaf-rename:focus {
|
||||
operator owes a decision on */
|
||||
.st-approval {
|
||||
border: 1px solid var(--warn);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--r-2);
|
||||
background: color-mix(in oklab, var(--warn) 12%, var(--surface));
|
||||
box-shadow: var(--shadow-1);
|
||||
@@ -2634,7 +2652,7 @@ input.leaf-rename:focus {
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.structured-body.st-narrow .structured-composer {
|
||||
.structured-composer {
|
||||
padding-bottom: max(var(--sp-3), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@@ -2679,11 +2697,20 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.act {
|
||||
.act,
|
||||
.st-copyable,
|
||||
.st-copyable::after,
|
||||
.st-tool-group-glyph {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.st-copyable::after {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
|
||||
.modal-backdrop {
|
||||
|
||||
Reference in New Issue
Block a user