// A single live structured session: a transcript + rail 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, structuredWsUrl, } from "./api.js"; import { requestToggleDrawer } from "./drawer.js"; import type { CatalogState, 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 type { AcpPlan, AcpUsage, ReducedState, Session, 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: [], }); 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 pillsEl = el.querySelector(".st-pills") as HTMLSpanElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; 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; const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; 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(); 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; let socketGeneration = 0; let stateSocketGeneration = 0; let disposed = false; 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; } } } 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); }, ); if (!el) return null; if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { expandedRowIds.delete(row.id); } return el; } function insertRowElement(row: TranscriptRow, el: HTMLElement): void { const index = rowIndex.get(row.id) ?? rows.length - 1; let next: HTMLElement | null = null; for (let i = index + 1; i < rows.length; i++) { next = rowEls.get(rows[i].id) ?? null; if (next) break; } rowEls.set(row.id, el); rowsEl.insertBefore(el, next); } function mountRow(row: TranscriptRow): void { const el = buildRowElement(row); if (el) insertRowElement(row, el); } 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; } if (old) { rowEls.set(row.id, el); old.replaceWith(el); } else { insertRowElement(row, el); } const newToolHead = el.querySelector(".st-tool-head"); if (restoreFocus) newToolHead?.focus(); } function applySnapshot(snapshot: { rows: TranscriptRow[] }): void { rows = [...snapshot.rows]; const expandableIds = new Set( rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id), ); for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id); rowIndex.clear(); rows.forEach((row, i) => rowIndex.set(row.id, i)); rowEls.clear(); rowsEl.replaceChildren(); for (const row of rows) mountRow(row); } function applyDelta(delta: TranscriptDelta | null | undefined): void { if (!delta) return; if (delta.type === "append") { rowIndex.set(delta.row.id, rows.length); rows.push(delta.row); mountRow(delta.row); } else if (delta.type === "patch") { const i = rowIndex.get(delta.row.id); if (i === undefined) { 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); if (i === undefined) return; rows.splice(i, 1); expandedRowIds.delete(delta.id); rowIndex.clear(); rows.forEach((row, idx) => rowIndex.set(row.id, idx)); rowEls.get(delta.id)?.remove(); rowEls.delete(delta.id); } } function toolRowCount(): number { 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); if (state.mode) parts.push(state.mode); 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; sendBtn.disabled = active; cancelBtn.hidden = !state.turn.responding && !state.turn.thinking; } function reconcileAwaitingBegin(): void { if (state.turn.responding || state.turn.thinking) { awaitingBeginGeneration = null; } else if ( awaitingBeginGeneration !== null && acceptedSubmission?.generation === awaitingBeginGeneration && stateSocketGeneration > acceptedSubmission.socketGeneration ) { awaitingBeginGeneration = null; } } function applyState(next: ReducedState, connectionGeneration: number): void { state = next; stateSocketGeneration = connectionGeneration; reconcileAwaitingBegin(); const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; if (newPendingId !== pendingApprovalRowId) { const prev = pendingApprovalRowId; pendingApprovalRowId = newPendingId; if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]); if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]); } renderPills(); renderRailNow(); renderComposerState(); } // ---- error banner ---- let errorEl: HTMLElement | null = null; function showError(message: string): void { if (!errorEl) { errorEl = document.createElement("div"); errorEl.className = "structured-error"; composerForm.insertAdjacentElement("beforebegin", errorEl); } errorEl.textContent = message; } function clearError(): void { errorEl?.remove(); errorEl = null; } // ---- composer ---- function autoResize(): void { input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; } input.addEventListener("input", autoResize); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); composerForm.requestSubmit(); } }); composerForm.addEventListener("submit", (e) => { e.preventDefault(); const text = input.value; if (!text.trim() || input.disabled) return; const generation = ++submissionGeneration; const submissionSocketGeneration = socketGeneration; awaitingBeginGeneration = generation; acceptedSubmission = null; input.value = ""; autoResize(); clearError(); renderComposerState(); sendStructuredPrompt(sessionId, text).then( () => { if ( disposed || generation !== submissionGeneration || awaitingBeginGeneration !== generation ) return; const acceptanceSocketGeneration = socketGeneration; acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration }; // A replacement socket may have captured idle before the POST claimed // the prompt. Reconnect again so its snapshot cannot predate the 204. if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation(); }, (e: unknown) => { if (disposed || generation !== submissionGeneration) return; if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; if (input.value === "") { input.value = text; autoResize(); } showError(String(e)); renderComposerState(); }, ); }); cancelBtn.addEventListener("click", () => { clearError(); cancelStructuredTurn(sessionId).catch((e: unknown) => { if (!disposed) showError(String(e)); }); }); // ---- narrow-pane rail collapse (a split pane, not just the viewport) ---- const NARROW_BREAKPOINT = 560; const resizeObserver = new ResizeObserver((entries) => { const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth; bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT); }); 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; let reconnectTimer: number | null = null; function cancelReconnect(): void { if (reconnectTimer === null) return; window.clearTimeout(reconnectTimer); reconnectTimer = null; } function closeCurrentSocket(): void { const socket = ws; ws = null; if (!socket) return; socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null; try { socket.close(); } catch { // ignore } } function reconnectForReconciliation(): void { if (disposed) return; cancelReconnect(); closeCurrentSocket(); setConnState("reconnecting…", "connecting"); connect(); } function connect(): void { if (disposed) return; cancelReconnect(); closeCurrentSocket(); const connectionGeneration = ++socketGeneration; const socket = socketFactory(sessionId); ws = socket; socket.onopen = () => { if (disposed || ws !== socket) return; reconnectMs = INITIAL_RECONNECT_MS; setConnState("connected", "open"); }; socket.onmessage = (ev) => { if (disposed || ws !== socket) return; if (typeof ev.data !== "string") return; let msg: StructuredEvent; try { msg = JSON.parse(ev.data) as StructuredEvent; } catch { return; // not a structured event we understand } switch (msg.type) { case "transcript_snapshot": applySnapshot(msg.snapshot); break; case "reduced_state": applyState(msg.state, connectionGeneration); break; case "projection_update": applyDelta(msg.transcript); applyState(msg.state, connectionGeneration); break; } }; socket.onerror = () => { if (disposed || ws !== socket) return; setConnState("error", "error"); }; socket.onclose = (ev) => { if (disposed || ws !== socket) return; ws = null; const reason = ev.reason?.trim(); if (ev.code >= 4000 && reason) { setConnState(reason, "error"); showError(`cannot attach: ${reason}`); return; } if (opts.shouldReconnect?.(sessionId, ev.code)) { const delay = Math.min(reconnectMs, MAX_RECONNECT_MS); setConnState("reconnecting…", "connecting"); reconnectTimer = window.setTimeout(() => { reconnectTimer = null; if (disposed) return; reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS); connect(); }, delay); return; } setConnState(`closed (${ev.code})`, "closed"); opts.onClosed?.(sessionId); }; } connect(); // A pane can be parked again before its deferred focus frame fires (a // preview stepped past it, a split closed it) — the frame must not focus a // hidden textarea out from under whatever the operator moved to since. // `mountToken` is bumped on every mount/unmount so a stale frame can tell // it is no longer the most recent one. let mountToken = 0; return { sessionId, el, onMount(mountOpts?: { focus?: boolean }) { const focusOnMount = mountOpts?.focus !== false; const token = ++mountToken; requestAnimationFrame(() => { if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled if (focusOnMount && !input.disabled) input.focus(); }); }, onUnmount() { mountToken++; }, focusTerminal() { if (!input.disabled) input.focus(); }, sendInput() { // No PTY behind a structured session — the leader's literal-byte // escape hatch has nothing to do here. }, dispose() { if (disposed) return; disposed = true; mountToken++; cancelReconnect(); resizeObserver.disconnect(); unsubscribeStore?.(); closeCurrentSocket(); expandedRowIds.clear(); el.remove(); }, }; }