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>
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
// An `Approval` row. The daemon exposes exactly three decisions
|
|
// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
|
|
// them. A decision the request offers no matching ACP option for is not shown
|
|
// — its button would "allow" something that silently resolves `Cancelled`.
|
|
|
|
import { useState } from "react";
|
|
|
|
import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js";
|
|
import { offeredDecisions, toolKindLabel } from "./model.js";
|
|
|
|
const SPECS: [StructuredApprovalDecision, string, string][] = [
|
|
["allow_once", "Allow once", "primary"],
|
|
["allow_for_session", "Allow for session", ""],
|
|
["deny", "Deny", "deny"],
|
|
];
|
|
|
|
export interface ApprovalViewProps {
|
|
/** The `RequestPermissionRequest` the row carries — serialized without its
|
|
* nonce, which never leaves the daemon's live wire. */
|
|
request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
|
/** Whether this is the one live approval. A restored/answered row is not. */
|
|
pending: boolean;
|
|
/** Resolves once the decision reached the daemon; rejects only while this
|
|
* approval is still the pending one, so the buttons can come back. */
|
|
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
|
|
}
|
|
|
|
export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) {
|
|
const [busy, setBusy] = useState(false);
|
|
const toolCall = request.toolCall ?? {};
|
|
const what = toolCall.title ?? toolKindLabel(toolCall.kind);
|
|
|
|
// Resolved approvals render as a compact one-liner inside a tool group (or
|
|
// are dropped entirely when standalone — see model.ts's transcriptItems).
|
|
if (!pending) {
|
|
return <div className="st-row st-row-approval st-approval-compact">{`${what} — approved`}</div>;
|
|
}
|
|
|
|
const offered = offeredDecisions(request.options ?? []);
|
|
const path = toolCall.locations?.[0]?.path;
|
|
|
|
const decide = (decision: StructuredApprovalDecision) => {
|
|
setBusy(true);
|
|
onDecide(decision).catch(() => {
|
|
// Rejects only while this approval is still pending — restore the
|
|
// controls so the operator can retry. The error itself is surfaced by
|
|
// the pane's banner.
|
|
setBusy(false);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="st-row st-row-approval">
|
|
<div className="st-approval">
|
|
<div className="st-approval-title">
|
|
<span aria-hidden="true">⚠</span> permission requested
|
|
</div>
|
|
<div className="st-approval-what">{what}</div>
|
|
{path && <div className="st-approval-path">{path}</div>}
|
|
{offered.length > 0 ? (
|
|
<div className="st-approval-acts">
|
|
{SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => (
|
|
<button
|
|
key={decision}
|
|
type="button"
|
|
className={cls ? `act ${cls}` : "act"}
|
|
disabled={busy}
|
|
onClick={() => decide(decision)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="st-approval-resolved">no decision offered</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|