'use client'; import { useState, useCallback } from 'react'; import { useCanvas } from '@/store/canvas'; import { useHistory } from '@/store/history'; import { useArtboards, patchArtboard } from '@/hooks/useArtboards'; import { useDiffs } from '@/hooks/useDiffs'; import { useQueryClient } from '@tanstack/react-query'; import { useCanvasTheme } from '@/store/canvasTheme'; import type { PropChange } from '@originmain/diff-engine'; import type { FiberNode } from '@originmain/renderer'; import type { Artboard, IntentDiff } from '@originmain/origin-graph'; const TYPE_COLORS: Record = { s: '#7DD3A8', n: '#7EB8FF', b: '#FFBA7B', }; type TabId = 'props' | 'diff' | 'graph'; export function Inspector() { const T = useCanvasTheme(); const { selectedArtboardId, liveArtboardIds, artboardFiberRoots, selectedComponentId, selectedComponentData, workspaceId, projectId } = useCanvas(); const [tab, setTab] = useState('props'); const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null; const isLive = selectedArtboardId ? liveArtboardIds.has(selectedArtboardId) : false; return (
{/* Tab bar */}
{(['props', 'diff', 'graph'] as TabId[]).map((t) => ( ))}
{/* Content — minHeight:0 is required so this flex child can actually shrink and scroll */}
{!selectedArtboardId ? (
Select an artboard
) : tab === 'props' ? ( ) : tab === 'diff' ? ( ) : ( )}
{/* Status bar */}
{isLive ? 'Live render connected' : selectedArtboardId ? 'No render — set URL in Props' : 'No artboard selected'} {selectedArtboard ? `${selectedArtboard.metadata_jsonb['width'] ?? '?'} × ${selectedArtboard.metadata_jsonb['height'] ?? '?'}` : '—'}
); } /* ── Props tab ────────────────────────────────────────────── */ function PropsTab({ artboard, selectedComponentData, workspaceId, projectId, }: { artboard: Artboard | null; selectedComponentData: FiberNode | null; workspaceId: string | null; projectId: string | null; }) { const T = useCanvasTheme(); const queryClient = useQueryClient(); const [editingUrl, setEditingUrl] = useState(false); const [urlDraft, setUrlDraft] = useState(''); // Drift report state const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle'); const [driftReport, setDriftReport] = useState(''); const generateDriftReport = useCallback(async () => { if (!artboard) return; setDriftStatus('loading'); setDriftReport(''); try { const res = await fetch('/api/ai/drift-report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artboard_id: artboard.id }), }); if (!res.ok) throw new Error(`${res.status}`); const data = await res.json() as { report?: string; result?: string }; setDriftReport(data.report ?? data.result ?? '— No report returned'); setDriftStatus('done'); } catch { setDriftStatus('error'); } }, [artboard]); const saveRenderUrl = useCallback(async () => { if (!artboard) return; const { renderUrl: _removed, ...rest } = artboard.metadata_jsonb; const meta: Record = urlDraft.trim() ? { ...rest, renderUrl: urlDraft.trim() } : { ...rest }; try { await patchArtboard(artboard.id, { metadata_jsonb: meta }); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); } catch (e) { console.error('[Inspector] patch renderUrl failed', e); } setEditingUrl(false); }, [artboard, urlDraft, workspaceId, projectId, queryClient]); if (!artboard) return null; const meta = artboard.metadata_jsonb; const N = TYPE_COLORS['n']!; const B = TYPE_COLORS['b']!; const S = TYPE_COLORS['s']!; const canvasProps: Array<{ key: string; val: string; color: string }> = [ { key: 'x', val: String(meta['x'] ?? 0), color: N }, { key: 'y', val: String(meta['y'] ?? 0), color: N }, { key: 'width', val: String(meta['width'] ?? 0), color: N }, { key: 'height', val: String(meta['height'] ?? 0), color: N }, ]; const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl']); const extraProps = Object.entries(meta) .filter(([k]) => !reservedKeys.has(k)) .map(([k, v]) => { const t = typeof v; const color = t === 'number' ? N : t === 'boolean' ? B : S; const val = t === 'string' ? `"${v}"` : String(v); return { key: k, val, color }; }); const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : ''; return ( <> {/* Selected fiber component props — shown when a component is clicked in canvas */} {selectedComponentData && ( <>
{Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => { const t = typeof v; const color = t === 'number' ? N : t === 'boolean' ? B : S; const display = t === 'string' ? `"${v as string}"` : String(v); return ; })} {Object.keys(selectedComponentData.props ?? {}).length === 0 && ( No props )}
)} {extraProps.length > 0 && ( <>
{extraProps.map(({ key, val, color }) => ( ))}
)}
{canvasProps.map(({ key, val, color }) => ( ))}
{/* renderUrl — inline editable */}
url
{editingUrl ? (
setUrlDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void saveRenderUrl(); if (e.key === 'Escape') setEditingUrl(false); }} placeholder="http://localhost:3000" style={{ flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace", background: T.bgDeep, border: `1px solid ${T.border}`, borderRadius: 5, padding: '4px 8px', color: T.fg, outline: 'none', }} />
) : renderUrl ? ( {renderUrl} ) : ( not connected )}
{/* ── Drift Report ───────────────────────────────────── */}
{driftStatus === 'error' && (
Report failed — try again
)} {driftStatus === 'done' && driftReport && (
{driftReport}
)}
); } function Section({ label, children }: { label: string; children: React.ReactNode }) { const T = useCanvasTheme(); return (
{label}
{children}
); } function PropRow({ label, value, color }: { label: string; value: string; color: string }) { const T = useCanvasTheme(); return (
{label} {value}
); } function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isRoot?: boolean }) { const T = useCanvasTheme(); return (
{label}
); } /* ── Graph tab ────────────────────────────────────────────── */ function GraphTab({ fiberRoot }: { fiberRoot: FiberNode | undefined }) { const T = useCanvasTheme(); if (!fiberRoot) { return (
No live render — connect a URL in Props to see the fiber tree
); } const nodeCount = countFiberNodes(fiberRoot); const treeDepth = measureFiberDepth(fiberRoot); return (
); } function FiberTreeView({ node, depth }: { node: FiberNode; depth: number }) { const T = useCanvasTheme(); const [collapsed, setCollapsed] = useState(depth > 2); const hasChildren = node.children && node.children.length > 0; return (
hasChildren && setCollapsed(c => !c)} >
{hasChildren && ( {collapsed ? '▶' : '▼'} )} {node.name}
{!collapsed && hasChildren && node.children!.map((child, i) => ( ))}
); } function countFiberNodes(node: FiberNode): number { return 1 + (node.children ?? []).reduce((acc, c) => acc + countFiberNodes(c), 0); } function measureFiberDepth(node: FiberNode, d = 0): number { if (!node.children?.length) return d; return Math.max(...node.children.map(c => measureFiberDepth(c, d + 1))); } function HSep() { const T = useCanvasTheme(); return
; } /* ── Diff tab ─────────────────────────────────────────────── */ function DiffTab({ artboardId }: { artboardId: string | null }) { const T = useCanvasTheme(); const { stacks } = useHistory(); const { diffs, createDiff, isLoading } = useDiffs(artboardId); const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle'); const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] }; const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes); const hasChanges = pendingChanges.length > 0; const exportDiff = useCallback(async () => { if (!artboardId || !hasChanges) return; // 1. Generate AI summary (best-effort — fall back to empty string on failure) let summary = ''; const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged'); if (meaningfulChanges.length > 0) { setSummaryStatus('summarising'); try { const res = await fetch('/api/ai/diff-summary', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ changesJson: JSON.stringify(meaningfulChanges), componentName: meaningfulChanges[0]?.key ?? 'Component', }), }); if (res.ok) { const data = await res.json() as { summary?: string }; summary = data.summary ?? ''; } } catch { /* non-fatal — proceed without summary */ } } // 2. Export diff with AI-generated summary included setSummaryStatus('exporting'); createDiff.mutate( { artboard_id: artboardId, changes_jsonb: { propChanges: pendingChanges, styleChanges: [] }, summary, status: 'DRAFT', }, { onSettled: () => setSummaryStatus('idle') }, ); }, [artboardId, pendingChanges, hasChanges, createDiff]); if (!artboardId) { return (
No artboard selected
); } return ( <> {/* Pending local changes */}
c.changeType !== 'unchanged').length : 0} changes`}> {hasChanges ? ( <> {pendingChanges .filter(c => c.changeType !== 'unchanged') .map((change, i) => ( ))} {createDiff.isError && ( Export failed — try again )} ) : ( No pending changes )}
{/* Saved diffs from DB */}
{isLoading ? ( Loading… ) : diffs.length === 0 ? ( No exported diffs yet ) : ( diffs.map(d => ) )}
); } const STATUS_COLOR: Record = { DRAFT: '#FFBA7B', REVIEWED: '#7EB8FF', APPLIED: '#7DD3A8', REJECTED: '#FF8080', }; function SavedDiffRow({ diff }: { diff: IntentDiff }) { const T = useCanvasTheme(); const changes = diff.changes_jsonb as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null; const count = (changes?.propChanges?.length ?? 0) + (changes?.styleChanges?.length ?? 0); const color = STATUS_COLOR[diff.status] ?? T.dim; return (
{diff.status} {count} change{count !== 1 ? 's' : ''}
{diff.summary && ( {diff.summary} )}
); } function DiffChangeRow({ change }: { change: PropChange }) { const isRemoved = change.changeType === 'removed'; const isAdded = change.changeType === 'added'; const isModified = change.changeType === 'modified'; const rows: Array<{ op: 'del' | 'add'; text: string }> = []; if (isModified) { rows.push({ op: 'del', text: `− ${change.key}: ${change.before}` }); rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` }); } else if (isRemoved) { rows.push({ op: 'del', text: `− ${change.key}: ${change.before}` }); } else if (isAdded) { rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` }); } return ( <> {rows.map((r, i) => (
{r.text}
))} ); }