// frontend/components/ExecutionPlanCard.jsx // // The approval-first surface for sandbox runs. Renders a // deterministic ExecutionPlan returned by POST /api/sandbox/plan // and gates the actual run on an explicit user click. // // Two visual variants, same component: // // variant = "full" — used in chat for file-run and chat-command // plans. Big card, every safety check / warning // visible, primary CTA "Run in Sandbox". // variant = "compact" — used as a popover above a code-block ▶ click. // Same info, less chrome. // // The component is stateless about the run itself: it produces an // approval event (onApprove with the plan object) and lets the // parent decide how to execute. This keeps the streaming/state // machine work for Batch 3 — here we only render and consent. import React from "react"; import { apiUrl } from "../utils/api.js"; const SEVERITY_STYLE = { high: { bg: "#3d1111", fg: "#fca5a5", border: "#7f1d1d", icon: "⛔" }, medium: { bg: "#3d2d11", fg: "#fde68a", border: "#854d0e", icon: "⚠" }, low: { bg: "#1f2937", fg: "#a5b4fc", border: "#3730a3", icon: "ⓘ" }, }; const BACKEND_LABELS = { subprocess: "Local", matrixlab: "MatrixLab", off: "Pass-through", }; export default function ExecutionPlanCard({ plan, variant = "full", busy = false, onApprove, onCancel, onOpenFile, }) { if (!plan) return null; const isCompact = variant === "compact"; const styles = isCompact ? compactStyles : fullStyles; const commandStr = Array.isArray(plan.command) ? plan.command.join(" ") : String(plan.command || ""); return (
EXECUTION PLAN

{plan.goal || "Run in sandbox"}

{plan.file && ( {plan.file}} /> )} {commandStr}} /> {plan.workdir && plan.workdir !== "." && ( {plan.workdir}} /> )}
{/* Safety checks — always green, no opinion */} {Array.isArray(plan.safety?.checks) && plan.safety.checks.length > 0 && ( )} {/* Warnings — non-blocking, sorted high → low by the backend */} {Array.isArray(plan.safety?.warnings) && plan.safety.warnings.length > 0 && ( )} {plan.inline_code && (
Snippet to run ({plan.inline_code.length} chars)
{plan.inline_code}
)}
); } function Field({ label, value }) { return ( <>
{label}
{value}
); } // --------------------------------------------------------------------------- // Approve helper — single source of truth so chat, codeblock, canvas // all build the plan the same way. Returns the plan object or throws. // --------------------------------------------------------------------------- export async function fetchExecutionPlan(payload) { const res = await fetch(apiUrl("/api/sandbox/plan"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.detail || `Plan failed (HTTP ${res.status})`); } return data.plan; } // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const dtStyle = { fontSize: 11, color: "#9092b5", textTransform: "uppercase", letterSpacing: "0.05em", margin: 0, }; const ddStyle = { margin: "0 0 6px", fontSize: 13, color: "#e4e4e7" }; const fullStyles = { card: { margin: "8px 0", background: "#0d1117", border: "1px solid #1f2937", borderLeft: "3px solid #10B981", borderRadius: 10, padding: 16, color: "#e4e4e7", fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', }, header: { display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }, badge: { fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", padding: "2px 8px", borderRadius: 4, background: "#0d3320", color: "#86efac", textTransform: "uppercase", }, title: { margin: 0, fontSize: 15, fontWeight: 600 }, fields: { display: "grid", gridTemplateColumns: "120px 1fr", rowGap: 4, columnGap: 12, margin: "10px 0", }, checks: { listStyle: "none", padding: 0, margin: "8px 0", display: "flex", flexWrap: "wrap", gap: 6, }, check: { fontSize: 11, color: "#86efac", background: "rgba(16,185,129,0.08)", border: "1px solid rgba(16,185,129,0.25)", borderRadius: 4, padding: "2px 8px", }, checkIcon: { marginRight: 4 }, warnings: { listStyle: "none", padding: 0, margin: "10px 0 4px" }, warning: { fontSize: 12, padding: "6px 10px", borderRadius: 4, border: "1px solid", marginBottom: 4, display: "flex", gap: 8, alignItems: "flex-start", }, warnIcon: { fontSize: 14, lineHeight: 1 }, warnDetail: { opacity: 0.8 }, snippetWrap: { margin: "8px 0", border: "1px solid #1f2937", borderRadius: 6, padding: "6px 10px", }, snippetLabel: { fontSize: 11, color: "#9092b5", cursor: "pointer" }, snippet: { margin: "6px 0 0", padding: 8, fontSize: 12, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", background: "#000", color: "#d4d4d8", borderRadius: 4, maxHeight: 240, overflow: "auto", whiteSpace: "pre-wrap", }, footer: { display: "flex", gap: 8, marginTop: 12 }, primary: { background: "#10B981", color: "#052e1c", border: "0", borderRadius: 6, padding: "8px 16px", fontSize: 13, fontWeight: 600, cursor: "pointer", }, secondary: { background: "transparent", color: "#a1a1aa", border: "1px solid #3F3F46", borderRadius: 6, padding: "7px 14px", fontSize: 13, cursor: "pointer", }, tertiary: { background: "transparent", color: "#71717a", border: "1px solid #27272a", borderRadius: 6, padding: "7px 14px", fontSize: 13, cursor: "pointer", marginLeft: "auto", }, }; // Compact variant — same structure, denser padding, no inline snippet // expander. Used for code-block Run confirmation popovers. const compactStyles = { ...fullStyles, card: { ...fullStyles.card, padding: 10, margin: "6px 0", borderRadius: 8, }, title: { margin: 0, fontSize: 13, fontWeight: 600 }, fields: { ...fullStyles.fields, gridTemplateColumns: "90px 1fr", rowGap: 2, margin: "6px 0", }, primary: { ...fullStyles.primary, padding: "6px 12px", fontSize: 12 }, secondary: { ...fullStyles.secondary, padding: "5px 10px", fontSize: 12 }, tertiary: { ...fullStyles.tertiary, padding: "5px 10px", fontSize: 12 }, };