# Conflicts: # crates/maestro-daemon/tests/worktree.rs # crates/maestro-e2e/tests/e2e.rs # web/src/attach.ts # web/src/connections.test.ts # web/src/connections.ts
1015 lines
38 KiB
TypeScript
1015 lines
38 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,
|
|
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>
|
|
<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>
|
|
<form class="structured-composer">
|
|
<div class="structured-input-wrap">
|
|
<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 structured-cancel" hidden>Cancel turn</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 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>();
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
|
|
const groups = new Map<string, TranscriptRow[]>();
|
|
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 id = `turn:${turnId}`;
|
|
const group = groups.get(id) ?? [];
|
|
group.push(row);
|
|
groups.set(id, group);
|
|
}
|
|
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 = toolGroupsByTurn();
|
|
const groupedRowIds = new Set<string>();
|
|
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
|
|
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 groupRows = groups.get(groupId) ?? [];
|
|
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
|
|
if (groupedRowIds.has(row.id)) continue;
|
|
const toolEls = groupRows.flatMap((toolRow) => {
|
|
groupedRowIds.add(toolRow.id);
|
|
const toolEl = buildRowElement(toolRow);
|
|
if (!toolEl) return [];
|
|
rowEls.set(toolRow.id, toolEl);
|
|
return [toolEl];
|
|
});
|
|
const groupEl = buildToolGroup(
|
|
groupId,
|
|
groupRows,
|
|
toolEls,
|
|
expandedGroupIds.has(groupId),
|
|
(expanded) => {
|
|
if (expanded) expandedGroupIds.add(groupId);
|
|
else expandedGroupIds.delete(groupId);
|
|
settleDisclosureLayout();
|
|
},
|
|
);
|
|
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
|
|
groupEl.classList.add("st-row-enter");
|
|
}
|
|
groupEls.set(groupId, groupEl);
|
|
children.push(groupEl);
|
|
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;
|
|
}
|
|
} 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();
|
|
}
|
|
}
|
|
|
|
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 active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
|
const projectedActive = state.turn.responding || state.turn.thinking;
|
|
input.disabled = active;
|
|
sendBtn.disabled = active;
|
|
sendBtn.hidden = projectedActive;
|
|
cancelBtn.hidden = !projectedActive;
|
|
if (active) closeCommandMenu();
|
|
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();
|
|
}
|
|
|
|
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<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);
|
|
});
|
|
composerForm.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const text = input.value;
|
|
if (!text.trim() || input.disabled) return;
|
|
const generation = ++submissionGeneration;
|
|
const submissionSocketGeneration = socketGeneration;
|
|
awaitingBeginGeneration = generation;
|
|
optimisticTurnStartedAt = Date.now();
|
|
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));
|
|
});
|
|
});
|
|
|
|
// ---- 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();
|
|
},
|
|
};
|
|
}
|