/** * GraphPanel — the authoring cockpit for a project's scene graph. * * Evolved from the read-only DAG view into the post-creation * authoring surface the user loads right after the wizard * finishes. Every row supports: * - **Edit** (EDIT-2): click-to-edit title + narration + * image prompt. Saves via PATCH /nodes/{id}. * - **Regenerate** (EDIT-3): re-render this scene's asset. Hits * POST /experiences/{eid}/nodes/{nid}/render and updates the * preview inline. * - **Preview** (EDIT-4): open the rendered image/video (or a * placeholder if nothing rendered yet) in a modal. * * Panel header adds a "Regenerate all missing" action that streams * through /generate-all/stream with the bulk modal, so users who * created a project with the render flag off can flip the flag * and one-click to catch every empty scene up. * * Nodes are grouped by kind (scene → decision → merge → ending) * so the structural skeleton stays legible even in a 20-scene * experience. Outbound edges render as chips with target-title * labels; O(1) lookup via a titleById map. */ import React, { useCallback, useMemo, useState } from "react"; import { ArrowRight, Check, GitBranch, Image as ImageIcon, Loader2, Pencil, Play, RotateCw, X, } from "lucide-react"; import type { InteractiveApi } from "./api"; import type { EdgeItem, NodeItem } from "./types"; import { InteractiveApiError } from "./types"; import { GeneratingPanel } from "./GeneratingPanel"; import { EmptyState, ErrorBanner, Panel, PrimaryButton, SecondaryButton, SkeletonRow, useAsyncResource, useToast, } from "./ui"; export interface GraphPanelProps { api: InteractiveApi; projectId: string; } const KIND_ORDER: NodeItem["kind"][] = [ "scene", "decision", "merge", "assessment", "remediation", "ending", ]; const KIND_BADGE: Record = { scene: "bg-[#3ea6ff]/10 text-[#3ea6ff] border-[#3ea6ff]/40", decision: "bg-amber-500/10 text-amber-300 border-amber-500/40", merge: "bg-purple-500/10 text-purple-300 border-purple-500/40", assessment: "bg-emerald-500/10 text-emerald-300 border-emerald-500/40", remediation: "bg-orange-500/10 text-orange-300 border-orange-500/40", ending: "bg-[#aaa]/10 text-[#aaa] border-white/10", }; // Kinds that carry a visual asset the Preview modal can show. // Endings + merges are usually structural-only so we skip the // render/preview affordances on them. const RENDERABLE_KINDS = new Set([ "scene", "decision", "assessment", "remediation", ]); export function GraphPanel({ api, projectId }: GraphPanelProps) { const toast = useToast(); const resource = useAsyncResource<{ nodes: NodeItem[]; edges: EdgeItem[] }>( async (signal) => { const [nodes, edges] = await Promise.all([ api.listNodes(projectId, signal), api.listEdges(projectId, signal), ]); return { nodes, edges }; }, [api, projectId], ); const { grouped, titleById, outboundByFrom } = useMemo(() => { const nodes = resource.data?.nodes || []; const edges = resource.data?.edges || []; const titleById = new Map(); nodes.forEach((n) => titleById.set(n.id, n.title || "(untitled)")); const outboundByFrom = new Map(); edges.forEach((e) => { const list = outboundByFrom.get(e.from_node_id) || []; list.push(e); outboundByFrom.set(e.from_node_id, list); }); const grouped: Record = {}; for (const n of nodes) { const bucket = grouped[n.kind] || (grouped[n.kind] = []); bucket.push(n); } return { grouped, titleById, outboundByFrom }; }, [resource.data]); const missingAssetCount = useMemo(() => { const nodes = resource.data?.nodes || []; return nodes.filter( (n) => RENDERABLE_KINDS.has(n.kind) && (n.asset_ids || []).length === 0, ).length; }, [resource.data]); // ── Bulk "Regenerate all missing" via /generate-all/stream ── const [bulkOpen, setBulkOpen] = useState(false); const [bulkTotal, setBulkTotal] = useState(0); const [bulkDone, setBulkDone] = useState(0); const [bulkTitle, setBulkTitle] = useState(""); const regenerateAllMissing = useCallback(async () => { setBulkOpen(true); setBulkTotal(0); setBulkDone(0); setBulkTitle(""); try { await api.generateAllStream(projectId, { onEvent: (ev) => { if (ev.type === "rendering_started") { const total = Number((ev.payload as any)?.total || 0); setBulkTotal(total); } if (ev.type === "rendering_scene") { const title = String((ev.payload as any)?.title || ""); if (title) setBulkTitle(title); } if ( ev.type === "scene_rendered" || ev.type === "scene_skipped" || ev.type === "scene_render_failed" ) { setBulkDone((prev) => prev + 1); } }, }); toast.toast({ variant: "success", title: "Regeneration complete", message: "Your scenes are up to date.", }); resource.reload(); } catch (err) { const e = err as InteractiveApiError; toast.toast({ variant: "error", title: "Regeneration failed", message: e.message || "Try again, or regenerate scenes one by one.", }); } finally { setBulkOpen(false); } }, [api, projectId, resource, toast]); // ── Per-row handlers shared with every NodeRow ──────────────── const handleSavePatch = useCallback( async (nodeId: string, patch: { title?: string; narration?: string; image_prompt?: string }) => { try { await api.patchNode(nodeId, patch); toast.toast({ variant: "success", title: "Scene saved" }); resource.reload(); } catch (err) { const e = err as InteractiveApiError; toast.toast({ variant: "error", title: "Save failed", message: e.message || "Check your input and retry.", }); throw err; } }, [api, resource, toast], ); const handleRegenerate = useCallback( async (nodeId: string) => { try { const res = await api.renderSingleScene(projectId, nodeId); if (res.status === "rendered") { toast.toast({ variant: "success", title: "Scene regenerated", message: "Refreshing the graph…", }); resource.reload(); } else if (res.status === "skipped") { toast.toast({ variant: "warning", title: "Rendering is disabled", message: "Enable INTERACTIVE_PLAYBACK_RENDER or pick a different model in Settings.", }); } else if (res.status === "failed") { toast.toast({ variant: "error", title: "Render failed", message: res.reason || "Try again in a moment.", }); } else { toast.toast({ variant: "info", title: "Rendered but not attached", message: "The asset exists; refresh to see it.", }); resource.reload(); } } catch (err) { const e = err as InteractiveApiError; toast.toast({ variant: "error", title: "Couldn't regenerate this scene", message: e.message || "Try again.", }); } }, [api, projectId, resource, toast], ); // ── Preview modal ──────────────────────────────────────────── const [previewNode, setPreviewNode] = useState(null); return ( 0 ? ( } size="sm" > Regenerate all ({missingAssetCount} missing) ) : undefined } > {resource.error ? ( ) : resource.loading && !resource.data ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : (resource.data?.nodes || []).length === 0 ? ( } title="No nodes yet" description="The planner didn't seed a graph. Re-run the wizard, or open the Catalog tab to add scenes manually." /> ) : (
{KIND_ORDER.map((kind) => { const bucket = grouped[kind]; if (!bucket || bucket.length === 0) return null; return (
{kind} {bucket.length} {bucket.length === 1 ? "node" : "nodes"}
    {bucket.map((node) => ( setPreviewNode(node)} /> ))}
); })}
)} {bulkOpen && ( 0 ? `Regenerating scenes · ${bulkDone} of ${bulkTotal}` : "Regenerating scenes"} description={bulkTitle ? `Now rendering: ${bulkTitle}` : "Starting the render pipeline…"} spinnerSize="large" accentClassName="text-[#c4b5fd]" progress={bulkTotal > 0 ? Math.round((bulkDone / bulkTotal) * 100) : undefined} progressLabel={bulkTotal > 0 ? `${bulkDone} / ${bulkTotal} scenes` : undefined} /> )} {previewNode && ( setPreviewNode(null)} onRegenerate={() => { setPreviewNode(null); handleRegenerate(previewNode.id); }} /> )}
); } // ── NodeRow with edit + regenerate + preview ────────────────── function NodeRow({ node, outbound, titleById, onSave, onRegenerate, onPreview, }: { node: NodeItem; outbound: EdgeItem[]; titleById: Map; onSave: (nodeId: string, patch: { title?: string; narration?: string; image_prompt?: string }) => Promise; onRegenerate: (nodeId: string) => void; onPreview: () => void; }) { const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [regenerating, setRegenerating] = useState(false); const [draftTitle, setDraftTitle] = useState(node.title || ""); const [draftNarration, setDraftNarration] = useState(node.narration || ""); const [draftImagePrompt, setDraftImagePrompt] = useState(node.image_prompt || ""); const hasAsset = (node.asset_ids || []).length > 0; const canRender = RENDERABLE_KINDS.has(node.kind); const openEdit = () => { setDraftTitle(node.title || ""); setDraftNarration(node.narration || ""); setDraftImagePrompt(node.image_prompt || ""); setEditing(true); }; const saveEdit = async () => { const patch: { title?: string; narration?: string; image_prompt?: string } = {}; if (draftTitle !== (node.title || "")) patch.title = draftTitle; if (draftNarration !== (node.narration || "")) patch.narration = draftNarration; if (draftImagePrompt !== (node.image_prompt || "")) patch.image_prompt = draftImagePrompt; if (Object.keys(patch).length === 0) { setEditing(false); return; } setSaving(true); try { await onSave(node.id, patch); setEditing(false); } catch { // toast already shown by parent; stay in edit mode. } finally { setSaving(false); } }; const kickRegenerate = async () => { setRegenerating(true); try { await onRegenerate(node.id); } finally { setRegenerating(false); } }; return (
  • {editing ? (
    setDraftTitle(e.target.value)} className="w-full bg-[#0f0f0f] border border-[#3f3f3f] rounded-md px-2.5 py-1.5 text-sm outline-none focus:border-[#3ea6ff]" autoFocus />