531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
// 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 = `
|
|
<header class="attach-header">
|
|
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
|
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
|
|
<span class="st-pills"></span>
|
|
<span class="attach-state" data-state="connecting">connecting…</span>
|
|
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
|
</header>
|
|
<div class="structured-body">
|
|
<div class="structured-scroll">
|
|
<div class="structured-rows"></div>
|
|
<form class="structured-composer">
|
|
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"></textarea>
|
|
<button type="submit" class="btn btn-primary structured-send">Send</button>
|
|
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
|
|
</form>
|
|
</div>
|
|
<aside class="structured-rail"></aside>
|
|
</div>
|
|
`;
|
|
|
|
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<string, number>();
|
|
const rowEls = new Map<string, HTMLElement>();
|
|
const expandedRowIds = new Set<string>();
|
|
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<void> {
|
|
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<HTMLButtonElement>(".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<HTMLButtonElement>(".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();
|
|
},
|
|
};
|
|
}
|