1318 lines
52 KiB
TypeScript
1318 lines
52 KiB
TypeScript
// 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 = `
|
||
<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="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-main">
|
||
<div class="structured-scroll">
|
||
<div class="structured-rows"></div>
|
||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||
<span class="structured-activity-text">Thinking</span>
|
||
</div>
|
||
</div>
|
||
<form class="structured-composer">
|
||
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
|
||
</button>
|
||
<input class="structured-file-input" type="file" multiple hidden>
|
||
<div class="structured-input-wrap">
|
||
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
|
||
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
|
||
role="listbox" hidden></div>
|
||
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
|
||
autocomplete="off" aria-autocomplete="list" aria-expanded="false"
|
||
aria-controls="structured-command-menu-${sessionId}"></textarea>
|
||
</div>
|
||
<div class="structured-config" hidden>
|
||
<button type="button" class="structured-config-trigger" aria-haspopup="true"
|
||
aria-expanded="false" aria-label="Session settings" title="Session settings">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6"/></svg>
|
||
</button>
|
||
<div class="structured-config-menu" role="group" aria-label="Session settings" hidden></div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
|
||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
|
||
</button>
|
||
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
|
||
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 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<string, number>();
|
||
const rowEls = new Map<string, HTMLElement>();
|
||
const expandedRowIds = new Set<string>();
|
||
const expandedGroupIds = new Set<string>();
|
||
const enteringRowIds = new Set<string>();
|
||
const groupEls = new Map<string, HTMLElement>();
|
||
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;
|
||
/** Server path → object URL for uploaded images, so user bubbles can show previews. */
|
||
const imageObjectUrls = new Map<string, string>();
|
||
|
||
// 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<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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
|
||
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||
|
||
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);
|
||
}
|
||
// Inject image previews into user message bubbles and strip the path text.
|
||
if (row.kind === "user" && imageObjectUrls.size > 0) {
|
||
const data = row.data as { content?: { type: string; text?: string } } | undefined;
|
||
const text = data?.content?.type === "text" ? data.content.text ?? "" : "";
|
||
const body = el.querySelector(".st-body");
|
||
if (body && text) {
|
||
const images: string[] = [];
|
||
attachedFileRe.lastIndex = 0;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = attachedFileRe.exec(text)) !== null) {
|
||
const serverPath = JSON.parse(`"${match[1]}"`);
|
||
const objectUrl = imageObjectUrls.get(serverPath);
|
||
if (objectUrl) images.push(objectUrl);
|
||
}
|
||
if (images.length > 0) {
|
||
// Strip attachment reference lines from the displayed text.
|
||
const userText = body.querySelector(".st-user-text");
|
||
if (userText) {
|
||
const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim();
|
||
userText.textContent = cleaned;
|
||
if (!cleaned) userText.remove();
|
||
}
|
||
for (const src of images) {
|
||
const img = document.createElement("img");
|
||
img.className = "st-user-image";
|
||
img.src = src;
|
||
img.alt = "Uploaded image";
|
||
img.loading = "lazy";
|
||
body.appendChild(img);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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:<userId>`, each with `toolCount`
|
||
* (for the header label) and `rows` (tools + intermediate text/approvals,
|
||
* in order). */
|
||
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
|
||
// First: count tools per turn to know which turns qualify for grouping.
|
||
const toolCounts = new Map<string, number>();
|
||
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<string, { toolCount: number; rows: TranscriptRow[] }>();
|
||
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);
|
||
}
|
||
}
|
||
// A final agent text is the answer, so it stays outside. Thought rows are
|
||
// working commentary, though: when a turn already has a disclosure they
|
||
// belong with its activity even if the next tool call has not arrived yet.
|
||
// Leaving that tail loose made a busy agent look like it was publishing a
|
||
// long italic answer above a collapsed "N tool calls" summary.
|
||
const lastGroup = groups.get(currentGid);
|
||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
||
lastGroup.rows.push(...pending);
|
||
}
|
||
|
||
return groups;
|
||
}
|
||
|
||
function rebuildTranscript(): void {
|
||
const active = document.activeElement as HTMLElement | null;
|
||
const focusedRowId = active?.closest<HTMLElement>("[data-row-id]")?.dataset.rowId;
|
||
const focusedGroupId = active?.closest<HTMLElement>("[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<string>();
|
||
const liveGroupIds = new Set([...groups.keys()]);
|
||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||
|
||
const prevGroupEls = new Map(groupEls);
|
||
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];
|
||
});
|
||
|
||
// Reuse existing group element when possible — avoids tearing down
|
||
// and recreating the wrapper, which restarts CSS transitions (visible
|
||
// flash) and destroys keyboard/focus state.
|
||
const existing = prevGroupEls.get(groupId);
|
||
if (existing) {
|
||
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
|
||
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
|
||
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
|
||
if (inner) inner.replaceChildren(...memberEls);
|
||
groupEls.set(groupId, existing);
|
||
children.push(existing);
|
||
} else {
|
||
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<HTMLButtonElement>(".st-tool-head")?.focus();
|
||
} else if (restoreGroupFocus && focusedGroupId) {
|
||
groupEls.get(focusedGroupId)?.querySelector<HTMLButtonElement>(".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 active = document.activeElement as HTMLElement | null;
|
||
const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active);
|
||
const newEl = buildRowElement(delta.row);
|
||
if (newEl) {
|
||
oldEl.replaceWith(newEl);
|
||
rowEls.set(delta.row.id, newEl);
|
||
if (hadToolFocus) {
|
||
newEl.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
|
||
}
|
||
}
|
||
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<void> {
|
||
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();
|
||
}
|
||
}
|
||
|
||
let configDirty = false;
|
||
function renderConfig(): void {
|
||
const options = sessionConfigOptions();
|
||
configEl.hidden = options.length === 0;
|
||
if (options.length === 0) {
|
||
configMenu.hidden = true;
|
||
configTrigger.setAttribute("aria-expanded", "false");
|
||
configDirty = 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;
|
||
|
||
// When the menu is open and the user is interacting (not mid-change),
|
||
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
|
||
// Mark dirty so the next open rebuilds with fresh data.
|
||
if (!configMenu.hidden && !configBusy) {
|
||
configDirty = true;
|
||
return;
|
||
}
|
||
configDirty = false;
|
||
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 renderQueuedPrompts(): void {
|
||
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
|
||
for (const queued of promptQueue) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "st-row st-row-queued";
|
||
const body = document.createElement("div");
|
||
body.className = "st-body";
|
||
const line = document.createElement("div");
|
||
line.className = "st-user-text";
|
||
line.textContent = queued.text;
|
||
body.appendChild(line);
|
||
wrap.appendChild(body);
|
||
rowsEl.appendChild(wrap);
|
||
}
|
||
}
|
||
|
||
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;
|
||
renderQueuedPrompts();
|
||
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;
|
||
const wasHidden = activityEl.hidden;
|
||
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);
|
||
// Activity lives inside the scroll area; nudge scroll when it first appears.
|
||
if (wasHidden && followingBottom) {
|
||
beginTranscriptLayout(true);
|
||
finishTranscriptLayout();
|
||
}
|
||
}
|
||
|
||
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 {
|
||
const wasTurnActive = state.turn.responding || state.turn.thinking;
|
||
state = next;
|
||
stateSocketGeneration = connectionGeneration;
|
||
reconcileAwaitingBegin();
|
||
const isTurnActive = state.turn.responding || state.turn.thinking;
|
||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
|
||
if (needsRebuild) {
|
||
updateTranscriptLayout(() => {
|
||
pendingApprovalRowId = newPendingId;
|
||
rebuildTranscript();
|
||
});
|
||
}
|
||
renderConfig();
|
||
renderComposerState();
|
||
dispatchQueuedPrompt();
|
||
}
|
||
|
||
function closeConfigMenu(): void {
|
||
configMenu.hidden = true;
|
||
configTrigger.setAttribute("aria-expanded", "false");
|
||
}
|
||
|
||
configTrigger.addEventListener("click", () => {
|
||
const opening = configMenu.hidden;
|
||
if (opening && configDirty) renderConfig();
|
||
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<AvailableCommand>;
|
||
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 (attachment.path && attachment.file.type.startsWith("image/")) {
|
||
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
|
||
}
|
||
}
|
||
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();
|
||
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
|
||
imageObjectUrls.clear();
|
||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
||
scrollEl.removeEventListener("touchstart", stopSmoothFollow);
|
||
document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
|
||
el.remove();
|
||
},
|
||
};
|
||
}
|