The collapsed tool group now reads "N tool calls · Label 'arg'" so the operator sees the current command at a glance. While a turn is active, the most recent agent text stays outside the group as visible narration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
// 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 "./render.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}
|
|
lastToolLabel={item.group.lastToolLabel}
|
|
lastToolArg={item.group.lastToolArg}
|
|
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>
|
|
);
|
|
});
|