// 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 // (fresh or reconnect) resends the *whole* transcript rather than resuming a // cursor (docs/STRUCTURED-VIEW.md), so a snapshot always replaces state // wholesale instead of being merged into what came before. import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt, uploadStructuredFile, setStructuredConfig, structuredWsUrl, } from "./api.js"; import { requestToggleDrawer } from "./drawer.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 { buildToolGroup, buildTranscriptRow, formatContextTokens, formatElapsed, } from "./structuredRender.js"; import type { AcpSessionConfigChoice, AcpSessionConfigOption, ReducedState, StructuredApprovalDecision, StructuredEvent, TranscriptDelta, TranscriptRow, TranscriptRowKind, Uuid, } from "./types.js"; export interface StructuredOptions { onRequestClose?: (sessionId: Uuid) => void; onClosed?: (sessionId: Uuid) => void; shouldReconnect?: (sessionId: Uuid, code: number) => boolean; } /** Injectable so tests can drive the socket without a daemon — the same seam * `CatalogStore` uses (events.ts). */ export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket; const idleTurn = (): ReducedState["turn"] => ({ responding: false, thinking: false, active_tool_calls: [], }); interface AvailableCommand { name: string; description: string; input?: { hint?: string }; } interface CommandToken { start: number; end: number; query: string; } export function createStructuredConnection( sessionId: Uuid, opts: StructuredOptions = {}, info?: AttachInfo, _store?: CatalogStore, socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)), ): PaneConnection { const el = document.createElement("div"); el.className = "attach structured"; el.innerHTML = `
${paneTitleHtml(sessionId, info)} connecting…
`; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const titleEl = el.querySelector(".attach-title") as HTMLSpanElement; const configEl = el.querySelector(".structured-config") as HTMLDivElement; const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement; const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement; const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement; const activityEl = el.querySelector(".structured-activity") as HTMLDivElement; const activityText = el.querySelector(".structured-activity-text") as HTMLSpanElement; const composerForm = el.querySelector(".structured-composer") as HTMLFormElement; const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement; const input = el.querySelector(".structured-input") as HTMLTextAreaElement; const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement; const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement; const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement; const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement; const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement; type Attachment = { file: File; path?: string }; type QueuedPrompt = { text: string; attachments: Attachment[] }; let attachments: Attachment[] = []; let uploading = false; let pasteCount = 0; function renderAttachments(): void { attachmentEl.replaceChildren(); attachmentEl.hidden = attachments.length === 0; for (const attachment of attachments) { const chip = document.createElement("span"); chip.className = "structured-attachment"; const label = document.createElement("span"); label.textContent = attachment.file.name; label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`; const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "×"; remove.setAttribute("aria-label", `Remove ${attachment.file.name}`); remove.disabled = uploading; remove.addEventListener("click", () => { attachments = attachments.filter((item) => item !== attachment); renderAttachments(); }); chip.append(label, remove); attachmentEl.append(chip); } } function addFiles(files: File[]): void { if (uploading || disposed) return; for (const file of files) { if (file.size > 20 * 1024 * 1024) { showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`); } else if (attachments.length >= 10) { showError("Attach up to 10 files per message."); } else { attachments.push({ file }); } } renderAttachments(); } attachBtn.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { addFiles(Array.from(fileInput.files ?? [])); fileInput.value = ""; }); composerForm.addEventListener("dragover", (event) => { if (event.dataTransfer?.types.includes("Files")) event.preventDefault(); }); composerForm.addEventListener("drop", (event) => { if (!event.dataTransfer?.files.length) return; event.preventDefault(); addFiles(Array.from(event.dataTransfer.files)); }); input.addEventListener("paste", (event) => { const clipboard = event.clipboardData; if (!clipboard || uploading) return; if (clipboard.files.length) { event.preventDefault(); addFiles(Array.from(clipboard.files)); return; } const text = clipboard.getData("text/plain"); if (text.length < 4000 && text.split("\n").length < 40) return; if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return; event.preventDefault(); addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]); input.setRangeText("", input.selectionStart, input.selectionEnd, "end"); autoResize(); }); disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId)); // Mobile-only (CSS hides it on desktop, where the sidebar is always in // view): the session list is an off-canvas drawer and this opens it — // same control attach.ts's terminal header carries. (el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () => requestToggleDrawer(), ); const setConnState = (text: string, kind: "connecting" | "open" | "closed" | "error") => { stateEl.textContent = text; stateEl.dataset.state = kind; }; // ---- transcript state: mirrors the daemon's own Projection shape (id -> // index, patched in place) so a Patch never needs to search the array. ---- let rows: TranscriptRow[] = []; const rowIndex = new Map(); const rowEls = new Map(); const expandedRowIds = new Set(); const expandedGroupIds = new Set(); const enteringRowIds = new Set(); const groupEls = new Map(); let state: ReducedState = { turn: idleTurn() }; let pendingApprovalRowId: string | null = null; let availableCommands: AvailableCommand[] = []; let configBusy = false; let filteredCommands: AvailableCommand[] = []; let activeCommandIndex = -1; let submissionGeneration = 0; let awaitingBeginGeneration: number | null = null; let acceptedSubmission: { generation: number; socketGeneration: number } | null = null; let socketGeneration = 0; let stateSocketGeneration = 0; let disposed = false; let optimisticTurnStartedAt: number | null = null; let activityTimer: number | null = null; const promptQueue: QueuedPrompt[] = []; let promptRequestPending = false; /** Server path → object URL for uploaded images, so user bubbles can show previews. */ const imageObjectUrls = new Map(); // Within 64px, wheel and touch imprecision should still count as following. const FOLLOW_BOTTOM_THRESHOLD_PX = 64; let followingBottom = true; let receivedSnapshot = false; let scrollWorkActive = true; let parkedScrollTop: number | null = null; let scrollFrame: number | null = null; let pendingScroll: { top: number; follow: boolean; smooth: boolean } | null = null; let smoothScrollTimer: number | null = null; let smoothFollowing = false; function reduceMotion(): boolean { return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; } function stopSmoothFollow(): void { if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer); smoothScrollTimer = null; smoothFollowing = false; scrollEl.style.removeProperty("scroll-behavior"); } function cancelScrollFrame(): void { if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame); scrollFrame = null; pendingScroll = null; } function beginTranscriptLayout(forceBottom = false, smooth = false): void { if (!scrollWorkActive || disposed) return; if (pendingScroll) { if (forceBottom) pendingScroll.follow = true; if (smooth) pendingScroll.smooth = true; return; } pendingScroll = { top: scrollEl.scrollTop, follow: forceBottom || followingBottom, smooth, }; } function finishTranscriptLayout(): void { if (!scrollWorkActive || disposed || !pendingScroll || scrollFrame !== null) return; scrollFrame = window.requestAnimationFrame(() => { scrollFrame = null; const pending = pendingScroll; pendingScroll = null; if (!pending || disposed || !scrollWorkActive) return; const target = pending.follow ? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight) : pending.top; if (pending.follow && pending.smooth && !reduceMotion()) { smoothFollowing = true; scrollEl.style.scrollBehavior = "smooth"; scrollEl.scrollTop = target; if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer); smoothScrollTimer = window.setTimeout(() => stopSmoothFollow(), 220); } else { stopSmoothFollow(); scrollEl.scrollTop = target; } }); } function updateTranscriptLayout(update: () => void, forceBottom = false, smooth = false): void { beginTranscriptLayout(forceBottom, smooth); update(); finishTranscriptLayout(); } function onTranscriptScroll(): void { if (smoothFollowing) return; const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop; followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX; cancelScrollFrame(); } function onTranscriptClick(event: Event): void { if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) { beginTranscriptLayout(false, true); } } function settleDisclosureLayout(): void { finishTranscriptLayout(); window.setTimeout(() => updateTranscriptLayout(() => {}, false, true), 190); } scrollEl.addEventListener("scroll", onTranscriptScroll); scrollEl.addEventListener("click", onTranscriptClick, true); scrollEl.addEventListener("wheel", stopSmoothFollow, { passive: true }); scrollEl.addEventListener("touchstart", stopSmoothFollow, { passive: true }); function lastRowIdOfKind(kind: TranscriptRowKind): string | null { for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; return null; } function approvalStateFor(row: TranscriptRow) { if (row.kind !== "approval") return undefined; return { pending: row.id === pendingApprovalRowId, onDecide: (decision: StructuredApprovalDecision) => decide(row.id, decision), }; } async function decide(rowId: string, decision: StructuredApprovalDecision): Promise { const nonce = state.pending_approval?.nonce; // Already resolved elsewhere between the click and here — the row's // buttons should already be gone, but a stale nonce is a no-op, not an // error (docs/STRUCTURED-VIEW.md: any nonce answers 204). Resolve // quietly: there is nothing for the row to restore. if (!nonce || rowId !== pendingApprovalRowId) return; try { await resolveStructuredApproval(sessionId, nonce, decision); } catch (e: unknown) { // Only propagate the failure — so the row re-enables its buttons — if // this is still the pending approval. If it moved on while the // request was in flight (superseded, cancelled, answered elsewhere), // the state-driven re-render already replaced the row; reviving // buttons on it would be reviving a decision that no longer applies. if (!disposed && rowId === pendingApprovalRowId) { showError(String(e)); throw e; } } } /** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */ const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g; function buildRowElement(row: TranscriptRow): HTMLElement | null { const el = buildTranscriptRow( row, approvalStateFor(row), expandedRowIds.has(row.id), (expanded) => { if (expanded) expandedRowIds.add(row.id); else expandedRowIds.delete(row.id); settleDisclosureLayout(); }, ); if (!el) return null; if (enteringRowIds.has(row.id)) el.classList.add("st-row-enter"); if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { expandedRowIds.delete(row.id); } // Inject image previews into user message bubbles and strip the path text. if (row.kind === "user" && imageObjectUrls.size > 0) { const data = row.data as { content?: { type: string; text?: string } } | undefined; const text = data?.content?.type === "text" ? data.content.text ?? "" : ""; const body = el.querySelector(".st-body"); if (body && text) { const images: string[] = []; attachedFileRe.lastIndex = 0; let match: RegExpExecArray | null; while ((match = attachedFileRe.exec(text)) !== null) { const serverPath = JSON.parse(`"${match[1]}"`); const objectUrl = imageObjectUrls.get(serverPath); if (objectUrl) images.push(objectUrl); } if (images.length > 0) { // Strip attachment reference lines from the displayed text. const userText = body.querySelector(".st-user-text"); if (userText) { const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim(); userText.textContent = cleaned; if (!cleaned) userText.remove(); } for (const src of images) { const img = document.createElement("img"); img.className = "st-user-image"; img.src = src; img.alt = "Uploaded image"; img.loading = "lazy"; body.appendChild(img); } } } } return el; } /** Group tool rows (and intermediate agent text / resolved approvals between * them) by turn. An agent_text/agent_thought row is "intermediate" when a * tool row follows it in the same turn — it gets absorbed into the group so * the collapsed block preserves chronological order. Resolved approval rows * (not the currently pending one) are absorbed the same way. The *last* * agent text with no subsequent tool stays standalone (the prominent final * answer). Returns groups keyed by `turn:`, each with `toolCount` * (for the header label) and `rows` (tools + intermediate text/approvals, * in order). */ function activityGroupsByTurn(): Map { // First: count tools per turn to know which turns qualify for grouping. const toolCounts = 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 gid = `turn:${turnId}`; toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1); } // Second: build groups with interleaved text for turns with 2+ tools. const groups = new Map(); turnId = "before-first-message"; // Pending rows that might be intermediate (not yet confirmed by a // following tool). If no tool follows, they stay standalone. let pending: TranscriptRow[] = []; let currentGid = `turn:${turnId}`; for (const row of rows) { if (row.kind === "user") { turnId = row.id; currentGid = `turn:${turnId}`; pending = []; continue; } const count = toolCounts.get(currentGid) ?? 0; if (count < 2) continue; const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought"; const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalRowId; if (isIntermediateText || isResolvedApproval) { pending.push(row); continue; } if (row.kind === "tool_call" || row.kind === "tool_diff") { const group = groups.get(currentGid) ?? { toolCount: count, rows: [] }; // Flush any pending rows — a tool follows, so they're intermediate. group.rows.push(...pending); pending = []; group.rows.push(row); groups.set(currentGid, group); } } // A final agent text is the answer, so it stays outside. Thought rows are // working commentary, though: when a turn already has a disclosure they // belong with its activity even if the next tool call has not arrived yet. // Leaving that tail loose made a busy agent look like it was publishing a // long italic answer above a collapsed "N tool calls" summary. const lastGroup = groups.get(currentGid); if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) { lastGroup.rows.push(...pending); } return groups; } 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 = activityGroupsByTurn(); const groupedRowIds = new Set(); const liveGroupIds = new Set([...groups.keys()]); for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id); const prevGroupEls = new Map(groupEls); 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 group = groups.get(groupId); if (group && groupedRowIds.has(row.id)) continue; if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) { // First ungrouped tool in this turn — render the whole activity group. const memberEls = group.rows.flatMap((memberRow) => { groupedRowIds.add(memberRow.id); const memberEl = buildRowElement(memberRow); if (!memberEl) return []; rowEls.set(memberRow.id, memberEl); return [memberEl]; }); // Reuse existing group element when possible — avoids tearing down // and recreating the wrapper, which restarts CSS transitions (visible // flash) and destroys keyboard/focus state. const existing = prevGroupEls.get(groupId); if (existing) { const titleEl = existing.querySelector(".st-tool-group-title"); if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`; const inner = existing.querySelector(".st-disclosure-inner"); if (inner) inner.replaceChildren(...memberEls); groupEls.set(groupId, existing); children.push(existing); } else { const groupEl = buildToolGroup( groupId, group.rows, memberEls, expandedGroupIds.has(groupId), (expanded) => { if (expanded) expandedGroupIds.add(groupId); else expandedGroupIds.delete(groupId); settleDisclosureLayout(); }, group.toolCount, ); if (group.rows.some((r) => enteringRowIds.has(r.id))) { groupEl.classList.add("st-row-enter"); } groupEls.set(groupId, groupEl); children.push(groupEl); } continue; } // Resolved approvals not absorbed into a tool group are hidden — the // tool row itself already identifies the action, so a standalone // "answered" card adds nothing. Pending approvals always render. if (row.kind === "approval" && row.id !== pendingApprovalRowId) continue; const rowEl = buildRowElement(row); if (!rowEl) continue; rowEls.set(row.id, rowEl); children.push(rowEl); } // A completed turn's footer is useful as the conversation's current // tail, but repeating duration/model/token summaries throughout history // makes them read like transcript content. Keep only a footer that is the // final visible item; appending the next turn naturally retires it. const visibleChildren = children.filter( (child, index) => !child.classList.contains("st-turn-meta") || index === children.length - 1, ); rowsEl.replaceChildren(...visibleChildren); enteringRowIds.clear(); if (restoreToolFocus && focusedRowId) { rowEls.get(focusedRowId)?.querySelector(".st-tool-head")?.focus(); } else if (restoreGroupFocus && focusedGroupId) { groupEls.get(focusedGroupId)?.querySelector(".st-tool-group-head")?.focus(); } } function applySnapshot(snapshot: { rows: TranscriptRow[] }): void { const forceBottom = !receivedSnapshot; updateTranscriptLayout(() => { rows = [...snapshot.rows]; const expandableIds = new Set( rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id), ); for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id); rowIndex.clear(); rows.forEach((row, i) => rowIndex.set(row.id, i)); rebuildTranscript(); syncAvailableCommands(); }, forceBottom); receivedSnapshot = true; } function applyDelta(delta: TranscriptDelta | null | undefined): void { if (!delta) return; updateTranscriptLayout(() => { if (delta.type === "append") { if (receivedSnapshot) enteringRowIds.add(delta.row.id); rowIndex.set(delta.row.id, rows.length); rows.push(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); } else { rows[i] = delta.row; // Surgical DOM swap: replace just this row's element instead of // tearing down and rebuilding the entire transcript, which causes // visible flashing and breaks text selection. const oldEl = rowEls.get(delta.row.id); if (oldEl) { const active = document.activeElement as HTMLElement | null; const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active); const newEl = buildRowElement(delta.row); if (newEl) { oldEl.replaceWith(newEl); rowEls.set(delta.row.id, newEl); if (hadToolFocus) { newEl.querySelector(".st-tool-head")?.focus(); } } syncAvailableCommands(); return; } } } else { const i = rowIndex.get(delta.id); if (i === undefined) return; rows.splice(i, 1); expandedRowIds.delete(delta.id); rowIndex.clear(); rows.forEach((row, idx) => rowIndex.set(row.id, idx)); } rebuildTranscript(); syncAvailableCommands(); }, false, true); } function sessionConfigOptions(): AcpSessionConfigOption[] { return state.config_options?.configOptions ?? []; } function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] { return (option.options ?? []).flatMap((choice) => "options" in choice ? choice.options : [choice], ); } function currentConfigLabel(option: AcpSessionConfigOption): string { if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off"; return configChoices(option).find((choice) => choice.value === option.currentValue)?.name ?? option.currentValue; } async function changeConfig(option: AcpSessionConfigOption, value: string | boolean): Promise { configBusy = true; renderConfig(); clearError(); try { await setStructuredConfig(sessionId, { config_id: option.id, value }); } catch (error) { showError(error instanceof Error ? error.message : "Could not change the session setting"); } finally { configBusy = false; renderConfig(); } } let configDirty = false; function renderConfig(): void { const options = sessionConfigOptions(); configEl.hidden = options.length === 0; if (options.length === 0) { configMenu.hidden = true; configTrigger.setAttribute("aria-expanded", "false"); configDirty = false; return; } const ordered = [...options].sort((a, b) => { const rank = (category?: string | null) => ["model", "thought_level", "mode", "model_config"].indexOf(category ?? ""); const aRank = rank(a.category); const bRank = rank(b.category); return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank); }); const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n"); configTrigger.title = summary; configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`); configTrigger.disabled = configBusy; // When the menu is open and the user is interacting (not mid-change), // skip the DOM rebuild so open