Files
NexusOS/interface/web/src/Playbook.jsx
T
AthenaandCursor 656c14caf3 feat(preview): add sandboxed live code previews
Render validated HTML, SVG, JSX, and TSX fences locally while preserving tool context and preventing explanatory JSON from triggering actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 03:34:19 -05:00

425 lines
16 KiB
React

import { useEffect, useState, useCallback, useRef } from "react";
import { API_BASE } from "./config";
export function Playbook() {
const [playbooks, setPlaybooks] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [form, setForm] = useState({ title: "", goal: "", instructions: "", tags: "" });
const [search, setSearch] = useState("");
const [message, setMessage] = useState("");
const dragItem = useRef(null);
const dragOverItem = useRef(null);
const normalizePlaybooks = (data) => {
if (!data) return [];
if (Array.isArray(data)) return data;
if (Array.isArray(data.playbooks)) return data.playbooks;
if (typeof data === "object") return Object.values(data);
return [];
};
const loadPlaybooks = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/playbooks`);
if (!res.ok) { setPlaybooks([]); return; }
const data = await res.json().catch(() => null);
setPlaybooks(normalizePlaybooks(data));
} catch {
setPlaybooks([]);
}
}, []);
useEffect(() => { loadPlaybooks(); }, [loadPlaybooks]);
const safeString = (v) => (v == null ? "" : String(v));
const filteredPlaybooks = Array.isArray(playbooks)
? playbooks.filter(p => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
safeString(p.title).toLowerCase().includes(q) ||
safeString(p.goal).toLowerCase().includes(q) ||
(Array.isArray(p.tags) ? p.tags : []).some(t => safeString(t).toLowerCase().includes(q))
);
})
: [];
const tryFetchWithTrailingSlash = async (url, opts) => {
let res = await fetch(url, opts);
if (res.status === 404 && !url.endsWith("/")) res = await fetch(url + "/", opts);
return res;
};
const selectPlaybook = async (playbookSummary) => {
const id = encodeURIComponent(String(playbookSummary.id));
setMessage("");
setSelectedId(playbookSummary.id);
try {
const res = await tryFetchWithTrailingSlash(`${API_BASE}/playbooks/${id}`, { method: "GET" });
const full = res.ok ? await res.json() : playbookSummary;
setForm({
title: full.title || "",
goal: full.goal || "",
instructions: full.instructions || "",
tags: (full.tags || []).join(", "),
tools: (full.tools || []).join(", "),
model: full.model || "",
});
} catch {
setForm({
title: playbookSummary.title || "",
goal: playbookSummary.goal || "",
instructions: playbookSummary.instructions || "",
tags: (playbookSummary.tags || []).join(", "),
tools: (playbookSummary.tools || []).join(", "),
model: playbookSummary.model || "",
});
}
};
const resetForm = () => {
setSelectedId(null);
setForm({ title: "", goal: "", instructions: "", tags: "", tools: "", model: "" });
setMessage("");
};
const savePlaybook = async () => {
if (!form.title || !form.goal || !form.instructions) {
setMessage("Please fill in title, goal, and instructions.");
return;
}
const payload = {
title: form.title,
goal: form.goal,
instructions: form.instructions,
tags: form.tags.split(",").map(t => t.trim()).filter(Boolean),
tools: (form.tools || "").split(",").map(t => t.trim()).filter(Boolean),
model: (form.model || "").trim(),
};
const isUpdate = Boolean(selectedId);
const safeId = isUpdate ? encodeURIComponent(String(selectedId)) : null;
const baseUrl = isUpdate ? `${API_BASE}/playbooks/${safeId}` : `${API_BASE}/playbooks`;
const commonOpts = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) };
try {
let res;
if (isUpdate) {
res = await tryFetchWithTrailingSlash(baseUrl, { ...commonOpts, method: "PATCH" });
if (res.status === 404 || res.status === 405) {
const getRes = await tryFetchWithTrailingSlash(baseUrl, { method: "GET" });
const existing = getRes.ok ? await getRes.json().catch(() => ({})) : {};
res = await tryFetchWithTrailingSlash(baseUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...existing, ...payload }),
});
}
} else {
res = await tryFetchWithTrailingSlash(baseUrl, { ...commonOpts, method: "POST" });
}
if (!res.ok) {
const text = await res.text().catch(() => "");
setMessage(`Save failed: ${text || res.statusText}`);
return;
}
setMessage(isUpdate ? "Playbook updated." : "Playbook created.");
resetForm();
loadPlaybooks();
} catch (err) {
setMessage(`Save error: ${err.message || err}`);
}
};
const deletePlaybook = async (id) => {
if (!window.confirm("Delete this playbook?")) return;
const safeId = encodeURIComponent(String(id));
try {
const res = await tryFetchWithTrailingSlash(`${API_BASE}/playbooks/${safeId}`, { method: "DELETE" });
if (!res.ok) {
const text = await res.text().catch(() => "");
setMessage(`Delete failed: ${text || res.statusText}`);
return;
}
setMessage("Playbook deleted.");
if (selectedId === id) resetForm();
loadPlaybooks();
} catch (err) {
setMessage(`Delete error: ${err.message || err}`);
}
};
const onDragStart = (e, id) => {
dragItem.current = String(id);
e.dataTransfer.effectAllowed = "move";
};
const onDragEnter = (e, id) => {
dragOverItem.current = String(id);
e.preventDefault();
};
const onDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const onDrop = async () => {
if (!dragItem.current || dragItem.current === dragOverItem.current) return;
const currentOrder = filteredPlaybooks.map(p => String(p.id));
const fromIdx = currentOrder.indexOf(dragItem.current);
const toIdx = currentOrder.indexOf(dragOverItem.current);
if (fromIdx === -1 || toIdx === -1) return;
// Guard: only a playbook with "main" in its title can occupy the first slot
if (toIdx === 0 && fromIdx !== 0) {
const dragged = filteredPlaybooks.find(p => String(p.id) === dragItem.current);
if (!dragged || !dragged.title.toLowerCase().includes("main")) {
setMessage("Only a playbook with 'main' in its title can be placed first.");
dragItem.current = null;
dragOverItem.current = null;
return;
}
}
const newFilteredOrder = [...currentOrder];
newFilteredOrder.splice(fromIdx, 1);
newFilteredOrder.splice(toIdx, 0, dragItem.current);
// Optimistic UI update — reorder within filtered set, keep unfiltered playbooks intact
setPlaybooks(prev => {
const filteredSet = new Set(currentOrder);
const filteredMap = Object.fromEntries(prev.filter(p => filteredSet.has(String(p.id))).map(p => [String(p.id), p]));
const reorderedFiltered = newFilteredOrder.map(id => filteredMap[id]).filter(Boolean);
// Merge: place unfiltered items back in their original relative positions
const result = [];
let fi = 0;
for (const p of prev) {
if (filteredSet.has(String(p.id))) {
if (fi < reorderedFiltered.length) result.push(reorderedFiltered[fi++]);
} else {
result.push(p);
}
}
return result;
});
dragItem.current = null;
dragOverItem.current = null;
// Send the full playbook order to the backend (filtered reorder merged with all IDs)
try {
await fetch(`${API_BASE}/playbooks/reorder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: newFilteredOrder }),
});
} catch (err) {
console.error("Failed to persist order:", err);
loadPlaybooks();
}
};
return (
<div style={{ flexGrow: 1 }}>
<div style={{ display: "flex", gap: "1rem", alignItems: "stretch" }}>
{/* Sidebar */}
<aside style={{
width: "35%",
minWidth: "280px",
background: "#161616",
border: "1px solid #333",
borderRadius: "12px",
padding: "1rem"
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h3 style={{ margin: 0, fontSize: "1rem" }}>Playbooks</h3>
<button
onClick={resetForm}
style={{ padding: "0.35rem 0.75rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer" }}
>
New
</button>
</div>
<input
type="text"
placeholder="Search playbooks..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{
width: "100%",
padding: "0.75rem",
background: "#222",
color: "#eee",
border: "1px solid #333",
borderRadius: "8px",
marginBottom: "1rem",
boxSizing: "border-box"
}}
/>
<div style={{ maxHeight: "calc(100vh - 240px)", overflowY: "auto" }}>
{filteredPlaybooks.length === 0 ? (
<p style={{ color: "#aaa" }}>No playbooks found.</p>
) : (
filteredPlaybooks.map((p, idx) => {
const isMain = idx === 0 && !search.trim();
return (
<div
key={safeString(p.id)}
draggable
onDragStart={e => onDragStart(e, p.id)}
onDragEnter={e => onDragEnter(e, p.id)}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => selectPlaybook(p)}
style={{
padding: "0.75rem 1rem",
marginBottom: "0.5rem",
borderRadius: "10px",
cursor: "grab",
background: selectedId === p.id ? "#262626" : "#1b1b1b",
border: selectedId === p.id
? "1px solid #007acc"
: isMain
? "1px solid #2a4a2a"
: "1px solid #2a2a2a",
display: "flex",
alignItems: "center",
gap: "0.75rem",
userSelect: "none",
}}
>
<span style={{ color: "#555", fontSize: "1rem", flexShrink: 0 }}></span>
<span style={{ color: "#eee", fontSize: "0.95rem", textAlign: "left", flexGrow: 1 }}>{p.title}</span>
{isMain && (
<span style={{
fontSize: "0.65rem",
fontWeight: "600",
color: "#4caf50",
background: "#1a2e1a",
border: "1px solid #2a4a2a",
borderRadius: "4px",
padding: "0.15rem 0.4rem",
flexShrink: 0,
letterSpacing: "0.05em",
}}>
MAIN
</span>
)}
</div>
);
})
)}
</div>
</aside>
{/* Editor */}
<main style={{
flexGrow: 1,
background: "#161616",
border: "1px solid #333",
borderRadius: "12px",
padding: "1rem"
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem", marginBottom: "1rem" }}>
<div>
<h3 style={{ margin: 0, fontSize: "1rem" }}>{selectedId ? "Edit Playbook" : "Create Playbook"}</h3>
<p style={{ margin: "0.35rem 0 0", color: "#aaa", fontSize: "0.9rem" }}>
Select a playbook on the left, then edit here.
</p>
</div>
{selectedId && (
<button
onClick={() => deletePlaybook(selectedId)}
style={{ padding: "0.5rem 0.9rem", background: "#b32f2f", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" }}
>
Delete
</button>
)}
</div>
<div style={{ display: "grid", gap: "0.9rem" }}>
<input
type="text"
placeholder="Title"
value={form.title}
onChange={e => setForm(prev => ({ ...prev, title: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
<input
type="text"
placeholder="Tags (comma separated)"
value={form.tags}
onChange={e => setForm(prev => ({ ...prev, tags: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
<input
type="text"
placeholder="Playbook tools: search_memory, … (render_preview auto-attaches on visual asks)"
value={form.tools}
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
<input
type="text"
placeholder="Model (blank = auto-select): e.g. mistral:latest"
value={form.model}
onChange={e => setForm(prev => ({ ...prev, model: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
<textarea
rows={4}
placeholder="Goal"
value={form.goal}
onChange={e => setForm(prev => ({ ...prev, goal: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
<textarea
rows={10}
placeholder="Instructions"
value={form.instructions}
onChange={e => setForm(prev => ({ ...prev, instructions: e.target.value }))}
onKeyDown={e => {
if (e.key === "Tab") {
e.preventDefault();
const ta = e.currentTarget;
const start = ta.selectionStart;
const end = ta.selectionEnd;
setForm(prev => {
const newVal = prev.instructions.slice(0, start) + "\t" + prev.instructions.slice(end);
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + 1; }, 0);
return { ...prev, instructions: newVal };
});
}
}}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
/>
</div>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
<button
onClick={savePlaybook}
style={{ padding: "0.85rem 1.2rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" }}
>
{selectedId ? "Update Playbook" : "Create Playbook"}
</button>
<button
onClick={resetForm}
style={{ padding: "0.85rem 1.2rem", background: "#444", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" }}
>
Clear
</button>
</div>
{message && (
<div style={{ marginTop: "1rem", color: "#aaffaa" }}>{message}</div>
)}
</main>
</div>
</div>
);
}