// 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 queueEl = el.querySelector(".structured-queue") as HTMLSpanElement; 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; // 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; } } } 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); } 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); } } // pending at end of a turn = final text/approvals, stays outside (not flushed). 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); 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]; }); 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 newEl = buildRowElement(delta.row); if (newEl) { oldEl.replaceWith(newEl); rowEls.set(delta.row.id, newEl); } 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(); } } function renderConfig(): void { const options = sessionConfigOptions(); configEl.hidden = options.length === 0; if (options.length === 0) { configMenu.hidden = true; configTrigger.setAttribute("aria-expanded", "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; configMenu.replaceChildren(); for (const option of ordered) { const row = document.createElement("label"); row.className = "structured-config-row"; const name = document.createElement("span"); name.className = "structured-config-name"; name.textContent = option.name; row.append(name); if (option.type === "select") { const select = document.createElement("select"); select.className = "structured-config-select"; select.disabled = configBusy; for (const choice of configChoices(option)) { const item = document.createElement("option"); item.value = choice.value; item.textContent = choice.name; item.title = choice.description ?? ""; select.append(item); } select.value = String(option.currentValue); select.addEventListener("change", () => void changeConfig(option, select.value)); row.append(select); } else if (option.type === "boolean") { const toggle = document.createElement("input"); toggle.type = "checkbox"; toggle.className = "structured-config-toggle"; toggle.checked = option.currentValue === true; toggle.disabled = configBusy; toggle.addEventListener("change", () => void changeConfig(option, toggle.checked)); row.append(toggle); } else { continue; } if (option.description) row.title = option.description; configMenu.append(row); } } function renderComposerState(): void { const projectedActive = state.turn.responding || state.turn.thinking; input.disabled = false; attachBtn.disabled = uploading; fileInput.disabled = uploading; renderAttachments(); sendBtn.disabled = false; sendBtn.hidden = false; sendLabel.textContent = "Send"; cancelBtn.hidden = !projectedActive; queueEl.hidden = promptQueue.length === 0; queueEl.textContent = promptQueue.length === 1 ? "1 message queued" : `${promptQueue.length} messages queued`; renderActivity(); } function liveElapsedMs(): number { const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN; const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticTurnStartedAt; return startedAt === null ? 0 : Math.max(0, Date.now() - startedAt); } function renderActivity(): void { const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; activityEl.hidden = !active; if (!active) { optimisticTurnStartedAt = null; if (activityTimer !== null) window.clearInterval(activityTimer); activityTimer = null; return; } const tools = state.turn.active_tool_calls.length; const label = awaitingBeginGeneration !== null ? "Sending" : state.turn.thinking ? "Thinking" : tools > 0 ? `Running ${tools} ${tools === 1 ? "tool" : "tools"}` : "Responding"; const parts = [label, formatElapsed(liveElapsedMs())]; if (typeof state.usage?.used === "number") { const usage = formatContextTokens(state.usage.used); parts.push(typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage); } activityText.textContent = parts.join(" · "); if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000); } 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) { updateTranscriptLayout(() => { const prev = pendingApprovalRowId; pendingApprovalRowId = newPendingId; if (prev !== null || newPendingId !== null) rebuildTranscript(); }); } renderConfig(); renderComposerState(); dispatchQueuedPrompt(); } function closeConfigMenu(): void { configMenu.hidden = true; configTrigger.setAttribute("aria-expanded", "false"); } configTrigger.addEventListener("click", () => { const opening = configMenu.hidden; configMenu.hidden = !opening; configTrigger.setAttribute("aria-expanded", String(opening)); }); const closeConfigOnOutsidePress = (event: PointerEvent) => { if (!configEl.contains(event.target as Node)) closeConfigMenu(); }; document.addEventListener("pointerdown", closeConfigOnOutsidePress); configEl.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeConfigMenu(); configTrigger.focus(); } }); // ---- 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 commandsFromTranscript(): AvailableCommand[] { for (let i = rows.length - 1; i >= 0; i--) { if (rows[i].kind !== "notice") continue; const data = rows[i].data as | { sessionUpdate?: string; availableCommands?: unknown[] } | undefined; if (data?.sessionUpdate !== "available_commands_update") continue; return (data.availableCommands ?? []).flatMap((value) => { if (!value || typeof value !== "object") return []; const command = value as Partial; if (typeof command.name !== "string" || typeof command.description !== "string") return []; return [{ name: command.name, description: command.description, input: command.input }]; }); } return []; } function closeCommandMenu(): void { commandMenu.hidden = true; commandMenu.replaceChildren(); filteredCommands = []; activeCommandIndex = -1; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); } function commandTokenAtCursor(): CommandToken | null { const cursor = input.selectionStart; if (cursor !== input.selectionEnd) return null; let start = cursor; while (start > 0 && !/\s/.test(input.value[start - 1])) start--; let end = cursor; while (end < input.value.length && !/\s/.test(input.value[end])) end++; const match = /^\/([^\s/]*)$/.exec(input.value.slice(start, cursor)); return match ? { start, end, query: match[1].toLocaleLowerCase() } : null; } function chooseCommand(command: AvailableCommand): void { const token = commandTokenAtCursor(); if (!token) return; const before = input.value.slice(0, token.start); const after = input.value.slice(token.end); let replacement = `/${command.name}`; let cursor = before.length + replacement.length; if (after.length === 0) { replacement += " "; cursor++; } else if (/^[ \t]/.test(after)) { cursor++; } input.value = before + replacement + after; closeCommandMenu(); autoResize(); input.focus(); input.setSelectionRange(cursor, cursor); } function renderCommandMenu(): void { const token = commandTokenAtCursor(); if (!token || input.disabled || availableCommands.length === 0) { closeCommandMenu(); return; } const { query } = token; filteredCommands = availableCommands .filter((command) => command.name.toLocaleLowerCase().includes(query)) .sort((a, b) => { const aStarts = a.name.toLocaleLowerCase().startsWith(query); const bStarts = b.name.toLocaleLowerCase().startsWith(query); return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1; }); if (filteredCommands.length === 0) { closeCommandMenu(); return; } activeCommandIndex = Math.min(Math.max(activeCommandIndex, 0), filteredCommands.length - 1); const options = filteredCommands.map((command, index) => { const option = document.createElement("div"); option.id = `${commandMenu.id}-option-${index}`; option.className = "structured-command-option"; option.setAttribute("role", "option"); option.setAttribute("aria-selected", String(index === activeCommandIndex)); const title = document.createElement("div"); title.className = "structured-command-title"; const name = document.createElement("span"); name.className = "structured-command-name"; name.textContent = `/${command.name}`; const hint = document.createElement("span"); hint.className = "structured-command-hint"; hint.textContent = command.input?.hint ?? ""; title.append(name, hint); const description = document.createElement("div"); description.className = "structured-command-description"; description.textContent = command.description.replace(/\s+/g, " ").trim(); option.append(title, description); option.addEventListener("pointerdown", (event) => event.preventDefault()); option.addEventListener("click", () => chooseCommand(command)); return option; }); commandMenu.replaceChildren(...options); commandMenu.hidden = false; input.setAttribute("aria-expanded", "true"); input.setAttribute("aria-activedescendant", options[activeCommandIndex].id); options[activeCommandIndex].scrollIntoView?.({ block: "nearest" }); } function syncAvailableCommands(): void { availableCommands = commandsFromTranscript(); renderCommandMenu(); } function autoResize(): void { updateTranscriptLayout(() => { input.style.height = "auto"; input.style.overflowY = "hidden"; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; if (input.scrollHeight > 160) input.style.overflowY = "auto"; }, false, true); } input.addEventListener("input", () => { autoResize(); activeCommandIndex = 0; renderCommandMenu(); }); input.addEventListener("click", renderCommandMenu); input.addEventListener("select", renderCommandMenu); input.addEventListener("keyup", (event) => { if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) renderCommandMenu(); }); input.addEventListener("keydown", (e) => { if (!commandMenu.hidden) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const direction = e.key === "ArrowDown" ? 1 : -1; activeCommandIndex = (activeCommandIndex + direction + filteredCommands.length) % filteredCommands.length; renderCommandMenu(); return; } if ((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") { e.preventDefault(); chooseCommand(filteredCommands[activeCommandIndex]); return; } if (e.key === "Escape") { e.preventDefault(); closeCommandMenu(); return; } } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); composerForm.requestSubmit(); } }); input.addEventListener("blur", () => { window.setTimeout(() => { if (!commandMenu.contains(document.activeElement)) closeCommandMenu(); }, 0); }); function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void { uploading = submittedAttachments.length > 0; const generation = ++submissionGeneration; const submissionSocketGeneration = socketGeneration; awaitingBeginGeneration = generation; optimisticTurnStartedAt = Date.now(); acceptedSubmission = null; promptRequestPending = true; input.value = ""; autoResize(); clearError(); renderComposerState(); const send = async () => { for (const attachment of submittedAttachments) { if (disposed) return; if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path; } if (disposed) return; const references = submittedAttachments.map(({ file, path }) => `Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`); await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n")); }; void send().then( () => { promptRequestPending = false; if (disposed || generation !== submissionGeneration) return; uploading = false; if (attachments === submittedAttachments) { attachments = []; renderAttachments(); } renderComposerState(); if (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(); renderComposerState(); dispatchQueuedPrompt(); }, (e: unknown) => { promptRequestPending = false; uploading = false; if (disposed || generation !== submissionGeneration) return; if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; if (attachments.length === 0 && submittedAttachments.length > 0) { attachments = submittedAttachments; } if (input.value === "") { input.value = text; autoResize(); } showError(String(e)); renderComposerState(); }, ); } function dispatchQueuedPrompt(): void { const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; if (disposed || active || promptRequestPending || promptQueue.length === 0) return; const next = promptQueue.shift()!; dispatchPrompt(next.text, next.attachments); } composerForm.addEventListener("submit", (e) => { e.preventDefault(); const text = input.value; if ((!text.trim() && attachments.length === 0) || uploading) return; const submittedAttachments = attachments; const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; if (active || promptQueue.length > 0) { promptQueue.push({ text, attachments: submittedAttachments }); attachments = []; input.value = ""; autoResize(); clearError(); renderComposerState(); dispatchQueuedPrompt(); return; } dispatchPrompt(text, submittedAttachments); }); cancelBtn.addEventListener("click", () => { clearError(); cancelStructuredTurn(sessionId).catch((e: unknown) => { if (!disposed) showError(String(e)); }); }); // ---- 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; let focusFrame: number | null = null; function cancelFocusFrame(): void { if (focusFrame !== null) window.cancelAnimationFrame(focusFrame); focusFrame = null; } return { sessionId, el, onMount(mountOpts?: { focus?: boolean }) { const focusOnMount = mountOpts?.focus !== false; const token = ++mountToken; scrollWorkActive = true; pendingScroll = { top: parkedScrollTop ?? scrollEl.scrollTop, follow: followingBottom, smooth: false, }; parkedScrollTop = null; finishTranscriptLayout(); cancelFocusFrame(); focusFrame = window.requestAnimationFrame(() => { focusFrame = null; if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled if (focusOnMount && !input.disabled) input.focus(); }); }, onUnmount() { mountToken++; parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop; scrollWorkActive = false; cancelScrollFrame(); stopSmoothFollow(); cancelFocusFrame(); }, 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. }, updateInfo(next: AttachInfo) { titleEl.innerHTML = paneTitleHtml(sessionId, next); titleEl.title = paneTitleAttr(sessionId, next); }, dispose() { if (disposed) return; disposed = true; if (activityTimer !== null) window.clearInterval(activityTimer); mountToken++; scrollWorkActive = false; cancelScrollFrame(); stopSmoothFollow(); cancelFocusFrame(); cancelReconnect(); closeCurrentSocket(); expandedRowIds.clear(); expandedGroupIds.clear(); scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("click", onTranscriptClick, true); scrollEl.removeEventListener("wheel", stopSmoothFollow); scrollEl.removeEventListener("touchstart", stopSmoothFollow); document.removeEventListener("pointerdown", closeConfigOnOutsidePress); el.remove(); }, }; }