import React, { useState } from "react"; import SandboxCanvas from "./SandboxCanvas.jsx"; import ExecutionPlanCard, { fetchExecutionPlan } from "./ExecutionPlanCard.jsx"; // Languages the Run button supports. Anything not in this set still // renders as a normal code block (no button) — keeps the visual contract // honest: if there's a button, the snippet really is executable. const RUNNABLE = new Set([ "python", "py", "javascript", "js", "node", "bash", "sh", "shell", ]); // Friendly badge text per backend, surfaced so the user always knows // which sandbox actually ran their code. Mirrors the labels in // SettingsModal so the two views agree. const BACKEND_LABELS = { subprocess: "Local", matrixlab: "MatrixLab", off: "Pass-through", }; // Map "py" → "python" etc. so the badge always shows the canonical // language name rather than whatever alias the LLM tagged the fence // with. const LANG_DISPLAY = { py: "python", js: "javascript", node: "javascript", sh: "bash", shell: "bash", }; // Default file extension per language — used when the user clicks // "Save to repo" and we need to seed a plausible filename. const LANG_EXT = { python: "py", py: "py", javascript: "js", js: "js", node: "js", bash: "sh", sh: "sh", shell: "sh", }; // Mirror executor's _looks_like_matplotlib heuristic so plt.show() // snippets don't hang the headless sandbox. False positives are // harmless (Agg is a valid backend for any Python script). function looksLikeMatplotlib(code) { if (!code) return false; const lower = code.toLowerCase(); return /import\s+matplotlib|from\s+matplotlib|plt\.show|pyplot/.test(lower); } function applyMatplotlibShim(language, code) { if (language !== "python" && language !== "py") return code; if (!looksLikeMatplotlib(code)) return code; return ( "import os as _gp_os\n" + '_gp_os.environ.setdefault("MPLBACKEND", "Agg")\n' + code ); } /** A single fenced code block with a per-block Run button. * * Optional ``owner``/``repo`` props enable the "Save to repo" button by * giving the save call a real target. When absent the button is * hidden — keeps the contract honest: no button without somewhere to * save. */ export default function RunnableCodeBlock({ language, code, owner, repo }) { const lang = (language || "").trim().toLowerCase(); const canRun = RUNNABLE.has(lang); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [canvasOpen, setCanvasOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); // Approval-first: clicking ▶ Run first fetches a deterministic // ExecutionPlan and surfaces it inline. The actual sandbox call // is gated on the user clicking "Run in Sandbox" inside the plan. const [pendingPlan, setPendingPlan] = useState(null); const [planError, setPlanError] = useState(null); const display = LANG_DISPLAY[lang] || lang || "text"; const onRunClick = async () => { setPlanError(null); setResult(null); setError(null); setBusy(true); try { const shipped = applyMatplotlibShim(lang, code); const plan = await fetchExecutionPlan({ code: shipped, language: lang, source: "code_block", }); setPendingPlan(plan); } catch (err) { setPlanError(err.message || "Could not build execution plan"); } finally { setBusy(false); } }; const onApprovePlan = async (plan) => { setBusy(true); setResult(null); setError(null); try { const res = await fetch("/api/sandbox/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language: plan.language, code: plan.inline_code, timeout_sec: plan.timeout_sec, }), }); const data = await res.json(); if (!res.ok) { setError(data.detail || `HTTP ${res.status}`); return; } setResult(data); setPendingPlan(null); } catch (err) { setError(err.message || "Run failed"); } finally { setBusy(false); } }; const onCancelPlan = () => { setPendingPlan(null); setPlanError(null); }; const copy = () => { if (navigator?.clipboard) navigator.clipboard.writeText(code).catch(() => {}); }; // POST the snippet to /api/repos/{owner}/{repo}/file with a path // chosen by the user. Pure client-side prompt — no new backend // wiring needed because the endpoint already exists. const saveToRepo = async (snippet, snippetLang) => { if (!owner || !repo) { setSaveMsg("No repository context — open this chat inside a repo to save."); return; } const ext = LANG_EXT[(snippetLang || lang).toLowerCase()] || "txt"; const suggested = `snippets/inline.${ext}`; const path = window.prompt("Save snippet to path (inside repo):", suggested); if (!path) return; setSaving(true); setSaveMsg(null); try { const res = await fetch(`/api/repos/${owner}/${repo}/file`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, content: snippet, message: `Save snippet from chat (${path})`, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setSaveMsg(data.detail || `Save failed (HTTP ${res.status})`); return; } setSaveMsg(`Saved to ${path}`); } catch (err) { setSaveMsg(err.message || "Save failed"); } finally { setSaving(false); } }; return (
{display}
{canRun && ( )} {canRun && owner && repo && ( )} {canRun && ( )}
{code}
{pendingPlan && ( )} {planError && (
Plan error: {planError}
)} {(result || error) && (
Output {result && ( exit {result.exit_code} {BACKEND_LABELS[result.backend] || result.backend} {typeof result.duration_ms === "number" && ( {result.duration_ms} ms )} {result.timed_out && timed out} {result.truncated && truncated} )}
{error &&
{error}
} {result?.stdout &&
{result.stdout}
} {result?.stderr &&
{result.stderr}
} {Array.isArray(result?.artifacts) && result.artifacts.length > 0 && (
Artifacts ({result.artifacts.length})
    {result.artifacts.map((a, i) => (
  • {a.name || a.id} {a.size && · {a.size} bytes} {a.mime && · {a.mime}}
  • ))}
)} {result && !result.stdout && !result.stderr && (
(no output)
)}
)} {saveMsg &&
{saveMsg}
} {canvasOpen && ( setCanvasOpen(false)} onSaveAsFile={owner && repo ? saveToRepo : undefined} /> )}
); } /** Split a markdown-ish string into text and fenced-code segments. * * Returned shape: ``[{type: 'text', value} | {type: 'code', language, code}]``. * * Kept deliberately small — full markdown rendering is out of scope; this * only needs to recognise ```lang fences so the Run button can attach to * code blocks the model emits. */ export function splitFences(input) { if (!input) return []; const out = []; const re = /```([a-zA-Z0-9_+-]*)\s*\n([\s\S]*?)```/g; let last = 0; let m; while ((m = re.exec(input)) !== null) { if (m.index > last) { out.push({ type: "text", value: input.slice(last, m.index) }); } out.push({ type: "code", language: m[1] || "", code: m[2].replace(/\s+$/, "") }); last = m.index + m[0].length; } if (last < input.length) { out.push({ type: "text", value: input.slice(last) }); } return out; } const styles = { wrap: { margin: "8px 0", background: "#09090B", border: "1px solid #27272A", borderRadius: 8, overflow: "hidden", fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', }, head: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 12px", background: "#18181B", borderBottom: "1px solid #27272A", fontSize: 11, }, headRight: { display: "flex", gap: 6, alignItems: "center" }, lang: { color: "#A1A1AA", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", fontSize: 10, }, iconBtn: { background: "transparent", color: "#A1A1AA", border: "1px solid #3F3F46", borderRadius: 4, padding: "2px 8px", fontSize: 11, cursor: "pointer", }, runBtn: { background: "#10B981", color: "#052e1c", border: "0", borderRadius: 4, padding: "2px 10px", fontSize: 11, fontWeight: 600, cursor: "pointer", }, code: { margin: 0, padding: "12px 14px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontSize: 12.5, lineHeight: 1.55, color: "#E4E4E7", whiteSpace: "pre-wrap", wordBreak: "break-word", overflowX: "auto", }, output: { background: "#0c0c10", borderTop: "1px solid #27272A", padding: "8px 14px 10px", }, outputHead: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6, }, outputLabel: { fontSize: 10, fontWeight: 600, color: "#A1A1AA", textTransform: "uppercase", letterSpacing: "0.05em", }, metaRow: { display: "flex", gap: 6, alignItems: "center" }, okPill: { fontSize: 10, fontWeight: 600, padding: "1px 6px", borderRadius: 9, background: "rgba(16, 185, 129, 0.12)", color: "#10B981", border: "1px solid rgba(16, 185, 129, 0.35)", }, failPill: { fontSize: 10, fontWeight: 600, padding: "1px 6px", borderRadius: 9, background: "rgba(239, 68, 68, 0.12)", color: "#ef4444", border: "1px solid rgba(239, 68, 68, 0.35)", }, warnPill: { fontSize: 10, fontWeight: 600, padding: "1px 6px", borderRadius: 9, background: "rgba(217, 119, 6, 0.12)", color: "#f59e0b", border: "1px solid rgba(217, 119, 6, 0.35)", }, backendPill: { fontSize: 10, fontWeight: 600, padding: "1px 6px", borderRadius: 9, background: "rgba(79, 70, 229, 0.12)", color: "#a5b4fc", border: "1px solid rgba(79, 70, 229, 0.35)", }, dim: { color: "#71717A", fontSize: 11 }, stdout: { margin: "4px 0 0", padding: "6px 8px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: 12, color: "#D4D4D8", background: "#000", borderRadius: 4, whiteSpace: "pre-wrap", wordBreak: "break-word", }, stderr: { margin: "4px 0 0", padding: "6px 8px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: 12, color: "#fca5a5", background: "#0a0000", borderRadius: 4, whiteSpace: "pre-wrap", wordBreak: "break-word", }, artifactsBox: { marginTop: 8, padding: "6px 8px", background: "#0a0a0f", border: "1px solid #27272A", borderRadius: 4, }, artifactList: { listStyle: "none", padding: 0, margin: "4px 0 0", }, artifactItem: { padding: "2px 0", fontSize: 12, color: "#D4D4D8", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", }, saveBanner: { margin: "0", padding: "6px 12px", fontSize: 11, color: "#A1A1AA", background: "#0c0c10", borderTop: "1px solid #27272A", }, errorBanner: { margin: "6px 0", padding: "8px 10px", fontSize: 12, color: "#fca5a5", background: "#3d1111", border: "1px solid #7f1d1d", borderRadius: 6, }, };