import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import ReactDOM from 'react-dom/client';
import {
ReactFlow,
Background,
Controls,
MiniMap,
Panel,
MarkerType,
Handle,
Position,
useNodesState,
useEdgesState,
} from '@xyflow/react';
import htm from 'htm';
const html = htm.bind(React.createElement);
let root = null;
const NODE_W = 340;
const NODE_H = 260;
const H_GAP = 80;
const V_GAP = 140;
const CARD_SCALE = 0.55;
export function renderGraph(flow, onNodeClick) {
const container = document.getElementById('graph-canvas');
if (!container) return;
if (!flow || !Array.isArray(flow.cards) || flow.cards.length === 0) {
container.innerHTML = '<div class="empty-state">No flow loaded.</div>';
return;
}
if (!root) {
root = ReactDOM.createRoot(container);
}
root.render(html`<${GraphApp} flow=${flow} onNodeClick=${onNodeClick} />`);
}
function CardNode({ data }) {
const containerRef = useRef(null);
useEffect(() => {
const el = containerRef.current;
if (!el || !data.cardJson) return;
try {
const ac = new AdaptiveCards.AdaptiveCard();
const cfg = window.__fb_hostConfig__ || compactHostConfig;
ac.hostConfig = new AdaptiveCards.HostConfig(cfg);
ac.onExecuteAction = () => {};
ac.parse(data.cardJson);
const rendered = ac.render();
el.innerHTML = '';
if (rendered) {
rendered.style.pointerEvents = 'none';
rendered.style.userSelect = 'none';
el.appendChild(rendered);
}
} catch (e) {
el.innerHTML =
'<div style="color:#dc2626;font-size:11px;padding:8px">Render failed: ' +
(e && e.message ? e.message : 'unknown error') +
'</div>';
}
}, [data.cardJson]);
return html`<div style=${nodeWrapperStyle}>
<${Handle} type="target" position=${Position.Top} style=${handleStyle} />
<div style=${nodeHeaderStyle}>
<div style=${nodeIdStyle}>${data.id}</div>
<div style=${nodeMetaStyle}>
${data.title ? html`<span>${data.title.slice(0, 40)} · </span>` : null}
${data.actionCount} action${data.actionCount === 1 ? '' : 's'}
</div>
</div>
<div style=${cardPreviewWrapStyle}>
<div ref=${containerRef} style=${cardPreviewInnerStyle} />
<div style=${fadeOverlayStyle} />
</div>
<${Handle} type="source" position=${Position.Bottom} style=${handleStyle} />
</div>`;
}
function HttpNode({ data }) {
const bodyFields = data.config?.body_mapping
? Object.keys(data.config.body_mapping).join(', ')
: '';
return html`<div style=${httpNodeWrapperStyle}>
<${Handle} type="target" position=${Position.Top} style=${httpHandleStyle} />
<div style=${httpNodeHeaderStyle}>
<div style=${httpNodeIdStyle}>\u26A1 ${data.id}</div>
</div>
<div style=${httpNodeBodyStyle}>
<div style=${httpMethodBadgeStyle}>${data.config?.method || 'POST'}</div>
<div style=${httpUrlStyle}>${data.config?.url || '/api/...'}</div>
${bodyFields ? html`<div style=${httpFieldsStyle}>body: ${bodyFields}</div>` : null}
<div style=${httpAuthStyle}>\uD83D\uDD12 Auth configured at setup</div>
</div>
<${Handle} type="source" position=${Position.Bottom} style=${httpHandleStyle} />
</div>`;
}
const nodeTypes = { cardNode: CardNode, httpNode: HttpNode };
function GraphApp({ flow, onNodeClick }) {
const initial = useMemo(() => buildGraphModel(flow), [flow]);
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
const flowKey = `${flow?.flow || ''}|${flow?.cards?.length || 0}`;
useEffect(() => {
const next = buildGraphModel(flow);
setNodes(next.nodes);
setEdges(next.edges);
}, [flowKey]);
const handleNodeClick = useCallback(
(_event, node) => {
onNodeClick?.(node.id);
},
[onNodeClick]
);
const resetLayout = useCallback(() => {
const next = buildGraphModel(flow);
setNodes(next.nodes);
setEdges(next.edges);
}, [flow, setNodes, setEdges]);
return html`<${ReactFlow}
nodes=${nodes}
edges=${edges}
nodeTypes=${nodeTypes}
onNodesChange=${onNodesChange}
onEdgesChange=${onEdgesChange}
onNodeClick=${handleNodeClick}
fitView=${true}
fitViewOptions=${{ padding: 0.15, maxZoom: 1 }}
proOptions=${{ hideAttribution: true }}
nodesDraggable=${true}
nodesConnectable=${false}
elementsSelectable=${true}
panOnDrag=${true}
panOnScroll=${false}
zoomOnScroll=${true}
zoomOnPinch=${true}
zoomOnDoubleClick=${true}
minZoom=${0.1}
maxZoom=${3}
>
<${Background} variant="dots" gap=${20} size=${1} color="#e5e7eb" />
<${Controls} showInteractive=${false} />
<${MiniMap}
nodeColor=${'#0d9488'}
nodeStrokeColor=${'#134e4a'}
maskColor=${'rgba(248, 250, 252, 0.7)'}
pannable=${true}
zoomable=${true}
style=${{ border: '1px solid #e5e7eb', borderRadius: 6 }}
/>
<${Panel} position="top-left">
<div style=${panelStyle}>
<div style=${{ fontWeight: 600, color: '#0d9488', marginBottom: 4 }}>
${flow.flow || 'flow'}
</div>
<div style=${{ fontSize: 11, color: '#6b7280' }}>
${nodes.length} cards · ${edges.length} links
</div>
<div style=${{ fontSize: 10, color: '#9ca3af', marginTop: 6 }}>
Scroll to zoom · Drag nodes · Click to focus
</div>
<button onClick=${resetLayout} style=${resetBtnStyle}>
Reset layout
</button>
</div>
</${Panel}>
</${ReactFlow}>`;
}
const compactHostConfig = {
fontFamily: "'Poppins', 'Segoe UI', system-ui, sans-serif",
supportsInteractivity: false,
fontSizes: { small: 11, default: 12, medium: 14, large: 17, extraLarge: 22 },
fontWeights: { lighter: 300, default: 400, bolder: 600 },
spacing: { small: 4, default: 8, medium: 12, large: 16, extraLarge: 20, padding: 12 },
separator: { lineThickness: 1, lineColor: '#e5e7eb' },
imageSizes: { small: 24, medium: 36, large: 60 },
containerStyles: {
default: {
backgroundColor: '#ffffff',
foregroundColors: {
default: { default: '#1a1a2e', subtle: '#6b7280' },
dark: { default: '#111827', subtle: '#374151' },
light: { default: '#9ca3af', subtle: '#d1d5db' },
accent: { default: '#0d9488', subtle: '#14b8a6' },
good: { default: '#16a34a', subtle: '#22c55e' },
attention: { default: '#dc2626', subtle: '#ef4444' },
warning: { default: '#d97706', subtle: '#f59e0b' },
},
},
emphasis: { backgroundColor: '#f8fafc', foregroundColors: { default: { default: '#1a1a2e', subtle: '#6b7280' }, dark: { default: '#111827', subtle: '#374151' }, light: { default: '#9ca3af', subtle: '#d1d5db' }, accent: { default: '#0d9488', subtle: '#14b8a6' }, good: { default: '#16a34a', subtle: '#22c55e' }, attention: { default: '#dc2626', subtle: '#ef4444' }, warning: { default: '#d97706', subtle: '#f59e0b' } } },
good: { backgroundColor: '#ecfdf5', foregroundColors: { default: { default: '#065f46', subtle: '#6b7280' }, dark: { default: '#111827', subtle: '#374151' }, light: { default: '#9ca3af', subtle: '#d1d5db' }, accent: { default: '#0d9488', subtle: '#14b8a6' }, good: { default: '#16a34a', subtle: '#22c55e' }, attention: { default: '#dc2626', subtle: '#ef4444' }, warning: { default: '#d97706', subtle: '#f59e0b' } } },
attention: { backgroundColor: '#fef2f2', foregroundColors: { default: { default: '#991b1b', subtle: '#6b7280' }, dark: { default: '#111827', subtle: '#374151' }, light: { default: '#9ca3af', subtle: '#d1d5db' }, accent: { default: '#0d9488', subtle: '#14b8a6' }, good: { default: '#16a34a', subtle: '#22c55e' }, attention: { default: '#dc2626', subtle: '#ef4444' }, warning: { default: '#d97706', subtle: '#f59e0b' } } },
warning: { backgroundColor: '#fffbeb', foregroundColors: { default: { default: '#92400e', subtle: '#6b7280' }, dark: { default: '#111827', subtle: '#374151' }, light: { default: '#9ca3af', subtle: '#d1d5db' }, accent: { default: '#0d9488', subtle: '#14b8a6' }, good: { default: '#16a34a', subtle: '#22c55e' }, attention: { default: '#dc2626', subtle: '#ef4444' }, warning: { default: '#d97706', subtle: '#f59e0b' } } },
accent: { backgroundColor: '#f0fdfa', foregroundColors: { default: { default: '#134e4a', subtle: '#6b7280' }, dark: { default: '#111827', subtle: '#374151' }, light: { default: '#9ca3af', subtle: '#d1d5db' }, accent: { default: '#0d9488', subtle: '#14b8a6' }, good: { default: '#16a34a', subtle: '#22c55e' }, attention: { default: '#dc2626', subtle: '#ef4444' }, warning: { default: '#d97706', subtle: '#f59e0b' } } },
},
actions: {
actionsOrientation: 'vertical',
actionAlignment: 'stretch',
buttonSpacing: 6,
maxActions: 6,
spacing: 'default',
},
factSet: {
title: { color: 'default', size: 'small', weight: 'bolder' },
value: { color: 'default', size: 'small', weight: 'default' },
spacing: 6,
},
};
const handleStyle = {
width: 8,
height: 8,
background: '#0d9488',
border: '2px solid #ffffff',
borderRadius: '50%',
};
const httpHandleStyle = {
width: 8, height: 8,
background: '#7c3aed', border: '2px solid #ffffff', borderRadius: '50%',
};
const httpNodeWrapperStyle = {
width: 340, minHeight: 120,
background: '#f5f3ff', border: '2px solid #7c3aed', borderRadius: 12,
overflow: 'hidden', boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
fontFamily: "'Poppins', sans-serif", cursor: 'grab',
display: 'flex', flexDirection: 'column',
};
const httpNodeHeaderStyle = {
padding: '8px 12px', borderBottom: '1px solid #ddd6fe',
background: '#ede9fe', flexShrink: 0,
};
const httpNodeIdStyle = {
fontWeight: 600, fontSize: 13, color: '#7c3aed', wordBreak: 'break-word',
};
const httpNodeBodyStyle = {
padding: '10px 14px', fontSize: 12, color: '#1a1a2e',
display: 'flex', flexDirection: 'column', gap: 4,
};
const httpMethodBadgeStyle = {
display: 'inline-block', background: '#7c3aed', color: 'white',
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
width: 'fit-content',
};
const httpUrlStyle = {
fontFamily: "'JetBrains Mono', monospace", fontSize: 12, color: '#4c1d95',
};
const httpFieldsStyle = {
fontSize: 11, color: '#6b7280', fontStyle: 'italic',
};
const httpAuthStyle = {
fontSize: 10, color: '#9ca3af', marginTop: 4,
};
const nodeWrapperStyle = {
width: NODE_W,
height: NODE_H,
background: '#ffffff',
border: '2px solid #0d9488',
borderRadius: 12,
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
fontFamily: "'Poppins', sans-serif",
cursor: 'grab',
display: 'flex',
flexDirection: 'column',
};
const nodeHeaderStyle = {
padding: '8px 12px',
borderBottom: '1px solid #e5e7eb',
background: '#f0fdfa',
flexShrink: 0,
};
const nodeIdStyle = {
fontWeight: 600,
fontSize: 13,
color: '#0d9488',
wordBreak: 'break-word',
};
const nodeMetaStyle = {
fontSize: 10,
color: '#6b7280',
marginTop: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const cardPreviewWrapStyle = {
position: 'relative',
flex: 1,
overflow: 'hidden',
background: '#ffffff',
};
const cardPreviewInnerStyle = {
transform: `scale(${CARD_SCALE})`,
transformOrigin: 'top left',
width: `${100 / CARD_SCALE}%`,
height: `${100 / CARD_SCALE}%`,
padding: 8,
pointerEvents: 'none',
userSelect: 'none',
};
const fadeOverlayStyle = {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
background: 'linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0.95))',
pointerEvents: 'none',
};
const panelStyle = {
background: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: '10px 12px',
fontFamily: "'Poppins', sans-serif",
fontSize: 12,
color: '#1a1a2e',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
minWidth: 180,
};
const resetBtnStyle = {
marginTop: 8,
padding: '4px 10px',
fontSize: 11,
border: '1px solid #0d9488',
borderRadius: 4,
background: '#ffffff',
color: '#0d9488',
cursor: 'pointer',
fontFamily: 'inherit',
fontWeight: 500,
};
function bfsLayout(cards, flowEdges) {
const ids = cards.map((c) => c.id);
const idSet = new Set(ids);
const children = {};
const hasParent = new Set();
for (const e of flowEdges) {
if (!idSet.has(e.source) || !idSet.has(e.target)) continue;
if (e.source === e.target) continue;
if (!children[e.source]) children[e.source] = [];
if (!children[e.source].includes(e.target)) {
children[e.source].push(e.target);
}
hasParent.add(e.target);
}
const roots = ids.filter((id) => !hasParent.has(id));
if (roots.length === 0 && ids.length > 0) roots.push(ids[0]);
const visited = new Set();
const levels = [];
const queue = roots.map((id) => ({ id, depth: 0 }));
for (const r of roots) visited.add(r);
while (queue.length > 0) {
const { id, depth } = queue.shift();
if (!levels[depth]) levels[depth] = [];
levels[depth].push(id);
for (const child of children[id] || []) {
if (visited.has(child)) continue;
visited.add(child);
queue.push({ id: child, depth: depth + 1 });
}
}
const orphans = ids.filter((id) => !visited.has(id));
if (orphans.length > 0) {
levels.push(orphans);
}
const positions = {};
for (let depth = 0; depth < levels.length; depth++) {
const row = levels[depth];
const rowWidth = row.length * NODE_W + (row.length - 1) * H_GAP;
const startX = -rowWidth / 2;
for (let i = 0; i < row.length; i++) {
positions[row[i]] = {
x: startX + i * (NODE_W + H_GAP),
y: depth * (NODE_H + V_GAP),
};
}
}
return positions;
}
function buildGraphModel(flow) {
const knownIds = new Set(flow.cards.map((c) => c.id));
const flowEdges = flow.edges || [];
const positions = bfsLayout(flow.cards, flowEdges);
const nodes = flow.cards.map((c) => {
const title = c.type === 'http' ? '' : extractFirstText(c.card);
const actionCount = c.type === 'http' ? 0 : countActions(c.card);
const pos = positions[c.id] || { x: 0, y: 0 };
return {
id: c.id,
position: pos,
type: c.type === 'http' ? 'httpNode' : 'cardNode',
draggable: true,
data: c.type === 'http'
? { id: c.id, config: c.config }
: { id: c.id, title, actionCount, cardJson: c.card },
};
});
const seen = new Set();
const edges = [];
for (const e of flowEdges) {
const key = `${e.source}->${e.target}`;
if (seen.has(key)) continue;
seen.add(key);
const valid = knownIds.has(e.target);
edges.push({
id: e.id || key,
source: e.source,
target: e.target,
label: e.label || '',
animated: false,
type: 'smoothstep',
markerEnd: {
type: MarkerType.ArrowClosed,
color: valid ? '#0d9488' : '#dc2626',
},
style: {
stroke: valid ? '#0d9488' : '#dc2626',
strokeWidth: 2,
},
labelStyle: {
fontSize: 11,
fill: '#6b7280',
fontFamily: "'Poppins', sans-serif",
},
labelBgStyle: { fill: '#f8fafc', fillOpacity: 0.95 },
labelBgPadding: [4, 6],
labelBgBorderRadius: 4,
});
}
return { nodes, edges };
}
function extractFirstText(cardJson) {
if (!cardJson || typeof cardJson !== 'object') return '';
const queue = [cardJson];
while (queue.length) {
const n = queue.shift();
if (!n || typeof n !== 'object') continue;
if (
n.type === 'TextBlock' &&
typeof n.text === 'string' &&
n.size !== 'Small' &&
!n.isSubtle
) {
return n.text.slice(0, 50);
}
if (Array.isArray(n.body)) queue.push(...n.body);
if (Array.isArray(n.items)) queue.push(...n.items);
if (Array.isArray(n.columns)) queue.push(...n.columns);
}
return '';
}
function countActions(cardJson) {
if (!cardJson || typeof cardJson !== 'object') return 0;
let count = 0;
const queue = [cardJson];
while (queue.length) {
const n = queue.shift();
if (!n || typeof n !== 'object') continue;
if (Array.isArray(n.actions)) count += n.actions.length;
if (Array.isArray(n.body)) queue.push(...n.body);
if (Array.isArray(n.items)) queue.push(...n.items);
if (Array.isArray(n.columns)) queue.push(...n.columns);
}
return count;
}