feat(maestro): port the structured pane to React
Closes #246. The transcript, composer and approval controls move from structured.ts/structuredRender.ts into components under web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row kinds, both projections, the single-active approval nonce, and diff rendering with its own line counts. The pure layers stay shared — structuredDiff.ts computes the diff, structuredRender.ts still sanitizes markdown — and model.ts holds the client-side decisions (activity grouping, notice classification, offered decisions, `/` completion) so they are testable without a DOM. Like TerminalPane, the pane draws the body only; pane chrome reads the connection through onStatusChange. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0a1774e4d
commit
9ba1078386
@@ -0,0 +1,400 @@
|
||||
// React port of structured.ts (#246): a live structured session — the
|
||||
// transcript from `WS /api/structured/:id`, the composer that drives
|
||||
// `.../prompt` and `.../cancel`, and the approval controls that answer
|
||||
// `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
|
||||
//
|
||||
// Like TerminalPane, the pane draws the body only: pane chrome (title,
|
||||
// disconnect, sessions drawer) belongs to whatever composes it, which reads
|
||||
// the connection state through `onStatusChange`.
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
|
||||
import type {
|
||||
AcpSessionConfigOption,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow as Row,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
import { Composer } from "./Composer.js";
|
||||
import {
|
||||
commandsFromTranscript,
|
||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||
transcriptItems,
|
||||
turnIsActive,
|
||||
} from "./model.js";
|
||||
import { ToolGroup } from "./ToolGroup.js";
|
||||
import { TranscriptRow } from "./TranscriptRow.js";
|
||||
import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js";
|
||||
import "./structured.css";
|
||||
|
||||
/** Within this much of the bottom, wheel and touch imprecision should still
|
||||
* count as following. */
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
interface QueuedPrompt {
|
||||
text: string;
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
export interface StructuredPaneProps {
|
||||
sessionId: Uuid;
|
||||
/** Grabs the composer once mounted. A sidebar preview passes `false`. */
|
||||
focus?: boolean;
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
onClosed?: () => void;
|
||||
onStatusChange?: (status: StructuredStatus, detail?: string) => void;
|
||||
/** Test seam: drive the socket without a daemon. */
|
||||
socketFactory?: StructuredSocketFactory;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface StructuredPaneHandle {
|
||||
/** Put the keyboard in the composer — the structured pane's terminal. */
|
||||
focusComposer(): void;
|
||||
}
|
||||
|
||||
export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPaneProps>(function StructuredPane(
|
||||
{ sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className },
|
||||
ref,
|
||||
) {
|
||||
const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory });
|
||||
const { rows, state, status, detail, socketGeneration } = session;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<HTMLDivElement | null>(null);
|
||||
const followingBottom = useRef(true);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<ReadonlySet<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [configBusy, setConfigBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
/** Server path → object URL, so a user row can preview an image it uploaded. */
|
||||
const imageUrls = useRef(new Map<string, string>());
|
||||
|
||||
// Submission bookkeeping, ported from structured.ts: a prompt is "sending"
|
||||
// from the POST until the projection shows the turn it claimed, and an
|
||||
// acceptance that straddles a reconnect forces one more reconnect so the
|
||||
// snapshot cannot predate the 204.
|
||||
const submission = useRef(0);
|
||||
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
|
||||
const optimisticStart = useRef<number | null>(null);
|
||||
const promptPending = useRef(false);
|
||||
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusComposer: () => composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.(status, detail ?? undefined);
|
||||
}, [status, detail, onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus) return;
|
||||
composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus();
|
||||
}, [focus]);
|
||||
|
||||
const urls = imageUrls.current;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const url of urls.values()) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
},
|
||||
[urls],
|
||||
);
|
||||
|
||||
const turnActive = turnIsActive(state);
|
||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||
const items = useMemo(
|
||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
||||
[rows, pendingRowId, turnActive],
|
||||
);
|
||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||
|
||||
const active = turnActive || awaitingBegin !== null;
|
||||
|
||||
// Stop saying "Sending" once the turn the submission was waiting for has
|
||||
// begun — or, when it was accepted but a replacement socket has
|
||||
// re-snapshotted since, once that newer snapshot has landed. Without the
|
||||
// second case a prompt accepted across a reconnect would say "Sending"
|
||||
// forever.
|
||||
useEffect(() => {
|
||||
if (turnActive) {
|
||||
setAwaitingBegin(null);
|
||||
} else if (
|
||||
awaitingBegin !== null &&
|
||||
accepted.current?.generation === awaitingBegin &&
|
||||
socketGeneration > accepted.current.socketGeneration
|
||||
) {
|
||||
setAwaitingBegin(null);
|
||||
}
|
||||
}, [turnActive, awaitingBegin, socketGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) optimisticStart.current = null;
|
||||
}, [active]);
|
||||
|
||||
// Keep the pane pinned to the newest row unless the operator scrolled away.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !followingBottom.current) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
}, [items, active, queue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||
};
|
||||
|
||||
const decide = useCallback(
|
||||
async (rowId: string, decision: StructuredApprovalDecision): Promise<void> => {
|
||||
const nonce = state.pending_approval?.nonce;
|
||||
// Already resolved elsewhere between the click and here. A stale nonce
|
||||
// is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers
|
||||
// 204), and the row's buttons are already gone — resolve quietly.
|
||||
if (!nonce || rowId !== pendingRowId) return;
|
||||
try {
|
||||
await resolveStructuredApproval(sessionId, nonce, decision);
|
||||
} catch (e: unknown) {
|
||||
// Propagate — so the row re-enables its buttons — only while this is
|
||||
// still the pending approval. If it moved on in flight, reviving the
|
||||
// buttons would revive a decision that no longer applies.
|
||||
setError(String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||
);
|
||||
|
||||
const dispatchPrompt = useCallback(
|
||||
(text: string, files: File[]) => {
|
||||
const generation = ++submission.current;
|
||||
const submissionSocket = socketGeneration;
|
||||
setAwaitingBegin(generation);
|
||||
optimisticStart.current = Date.now();
|
||||
accepted.current = null;
|
||||
promptPending.current = true;
|
||||
setUploading(files.length > 0);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
|
||||
const send = async () => {
|
||||
const references: string[] = [];
|
||||
for (const file of files) {
|
||||
const { path } = await uploadStructuredFile(sessionId, file);
|
||||
if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file));
|
||||
references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
|
||||
}
|
||||
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
|
||||
};
|
||||
|
||||
void send().then(
|
||||
() => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setUploading(false);
|
||||
setAttachments((current) => (current === files ? [] : current));
|
||||
accepted.current = { generation, socketGeneration };
|
||||
// A replacement socket may have captured idle before the POST
|
||||
// claimed the prompt. Reconnect so its snapshot cannot predate the
|
||||
// 204.
|
||||
if (socketGeneration > submissionSocket) session.reconnect();
|
||||
},
|
||||
(e: unknown) => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setAwaitingBegin((current) => (current === generation ? null : current));
|
||||
optimisticStart.current = null;
|
||||
setUploading(false);
|
||||
// The draft and its attachments are the operator's work — a failed
|
||||
// send hands both back rather than eating them.
|
||||
setAttachments((current) => (current.length === 0 ? files : current));
|
||||
setDraft((current) => (current === "" ? text : current));
|
||||
setError(String(e));
|
||||
},
|
||||
);
|
||||
},
|
||||
[sessionId, socketGeneration, session],
|
||||
);
|
||||
|
||||
// A queued message goes out as soon as the projected turn is idle — the
|
||||
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
|
||||
// client-local (docs/STRUCTURED-VIEW.md).
|
||||
useEffect(() => {
|
||||
if (active || promptPending.current || queue.length === 0) return;
|
||||
const [next, ...rest] = queue;
|
||||
setQueue(rest);
|
||||
dispatchPrompt(next.text, next.attachments);
|
||||
}, [active, queue, dispatchPrompt]);
|
||||
|
||||
const submit = (text: string, files: File[]) => {
|
||||
if (active || queue.length > 0) {
|
||||
setQueue((current) => [...current, { text, attachments: files }]);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
dispatchPrompt(text, files);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setError(null);
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e)));
|
||||
};
|
||||
|
||||
const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => {
|
||||
setConfigBusy(true);
|
||||
setError(null);
|
||||
setStructuredConfig(sessionId, { config_id: option.id, value })
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : "Could not change the session setting"),
|
||||
)
|
||||
.finally(() => setConfigBusy(false));
|
||||
};
|
||||
|
||||
const elapsedMs = () => {
|
||||
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
|
||||
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current;
|
||||
return startedAt === null ? 0 : Math.max(0, now - startedAt);
|
||||
};
|
||||
|
||||
const activityLabel = () => {
|
||||
const tools = state.turn.active_tool_calls.length;
|
||||
const label = awaitingBegin !== null
|
||||
? "Sending"
|
||||
: state.turn.thinking
|
||||
? "Thinking"
|
||||
: tools > 0
|
||||
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
|
||||
: "Responding";
|
||||
const parts = [label, formatElapsed(elapsedMs())];
|
||||
if (typeof state.usage?.used === "number") {
|
||||
const usage = formatContextTokens(state.usage.used);
|
||||
parts.push(
|
||||
typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage,
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
const setRowExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedRows((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const setGroupExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const renderRow = (row: Row) => (
|
||||
<TranscriptRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pendingApproval={row.id === pendingRowId}
|
||||
onDecide={(decision) => decide(row.id, decision)}
|
||||
expanded={expandedRows.has(row.id)}
|
||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||
imageUrls={imageUrls.current}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className ? `structured-pane ${className}` : "structured-pane"}>
|
||||
<div className="structured-body">
|
||||
<div className="structured-main">
|
||||
<div className="structured-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="structured-rows">
|
||||
{items.map((item) =>
|
||||
item.type === "row" ? (
|
||||
renderRow(item.row)
|
||||
) : (
|
||||
<ToolGroup
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
toolCount={item.group.toolCount}
|
||||
expanded={expandedGroups.has(item.id)}
|
||||
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
||||
>
|
||||
{item.group.rows.map(renderRow)}
|
||||
</ToolGroup>
|
||||
),
|
||||
)}
|
||||
{queue.map((queued, index) => (
|
||||
<div key={`queued-${index}`} className="st-row st-row-queued">
|
||||
<div className="st-body">
|
||||
<div className="st-user-text">{queued.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="structured-activity" role="status" aria-live="polite" hidden={!active}>
|
||||
<span className="structured-activity-pulse" aria-hidden="true" />
|
||||
<span className="structured-activity-text">{active ? activityLabel() : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error !== null && <div className="structured-error">{error}</div>}
|
||||
<div ref={composerRef}>
|
||||
<Composer
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
commands={commands}
|
||||
configOptions={configOptions}
|
||||
turnActive={active}
|
||||
uploading={uploading}
|
||||
configBusy={configBusy}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onCancel={cancel}
|
||||
onConfigChange={changeConfig}
|
||||
onError={setError}
|
||||
menuId={`structured-command-menu-${sessionId}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user