This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/components/structured/useStructuredSession.ts
T
Miguel PalhasandClaude Opus 5 9ba1078386 feat(maestro): port the structured pane to React
Closes #246. The transcript, composer and approval controls move from
structured.ts/structuredRender.ts into components under
web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row
kinds, both projections, the single-active approval nonce, and diff rendering
with its own line counts.

The pure layers stay shared — structuredDiff.ts computes the diff,
structuredRender.ts still sanitizes markdown — and model.ts holds the
client-side decisions (activity grouping, notice classification, offered
decisions, `/` completion) so they are testable without a DOM.

Like TerminalPane, the pane draws the body only; pane chrome reads the
connection through onStatusChange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:41:18 +01:00

217 lines
6.8 KiB
TypeScript

// The `WS /api/structured/:id` half of the pane (#246). Every connect — fresh
// or reconnect — resends the whole transcript rather than resuming a cursor
// (docs/STRUCTURED-VIEW.md), so a snapshot replaces state wholesale instead of
// merging into what came before.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { structuredWsUrl } from "../../routes.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import type {
ReducedState,
StructuredEvent,
TranscriptDelta,
TranscriptRow,
Uuid,
} from "../../types.js";
export type StructuredStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Injectable so tests can drive the socket without a daemon — the same seam
* structured.ts used. */
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
const idleState = (): ReducedState => ({
turn: { responding: false, thinking: false, active_tool_calls: [] },
});
function applyDelta(rows: TranscriptRow[], delta: TranscriptDelta): TranscriptRow[] {
if (delta.type === "append") {
// A patch can arrive for a row this connection has never seen (its
// snapshot predates it); treating an unknown id as an append keeps the
// transcript complete rather than dropping the row.
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
if (delta.type === "patch") {
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
return rows.filter((row) => row.id !== delta.id);
}
export interface StructuredSession {
rows: TranscriptRow[];
state: ReducedState;
status: StructuredStatus;
/** A daemon-supplied close reason (code ≥ 4000), when there is one. */
detail: string | null;
/** The number of sockets this pane has opened, newest last. A submission
* that straddles a reconnect uses it to notice its acceptance may predate
* the current snapshot. */
socketGeneration: number;
/** Drop the socket and reconnect, so the next snapshot cannot predate a
* POST that has already been accepted. */
reconnect(): void;
}
export interface UseStructuredSessionOptions {
shouldReconnect?: (code: number) => boolean;
onClosed?: () => void;
socketFactory?: StructuredSocketFactory;
}
/** Application-defined close codes (daemon rejection) and the clean-close code
* — retrying either would only loop. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export function useStructuredSession(
sessionId: Uuid,
{
shouldReconnect = defaultShouldReconnect,
onClosed,
socketFactory = (id) => new WebSocket(structuredWsUrl(id)),
}: UseStructuredSessionOptions = {},
): StructuredSession {
const [rows, setRows] = useState<TranscriptRow[]>([]);
const [state, setState] = useState<ReducedState>(idleState);
const [status, setStatus] = useState<StructuredStatus>("connecting");
const [detail, setDetail] = useState<string | null>(null);
const [socketGeneration, setSocketGeneration] = useState(0);
// Read through refs so a changed callback never tears the socket down.
const shouldReconnectRef = useRef(shouldReconnect);
const onClosedRef = useRef(onClosed);
const factoryRef = useRef(socketFactory);
shouldReconnectRef.current = shouldReconnect;
onClosedRef.current = onClosed;
factoryRef.current = socketFactory;
const reconnectRef = useRef<() => void>(() => {});
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let reconnectMs = INITIAL_RECONNECT_MS;
let timer: number | null = null;
const closeSocket = () => {
const existing = socket;
socket = null;
if (!existing) return;
existing.onopen = null;
existing.onmessage = null;
existing.onerror = null;
existing.onclose = null;
try {
existing.close();
} catch {
// ignore — an already-closing socket has nothing to close
}
};
const cancelTimer = () => {
if (timer === null) return;
window.clearTimeout(timer);
timer = null;
};
function connect(): void {
if (disposed) return;
cancelTimer();
closeSocket();
const current = factoryRef.current(sessionId);
socket = current;
setSocketGeneration((n) => n + 1);
current.onopen = () => {
if (disposed || socket !== current) return;
reconnectMs = INITIAL_RECONNECT_MS;
setDetail(null);
setStatus("open");
};
current.onmessage = (ev) => {
if (disposed || socket !== current) 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":
setRows([...msg.snapshot.rows]);
break;
case "reduced_state":
setState(msg.state);
break;
case "projection_update":
if (msg.transcript) {
const delta = msg.transcript;
setRows((previous) => applyDelta(previous, delta));
}
setState(msg.state);
break;
}
};
current.onerror = () => {
if (disposed || socket !== current) return;
setStatus("error");
};
current.onclose = (ev) => {
if (disposed || socket !== current) return;
socket = null;
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason) {
setDetail(reason);
setStatus("error");
return;
}
if (shouldReconnectRef.current(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setStatus("reconnecting");
timer = window.setTimeout(() => {
timer = null;
if (disposed) return;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setDetail(String(ev.code));
setStatus("closed");
onClosedRef.current?.();
};
}
reconnectRef.current = () => {
if (disposed) return;
setStatus("reconnecting");
connect();
};
setStatus("connecting");
connect();
return () => {
disposed = true;
reconnectRef.current = () => {};
cancelTimer();
closeSocket();
};
}, [sessionId]);
const reconnect = useCallback(() => reconnectRef.current(), []);
return useMemo(
() => ({ rows, state, status, detail, socketGeneration, reconnect }),
[rows, state, status, detail, socketGeneration, reconnect],
);
}