From 22d740a36bfd768ebdc61a3c100386913a2175f9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 6 Sep 2026 15:17:40 +0100 Subject: [PATCH] feat: streamline structured transcript --- docs/STRUCTURED-VIEW.md | 10 +- web/src/structured.test.ts | 116 ++++--------- web/src/structured.ts | 161 +++++++++--------- web/src/structuredRender.test.ts | 158 +++++++----------- web/src/structuredRender.ts | 277 ++++++++++++++++--------------- web/src/styles.css | 271 ++++++++++++++++-------------- 6 files changed, 462 insertions(+), 531 deletions(-) diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 2be8347..684df5a 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -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`), diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 42845e7..937d230 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -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(".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(".st-tool-group"); + expect(groups).toHaveLength(1); + expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls"); + expect(groups[0].querySelector(".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; 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(".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", () => { diff --git a/web/src/structured.ts b/web/src/structured.ts index 04c3ae1..d14cc67 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -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( - `; @@ -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(); const rowEls = new Map(); const expandedRowIds = new Set(); + const expandedGroupIds = new Set(); + const groupEls = new Map(); 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 { + const groups = new Map(); + 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("[data-row-id]")?.dataset.rowId; + const focusedGroupId = active?.closest("[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(); + 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(".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(".st-tool-head")?.focus(); + } else if (restoreGroupFocus && focusedGroupId) { + groupEls.get(focusedGroupId)?.querySelector(".st-tool-group-head")?.focus(); } - const newToolHead = el.querySelector(".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(); diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index 02a6ff0..93e09e8 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -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( 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(".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(".st-tool-group-head")!; + const body = group.querySelector(".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(".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 { - 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 - }); -}); diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index 326b6e9..640022c 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -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 { + 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; + 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 => 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 = { completed: "✓", in_progress: "▸", pending: "·" }; -const PLAN_CLASS: Record = { 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 `/worktrees/` (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); -} diff --git a/web/src/styles.css b/web/src/styles.css index 0315753..211c91a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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 {