'use client'; // ── Code Tab (Phase 4) ──────────────────────────────────────────────────────── // Shows a live source diff for the selected component based on pending style // edits, with hunk-level accept/reject, Send-to-Agent, and Realtime status. // Extracted from Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2. // // Phase 4 additions: // • diffIndicators: 'bars' — gutter bar change indicators // • lineAnnotations — token match badges on addition lines // • renderAnnotation — renders the token key pill // • onTokenEnter/Leave — hover tooltip showing matched design token // • rejectedHunks Set — tracks explicitly rejected hunks // • allRejected guard — disables Send-to-Agent when all hunks rejected import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useCanvas } from '@/store/canvas'; import { useHistory } from '@/store/history'; import { useDiffs } from '@/hooks/useDiffs'; import { useIndexer } from '@/hooks/useIndexer'; import { useCanvasTheme } from '@/store/canvasTheme'; import { generatePatch } from '@originmain/diff-engine'; import type { PropChange } from '@originmain/diff-engine'; import type { FiberNode } from '@originmain/renderer'; import type { IntentDiff } from '@originmain/origin-graph'; import { FileDiff as PierreDiff } from '@pierre/diffs/react'; import type { DiffLineAnnotation } from '@pierre/diffs/react'; import { processFile, diffAcceptRejectHunk } from '@pierre/diffs'; import type { FileDiffMetadata } from '@pierre/diffs'; import { resolveValueToToken } from '@originmain/design-language'; import { browserClient } from '@/lib/supabase'; import type { SupabaseClient } from '@supabase/supabase-js'; // ── Helpers ──────────────────────────────────────────────────────────────────── /** * Best-effort application of PropChange values to a source file string. * Searches for `propKey: oldValue` patterns and replaces with new values. */ function applyChangesToSource(source: string, changes: PropChange[]): string { let result = source; for (const change of changes) { if (change.before === undefined || change.before === change.after) continue; const escapedBefore = String(change.before).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(`(${change.key}\\s*:\\s*)${escapedBefore}`, 'g'); result = result.replace(re, `$1${String(change.after)}`); } return result; } // ── Sub-components ───────────────────────────────────────────────────────────── const STATUS_COLOR: Record = { DRAFT: '#FFBA7B', REVIEWED: '#7EB8FF', APPLIED: '#7DD3A8', REJECTED: '#FF8080', }; export function SavedDiffRow({ diff }: { diff: IntentDiff }) { const T = useCanvasTheme(); const changes = diff.changes 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.aggregate_summary && ( {diff.aggregate_summary} )}
); } export 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}
))} ); } // ── HunkList — virtualised list of hunk accept/reject controls ──────────────── // Uses @tanstack/react-virtual so even a 1 000-hunk diff won't freeze the panel. // Each row is ~26px high; overscan=3 keeps the list feeling instant on scroll. interface HunkListProps { hunks: FileDiffMetadata['hunks']; allRejected: boolean; fileDiff: FileDiffMetadata; handleRejectHunk: (renderedIdx: number) => void; setFileDiff: (d: FileDiffMetadata) => void; } function HunkList({ hunks, allRejected, fileDiff, handleRejectHunk, setFileDiff }: HunkListProps) { const T = useCanvasTheme(); const scrollRef = useRef(null); const virtualizer = useVirtualizer({ count: hunks.length, getScrollElement: () => scrollRef.current, estimateSize: () => 26, overscan: 3, }); return (
{hunks.length} hunk{hunks.length !== 1 ? 's' : ''} {allRejected && — all rejected} {/* Scrollable virtual container — capped at 160px so it doesn't crowd the diff */}
{virtualizer.getVirtualItems().map((vItem) => (
Hunk {vItem.index + 1}
))}
); } // ── Main CodeTab component ───────────────────────────────────────────────────── interface CodeTabProps { componentId: string | null; componentData: FiberNode | null; artboardId: string | null; } // ── Token annotation metadata shape ─────────────────────────────────────────── interface TokenAnnotationMeta { tokenKey: string; tokenName: string; } export function CodeTab({ componentId, componentData, artboardId }: CodeTabProps) { const T = useCanvasTheme(); const { indexerStatus, undoStyleEdit, patchStyleEdit, designLanguageTokens, artboardRootFontSize, } = useCanvas(); const { stacks } = useHistory(); const { fetchFile } = useIndexer(); const { createDiff } = useDiffs(artboardId); const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split'); const [fileDiff, setFileDiff] = useState(null); const [patchStr, setPatchStr] = useState(''); const [isLoading, setIsLoading] = useState(false); const [diffError, setDiffError] = useState(null); const [isSending, setIsSending] = useState(false); const [exportedId, setExportedId] = useState(null); const [intentRtStatus, setIntentRtStatus] = useState(null); // ── Phase 4: rejection tracking ─────────────────────────────────────────── // rejectedHunks tracks each explicit hunk rejection; size compared against // initialHunkCount to determine when all hunks have been dismissed. const [rejectedHunks, setRejectedHunks] = useState>(new Set()); const [initialHunkCount, setInitialHunkCount] = useState(0); // Monotonically incrementing ID so each rejection inserts a unique entry. const rejectionIdRef = useRef(0); // ── Phase 4: token hover tooltip ────────────────────────────────────────── const [tokenTooltip, setTokenTooltip] = useState<{ tokenKey: string; tokenName: string; x: number; y: number; } | null>(null); const tokens = designLanguageTokens ?? []; const rootFontSizePx = artboardId ? (artboardRootFontSize[artboardId] ?? 16) : 16; const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] }; const pendingChanges = artboardHistory.past .flatMap(e => e.changes) .filter(c => c.changeType !== 'unchanged'); // ── Generate diff ────────────────────────────────────────────────────────── useEffect(() => { if (!componentData?.callSite || indexerStatus !== 'ready' || pendingChanges.length === 0) { setFileDiff(null); setPatchStr(''); return; } let cancelled = false; setIsLoading(true); setDiffError(null); void (async () => { try { const filePath = componentData.callSite!.fileName.replace(/\\/g, '/'); const sourceContent = await fetchFile(filePath).catch(() => null); let patch: string; if (sourceContent) { const afterContent = applyChangesToSource(sourceContent, pendingChanges); patch = generatePatch(sourceContent, afterContent, { filename: filePath }); } else { const beforeText = pendingChanges.map(c => ` ${c.key}: ${String(c.before)},`).join('\n'); const afterText = pendingChanges.map(c => ` ${c.key}: ${String(c.after)},`).join('\n'); patch = generatePatch(beforeText, afterText, { filename: filePath }); } if (cancelled || !patch) return; const parsed = processFile(patch); if (!cancelled) { setFileDiff(parsed ?? null); setPatchStr(patch); // Reset rejection tracking when a fresh diff arrives. setRejectedHunks(new Set()); rejectionIdRef.current = 0; setInitialHunkCount(parsed?.hunks?.length ?? 0); } } catch (err) { if (!cancelled) setDiffError(err instanceof Error ? err.message : 'Diff generation failed'); } finally { if (!cancelled) setIsLoading(false); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [componentData?.callSite?.fileName, pendingChanges.length, indexerStatus, fetchFile]); // ── Realtime — watch intent_diffs for agent status ───────────────────────── useEffect(() => { if (!exportedId) return; const db = browserClient() as unknown as SupabaseClient; const channel = db .channel(`code_tab_intent_${exportedId}`) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'intent_diffs', filter: `id=eq.${exportedId}` }, (payload: { new: Record }) => { const status = payload.new['status']; if (typeof status === 'string') setIntentRtStatus(status); }, ) .subscribe(); return () => { void db.removeChannel(channel); }; }, [exportedId]); // ── Cmd+Z undo ───────────────────────────────────────────────────────────── useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { const undone = undoStyleEdit(); if (undone) { e.preventDefault(); patchStyleEdit(undone.artboardId, undone.nodeId, undone.property, undone.previousValue); } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [undoStyleEdit, patchStyleEdit]); // ── Empty state ──────────────────────────────────────────────────────────── if (!componentId) { return (
Select a component to
view its source
); } const filePath = componentData?.callSite?.fileName?.replace(/\\/g, '/') ?? null; const shortPath = filePath ? filePath.split('/').slice(-2).join('/') : null; const hunkCount = fileDiff?.hunks?.length ?? 0; // ── Phase 4: allRejected guard ──────────────────────────────────────────── // True when the user has explicitly dismissed every hunk via "reject". // Prevents sending an empty diff to the agent. const allRejected = initialHunkCount > 0 && rejectedHunks.size >= initialHunkCount; // ── Phase 4: per-hunk reject handler ───────────────────────────────────── const handleRejectHunk = useCallback((renderedIdx: number) => { const id = rejectionIdRef.current++; setRejectedHunks(prev => new Set([...prev, id])); setFileDiff(prev => prev ? diffAcceptRejectHunk(prev, renderedIdx, 'reject') : null); }, []); // ── Phase 4: line annotations (token match badges on addition lines) ────── // For each hunk that is not yet rejected, find the first pending change // whose new value resolves to a known design token and annotate the first // addition line of that hunk with the token key/name. const lineAnnotations = useMemo[]>(() => { if (!fileDiff || !tokens.length) return []; const annotations: DiffLineAnnotation[] = []; fileDiff.hunks.forEach((hunk) => { for (const change of pendingChanges) { const cssValue = String(change.after ?? '').trim(); if (!cssValue) continue; const match = resolveValueToToken(cssValue, tokens, rootFontSizePx); if (match) { annotations.push({ side: 'additions', lineNumber: hunk.additionStart, metadata: { tokenKey: match.token.key, tokenName: match.token.name }, }); break; // one annotation per hunk } } }); return annotations; }, [fileDiff, tokens, pendingChanges, rootFontSizePx]); // ── Phase 4: annotation renderer ───────────────────────────────────────── // Renders the token key as a small pill badge in the diff gutter annotation slot. const renderAnnotation = useCallback( (annotation: DiffLineAnnotation): React.ReactNode => { if (!annotation.metadata) return null; return ( {annotation.metadata.tokenKey} ); }, [], ); async function handleSendToAgent() { if (!artboardId || !fileDiff || pendingChanges.length === 0 || isSending) return; setIsSending(true); try { const result = await createDiff.mutateAsync({ artboard_id: artboardId, changes: { propChanges: pendingChanges, styleChanges: [] }, aggregate_summary: `Code diff — ${componentData?.name ?? componentId} (${pendingChanges.length} change${pendingChanges.length !== 1 ? 's' : ''})`, status: 'EXPORTED', session_id: '', exported_code: patchStr || null, }); setExportedId(result.id); setIntentRtStatus('EXPORTED'); } catch { /* mutation error shown via createDiff.isError */ } finally { setIsSending(false); } } const rtColour = intentRtStatus === 'IMPLEMENTED' ? '#7DD3A8' : intentRtStatus === 'BLOCKED' ? '#FF6B6B' : '#FFBA7B'; const rtBg = intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.10)' : intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.10)' : 'rgba(255,186,123,0.10)'; const rtBorder = intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.30)' : intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.30)' : 'rgba(255,186,123,0.30)'; const rtLabel = intentRtStatus === 'IMPLEMENTED' ? '✓ Implemented by agent' : intentRtStatus === 'BLOCKED' ? '✗ Blocked — check agent output' : intentRtStatus ?? ''; const canSend = !!fileDiff && !isSending && pendingChanges.length > 0 && !allRejected; return (
{/* ── Header ────────────────────────────────────────────────────────── */}
{shortPath ?? '—'} {componentData?.callSite?.lineNumber != null && ( :{componentData.callSite.lineNumber} )}
{componentData?.name ?? componentId} {(['split', 'unified'] as const).map(s => ( ))}
{/* ── Diff viewer ───────────────────────────────────────────────────── */}
{indexerStatus !== 'ready' ? (
CLI indexer offline

Source diffs require the CLI indexer. Run{' '} npx @originmain/cli dev {' '}to enable.

) : pendingChanges.length === 0 ? (
No pending changes
) : isLoading ? (
Generating diff…
) : diffError ? (
{diffError}
) : fileDiff ? (
{/* Token hover tooltip (Phase 4) */} {tokenTooltip && (
{tokenTooltip.tokenKey}
{tokenTooltip.tokenName}
)} fileDiff={fileDiff} lineAnnotations={lineAnnotations} renderAnnotation={renderAnnotation} options={{ diffStyle, lineDiffType: 'char', diffIndicators: 'bars', onTokenEnter: (props, event) => { void event; const match = resolveValueToToken(props.tokenText, tokens, rootFontSizePx); if (match) { const rect = props.tokenElement.getBoundingClientRect(); setTokenTooltip({ tokenKey: match.token.key, tokenName: match.token.name, x: rect.left, y: rect.bottom, }); } }, onTokenLeave: () => setTokenTooltip(null), onTokenClick: (props, event) => { void event; // Copy token key to clipboard on click. const match = resolveValueToToken(props.tokenText, tokens, rootFontSizePx); if (match) { void navigator.clipboard.writeText(match.token.key).catch(() => { /* non-fatal */ }); } }, }} style={{ fontSize: '0.5625rem' }} /> {hunkCount > 0 && ( )}
) : null}
{/* ── Footer: Realtime status + Send to Agent ───────────────────────── */}
{intentRtStatus && (
{rtLabel}
)}
); }