made tiny updates

This commit is contained in:
SinachPat
2026-05-06 22:11:40 +01:00
parent f21969f018
commit 1fc2702a4b
36 changed files with 2455 additions and 1558 deletions
@@ -0,0 +1,645 @@
'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<string, string> = {
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 (
<div style={{ marginBottom: 8, padding: '6px 8px', background: T.bgDeep, borderRadius: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color }}>
{diff.status}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: T.dim }}>
{count} change{count !== 1 ? 's' : ''}
</span>
</div>
{diff.aggregate_summary && (
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: T.fgMuted, lineHeight: 1.4, display: 'block' }}>
{diff.aggregate_summary}
</span>
)}
</div>
);
}
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) => (
<div
key={i}
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem', padding: '4px 8px',
borderRadius: 4, marginBottom: 3, lineHeight: 1.55,
background: r.op === 'del' ? 'rgba(255,70,70,0.08)' : 'rgba(70,220,120,0.08)',
color: r.op === 'del' ? '#FF8080' : '#7DDBA0',
borderLeft: `2px solid ${r.op === 'del' ? 'rgba(255,80,80,0.3)' : 'rgba(70,220,120,0.3)'}`,
}}
>
{r.text}
</div>
))}
</>
);
}
// ── 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<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: hunks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 26,
overscan: 3,
});
return (
<div
style={{
padding: '8px 12px', borderTop: `1px solid ${T.border}`,
display: 'flex', flexDirection: 'column', gap: 5,
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: T.dim, letterSpacing: '0.07em', textTransform: 'uppercase', marginBottom: 2,
}}>
{hunks.length} hunk{hunks.length !== 1 ? 's' : ''}
{allRejected && <span style={{ color: '#FF8080', marginLeft: 6 }}> all rejected</span>}
</span>
{/* Scrollable virtual container — capped at 160px so it doesn't crowd the diff */}
<div ref={scrollRef} style={{ maxHeight: 160, overflowY: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => (
<div
key={vItem.index}
data-index={vItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute', top: 0, left: 0, width: '100%',
transform: `translateY(${vItem.start}px)`,
display: 'flex', alignItems: 'center', gap: 5, paddingBottom: 4,
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: T.fgMuted, flex: 1,
}}>
Hunk {vItem.index + 1}
</span>
<button
onClick={() => setFileDiff(diffAcceptRejectHunk(fileDiff, vItem.index, 'accept'))}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: '#7DD3A8', background: 'rgba(125,211,168,0.08)',
border: '1px solid rgba(125,211,168,0.28)', borderRadius: 3,
padding: '2px 7px', cursor: 'pointer',
}}
>
accept
</button>
<button
onClick={() => handleRejectHunk(vItem.index)}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: '#FF6B6B', background: 'rgba(255,107,107,0.08)',
border: '1px solid rgba(255,107,107,0.28)', borderRadius: 3,
padding: '2px 7px', cursor: 'pointer',
}}
>
reject
</button>
</div>
))}
</div>
</div>
</div>
);
}
// ── 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<FileDiffMetadata | null>(null);
const [patchStr, setPatchStr] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [exportedId, setExportedId] = useState<string | null>(null);
const [intentRtStatus, setIntentRtStatus] = useState<string | null>(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<Set<number>>(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<string, unknown> }) => {
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 (
<div style={{ padding: '32px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center' }}>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.22 }}>
<polyline points="7,9 2,14 7,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<polyline points="21,9 26,14 21,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<line x1="16" y1="6" x2="12" y2="22" stroke="white" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
Select a component to<br/>view its source
</span>
</div>
);
}
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<DiffLineAnnotation<TokenAnnotationMeta>[]>(() => {
if (!fileDiff || !tokens.length) return [];
const annotations: DiffLineAnnotation<TokenAnnotationMeta>[] = [];
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<TokenAnnotationMeta>): React.ReactNode => {
if (!annotation.metadata) return null;
return (
<span
title={annotation.metadata.tokenName}
style={{
display: 'inline-flex', alignItems: 'center',
padding: '1px 5px', borderRadius: 3, flexShrink: 0, whiteSpace: 'nowrap',
background: 'rgba(125,211,168,0.10)',
border: '1px solid rgba(125,211,168,0.28)',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.45rem', color: '#7DD3A8', letterSpacing: '0.04em', lineHeight: 1.2,
}}
>
{annotation.metadata.tokenKey}
</span>
);
},
[],
);
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* ── Header ────────────────────────────────────────────────────────── */}
<div style={{
padding: '9px 12px', borderBottom: `1px solid ${T.border}`,
flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.fgMuted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortPath ?? '—'}
{componentData?.callSite?.lineNumber != null && (
<span style={{ color: T.dim }}>:{componentData.callSite.lineNumber}</span>
)}
</span>
<span
title={indexerStatus === 'ready' ? 'CLI indexer ready' : indexerStatus === 'indexing' ? 'Indexing…' : 'CLI indexer offline'}
style={{
display: 'inline-block', width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
background: indexerStatus === 'ready' ? '#7DD3A8' : indexerStatus === 'indexing' ? '#FFBA7B' : T.dim,
boxShadow: indexerStatus === 'ready' ? '0 0 5px rgba(125,211,168,0.7)' : 'none',
transition: 'background 0.25s',
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
color: T.accent, background: T.accentBg,
border: `1px solid ${T.accent}33`, borderRadius: 4, padding: '1px 6px',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 100,
}}>
{componentData?.name ?? componentId}
</span>
<span style={{ flex: 1 }} />
{(['split', 'unified'] as const).map(s => (
<button
key={s}
onClick={() => setDiffStyle(s)}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
letterSpacing: '0.04em', textTransform: 'uppercase',
color: diffStyle === s ? T.accent : T.dim,
background: diffStyle === s ? T.accentBg : 'transparent',
border: `1px solid ${diffStyle === s ? T.accent + '44' : 'transparent'}`,
borderRadius: 3, padding: '2px 6px', cursor: 'pointer',
transition: 'color 0.15s, background 0.15s',
}}
>
{s}
</button>
))}
</div>
</div>
{/* ── Diff viewer ───────────────────────────────────────────────────── */}
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{indexerStatus !== 'ready' ? (
<div style={{ padding: '20px 14px' }}>
<div style={{ padding: '10px 12px', background: 'rgba(255,186,123,0.06)', border: '1px solid rgba(255,186,123,0.2)', borderRadius: 7 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: '#FFBA7B', flexShrink: 0 }} />
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FFBA7B', letterSpacing: '0.04em' }}>CLI indexer offline</span>
</div>
<p style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: T.fgDim, lineHeight: 1.6, margin: 0 }}>
Source diffs require the CLI indexer. Run{' '}
<code style={{ fontFamily: "'JetBrains Mono', monospace", color: '#FFBA7B', fontSize: '0.5rem' }}>
npx @originmain/cli dev
</code>{' '}to enable.
</p>
</div>
</div>
) : pendingChanges.length === 0 ? (
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em' }}>
No pending changes
</span>
</div>
) : isLoading ? (
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim }}>Generating diff</span>
</div>
) : diffError ? (
<div style={{ padding: '14px', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FF6B6B' }}>
{diffError}
</div>
) : fileDiff ? (
<div style={{ position: 'relative' }}>
{/* Token hover tooltip (Phase 4) */}
{tokenTooltip && (
<div
style={{
position: 'fixed',
left: tokenTooltip.x, top: tokenTooltip.y + 4,
zIndex: 9999,
padding: '4px 8px', borderRadius: 5,
background: '#1A1A22', border: '1px solid rgba(125,211,168,0.3)',
pointerEvents: 'none',
}}
>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: '#7DD3A8' }}>
{tokenTooltip.tokenKey}
</div>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.45rem', color: 'rgba(255,255,255,0.4)', marginTop: 1 }}>
{tokenTooltip.tokenName}
</div>
</div>
)}
<PierreDiff<TokenAnnotationMeta>
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 && (
<HunkList
hunks={fileDiff.hunks}
allRejected={allRejected}
fileDiff={fileDiff}
handleRejectHunk={handleRejectHunk}
setFileDiff={setFileDiff}
/>
)}
</div>
) : null}
</div>
{/* ── Footer: Realtime status + Send to Agent ───────────────────────── */}
<div style={{ padding: '9px 12px', borderTop: `1px solid ${T.border}`, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
{intentRtStatus && (
<div style={{ padding: '4px 9px', background: rtBg, border: `1px solid ${rtBorder}`, borderRadius: 5 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: rtColour, letterSpacing: '0.02em' }}>
{rtLabel}
</span>
</div>
)}
<button
onClick={() => void handleSendToAgent()}
disabled={!canSend}
style={{
width: '100%', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
background: canSend ? T.accent : T.bgDeep,
color: canSend ? '#fff' : T.dim,
border: 'none', borderRadius: 6, padding: '7px 0',
cursor: canSend ? 'pointer' : 'not-allowed',
transition: 'background 0.15s',
}}
>
{isSending ? 'Sending…' : 'Send to Agent'}
</button>
</div>
</div>
);
}