// React port of structured.ts (#246): a live structured session — the // transcript from `WS /api/structured/:id`, the composer that drives // `.../prompt` and `.../cancel`, and the approval controls that answer // `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract. // // Like TerminalPane, the pane draws the body only: pane chrome (title, // disconnect, sessions drawer) belongs to whatever composes it, which reads // the connection state through `onStatusChange`. import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt, setStructuredConfig, uploadStructuredFile, } from "../../api.js"; import { formatContextTokens, formatElapsed } from "./render.js"; import type { AcpSessionConfigOption, StructuredApprovalDecision, TranscriptRow as Row, Uuid, } from "../../types.js"; import { Composer } from "./Composer.js"; import { commandsFromTranscript, pendingApprovalRowId as pendingApprovalRowIdOf, transcriptItems, turnIsActive, } from "./model.js"; import { ToolGroup } from "./ToolGroup.js"; import { TranscriptRow } from "./TranscriptRow.js"; import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js"; import "./structured.css"; /** Within this much of the bottom, wheel and touch imprecision should still * count as following. */ const FOLLOW_BOTTOM_THRESHOLD_PX = 64; export interface StructuredPaneProps { sessionId: Uuid; /** Grabs the composer once mounted. A sidebar preview passes `false`. */ focus?: boolean; shouldReconnect?: (code: number) => boolean; onClosed?: () => void; onStatusChange?: (status: StructuredStatus, detail?: string) => void; /** Test seam: drive the socket without a daemon. */ socketFactory?: StructuredSocketFactory; className?: string; } export interface StructuredPaneHandle { /** Put the keyboard in the composer — the structured pane's terminal. */ focusComposer(): void; } export const StructuredPane = forwardRef(function StructuredPane( { sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className }, ref, ) { const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory }); const { rows, state, status, detail, socketGeneration } = session; const scrollRef = useRef(null); const composerRef = useRef(null); const followingBottom = useRef(true); const [expandedRows, setExpandedRows] = useState>(new Set()); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState([]); const [uploading, setUploading] = useState(false); const [configBusy, setConfigBusy] = useState(false); const [error, setError] = useState(null); const [now, setNow] = useState(() => Date.now()); /** Server path → object URL, so a user row can preview an image it uploaded. */ const imageUrls = useRef(new Map()); // Submission bookkeeping, ported from structured.ts: a prompt is "sending" // from the POST until the projection shows the turn it claimed, and an // acceptance that straddles a reconnect forces one more reconnect so the // snapshot cannot predate the 204. const submission = useRef(0); const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null); const optimisticStart = useRef(null); const [awaitingBegin, setAwaitingBegin] = useState(null); useImperativeHandle( ref, () => ({ focusComposer: () => composerRef.current?.querySelector(".structured-input")?.focus(), }), [], ); useEffect(() => { onStatusChange?.(status, detail ?? undefined); }, [status, detail, onStatusChange]); useEffect(() => { if (!focus) return; composerRef.current?.querySelector(".structured-input")?.focus(); }, [focus]); const urls = imageUrls.current; useEffect( () => () => { for (const url of urls.values()) URL.revokeObjectURL(url); urls.clear(); }, [urls], ); const turnActive = turnIsActive(state); const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]); const items = useMemo( () => transcriptItems(rows, pendingRowId, turnActive), [rows, pendingRowId, turnActive], ); const commands = useMemo(() => commandsFromTranscript(rows), [rows]); const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? []; const active = turnActive || awaitingBegin !== null; // Stop saying "Sending" once the turn the submission was waiting for has // begun — or, when it was accepted but a replacement socket has // re-snapshotted since, once that newer snapshot has landed. Without the // second case a prompt accepted across a reconnect would say "Sending" // forever. useEffect(() => { if (turnActive) { setAwaitingBegin(null); } else if ( awaitingBegin !== null && accepted.current?.generation === awaitingBegin && socketGeneration > accepted.current.socketGeneration ) { setAwaitingBegin(null); } }, [turnActive, awaitingBegin, socketGeneration]); useEffect(() => { if (!active) optimisticStart.current = null; }, [active]); // Keep the pane pinned to the newest row unless the operator scrolled away. useLayoutEffect(() => { const el = scrollRef.current; if (!el || !followingBottom.current) return; el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); }, [items, active, state.queued_prompts]); useEffect(() => { if (!active) return; const timer = window.setInterval(() => setNow(Date.now()), 1_000); return () => window.clearInterval(timer); }, [active]); const onScroll = () => { const el = scrollRef.current; if (!el) return; followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX; }; const decide = useCallback( async (rowId: string, decision: StructuredApprovalDecision): Promise => { const nonce = state.pending_approval?.nonce; // Already resolved elsewhere between the click and here. A stale nonce // is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers // 204), and the row's buttons are already gone — resolve quietly. if (!nonce || rowId !== pendingRowId) return; try { await resolveStructuredApproval(sessionId, nonce, decision); } catch (e: unknown) { // Propagate — so the row re-enables its buttons — only while this is // still the pending approval. If it moved on in flight, reviving the // buttons would revive a decision that no longer applies. setError(String(e)); throw e; } }, [sessionId, state.pending_approval?.nonce, pendingRowId], ); const dispatchPrompt = useCallback( (text: string, files: File[]) => { const generation = ++submission.current; const submissionSocket = socketGeneration; setAwaitingBegin(generation); optimisticStart.current = Date.now(); accepted.current = null; setUploading(files.length > 0); setDraft(""); setError(null); const send = async () => { const references: string[] = []; for (const file of files) { const { path } = await uploadStructuredFile(sessionId, file); if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file)); references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`); } await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n")); }; void send().then( () => { if (generation !== submission.current) return; setUploading(false); setAttachments((current) => (current === files ? [] : current)); accepted.current = { generation, socketGeneration }; // A replacement socket may have captured idle before the POST // claimed the prompt. Reconnect so its snapshot cannot predate the // 204. if (socketGeneration > submissionSocket) session.reconnect(); }, (e: unknown) => { if (generation !== submission.current) return; setAwaitingBegin((current) => (current === generation ? null : current)); optimisticStart.current = null; setUploading(false); // The draft and its attachments are the operator's work — a failed // send hands both back rather than eating them. setAttachments((current) => (current.length === 0 ? files : current)); setDraft((current) => (current === "" ? text : current)); setError(String(e)); }, ); }, [sessionId, socketGeneration, session], ); // The server accepts prompts at any time: if a turn is active the prompt is // queued server-side and drained when the turn completes. The client always // POSTs immediately — no local queue. const submit = (text: string, files: File[]) => { dispatchPrompt(text, files); }; const cancel = () => { setError(null); cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e))); }; const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => { setConfigBusy(true); setError(null); setStructuredConfig(sessionId, { config_id: option.id, value }) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not change the session setting"), ) .finally(() => setConfigBusy(false)); }; const elapsedMs = () => { const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN; const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current; return startedAt === null ? 0 : Math.max(0, now - startedAt); }; const activityLabel = () => { const tools = state.turn.active_tool_calls.length; const label = awaitingBegin !== null ? "Sending" : state.turn.thinking ? "Thinking" : tools > 0 ? `Running ${tools} ${tools === 1 ? "tool" : "tools"}` : "Responding"; const parts = [label, formatElapsed(elapsedMs())]; 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, ); } return parts.join(" · "); }; const setRowExpanded = (id: string, expanded: boolean) => setExpandedRows((current) => { const next = new Set(current); if (expanded) next.add(id); else next.delete(id); return next; }); const setGroupExpanded = (id: string, expanded: boolean) => setExpandedGroups((current) => { const next = new Set(current); if (expanded) next.add(id); else next.delete(id); return next; }); const renderRow = (row: Row) => ( decide(row.id, decision)} expanded={expandedRows.has(row.id)} onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)} imageUrls={imageUrls.current} /> ); return (
{items.map((item) => item.type === "row" ? ( renderRow(item.row) ) : ( setGroupExpanded(item.id, expanded)} > {item.group.rows.map(renderRow)} ), )} {(state.queued_prompts ?? []).map((text, index) => (
{text}
))}
{error !== null &&
{error}
}
); });