learning_ai_common_plat/packages/ai-ui/src/CodeDiff.tsx
saravanakumardb1 87e3bc490a feat: Wave 9.E (ai-ui@0.6.0) + Wave 13.D.1/.5 (motion@0.2.1)
──────────────────────────────────────────────────────────────────
motion@0.2.1 — Parallax + TiltGallery
──────────────────────────────────────────────────────────────────
  + Parallax.tsx
      - scroll-driven translate3d via rAF + window.scroll
      - speed multiplier + axis (y / x) + reduced-motion bypass
      - listener cleanup + cancelAnimationFrame on unmount
      - WAVE 13.D.1
  + TiltGallery.tsx
      - horizontally-scrolling rail of <TiltCard>-style tiles
      - per-tile cursor-tracking rotateX/Y + glare gradient
      - role=region + arrow-key scrolling (←/→) + scroll-snap
      - reduced-motion strips tilt + glare, keeps the rail
      - WAVE 13.D.5
  + 5 new tests (Parallax x 2, TiltGallery x 3) — 28/28 passing
  + index.ts: exports both + types
  + package.json: 0.2.0 → 0.2.1

──────────────────────────────────────────────────────────────────
ai-ui@0.6.0 — Wave 9.E composition surfaces
──────────────────────────────────────────────────────────────────
  + Markdown.tsx
      - dep-free subset renderer: h1-h3 / **bold** / *italic* /
        `code` / fenced code / ul + ol / [text](url)
      - inline `[cite:<id>]` chips resolved from a citations
        registry (missing ids render as [?] — failure mode is loud)
      - WAVE 9.E.1
  + CodeDiff.tsx
      - line-LCS diff in <100 LOC, zero deps
      - split (2-col) and unified views; tinted add/del rows
      - WAVE 9.E.2
  + ExplainThis.tsx
      - listens for selectionchange, pops 'Explain' CTA over the
        selection rect when inside the wrapper + ≥ minLength chars
      - fires onExplain({ text, rect }) so hosts can open a richer
        side panel if preferred
      - WAVE 9.E.3
  + usePromptHistory.ts
      - bash-style ↑/↓ recall with localStorage persistence
        (storage key configurable; null = in-memory for tests/SSR)
      - dedupes consecutive duplicates + trims to capacity
      - WAVE 9.E.4
  + useTokenCount.ts
      - cheap estimator (default ~4 chars/token; configurable for
        code/CJK) + optional USD cost
      - memoised — stable across re-renders
      - WAVE 9.E.5
  + 19 new tests in src/__tests__/composition.test.tsx — 98/98 passing
  + index.ts: '0.6 surfaces' section exports all 5 + types
  + package.json: 0.5.0 → 0.6.0

Showcase routes + roadmap flips land in the paired showcase commit.
2026-05-27 16:59:28 -07:00

225 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { type CSSProperties } from 'react';
export interface CodeDiffProps {
/** Original code. */
before: string;
/** Modified code. */
after: string;
/** Display mode. Default `'split'` (two columns). */
view?: 'split' | 'unified';
/** Optional language hint (no syntax highlighting yet, kept for parity). */
language?: string;
/** Filename or label rendered in the header (e.g. `useChat.ts`). */
filename?: string;
className?: string;
style?: CSSProperties;
}
type DiffOp = { kind: 'eq' | 'add' | 'del'; text: string };
/**
* `<CodeDiff>` — pure-React diff renderer with `split` and `unified`
* views. Internal diff is a small line-LCS implementation (no runtime
* dependency), good enough for short hunks shown in chat surfaces and
* tracker PR cards.
*
* Wave 9.E.2. Hosts that need byte-perfect Myers diff or syntax
* highlighting should feed `<CodeDiff>` a pre-computed pair where the
* server has already done the heavy lift.
*/
export function CodeDiff({
before,
after,
view = 'split',
language,
filename,
className,
style,
}: CodeDiffProps) {
const ops = diffLines(before, after);
return (
<div
data-testid="bl-code-diff"
data-view={view}
className={className}
style={{
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 12,
overflow: 'hidden',
...style,
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 12px',
background: 'var(--bl-surface-muted, #f6f6f6)',
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
<span style={{ fontWeight: 600 }}>{filename ?? 'diff'}</span>
<span style={{ opacity: 0.6 }}>
{language ? `${language} · ` : ''}
{view}
</span>
</header>
{view === 'split' ? (
<SplitView ops={ops} />
) : (
<UnifiedView ops={ops} />
)}
</div>
);
}
function SplitView({ ops }: { ops: DiffOp[] }) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
lineHeight: 1.55,
}}
>
<div data-testid="bl-code-diff-left" style={paneStyle('left')}>
{ops.map((op, i) => {
if (op.kind === 'add') return null;
return (
<Row key={`l${i}`} kind={op.kind} side="left" text={op.text} />
);
})}
</div>
<div data-testid="bl-code-diff-right" style={paneStyle('right')}>
{ops.map((op, i) => {
if (op.kind === 'del') return null;
return (
<Row key={`r${i}`} kind={op.kind} side="right" text={op.text} />
);
})}
</div>
</div>
);
}
function UnifiedView({ ops }: { ops: DiffOp[] }) {
return (
<div
data-testid="bl-code-diff-unified"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
lineHeight: 1.55,
}}
>
{ops.map((op, i) => (
<Row key={`u${i}`} kind={op.kind} side="unified" text={op.text} />
))}
</div>
);
}
function Row({
kind,
text,
side,
}: {
kind: 'eq' | 'add' | 'del';
side: 'left' | 'right' | 'unified';
text: string;
}) {
const bg =
kind === 'add'
? 'color-mix(in srgb, var(--bl-success, #10b981) 14%, transparent)'
: kind === 'del'
? 'color-mix(in srgb, var(--bl-danger, #ef4444) 14%, transparent)'
: 'transparent';
const gutter = kind === 'add' ? '+' : kind === 'del' ? '' : ' ';
return (
<div
data-testid="bl-code-diff-row"
data-kind={kind}
data-side={side}
style={{
display: 'grid',
gridTemplateColumns: '20px 1fr',
background: bg,
padding: '0 8px',
whiteSpace: 'pre',
}}
>
<span
aria-hidden="true"
style={{ color: 'var(--bl-text-tertiary, #888)', userSelect: 'none' }}
>
{gutter}
</span>
<span>{text}</span>
</div>
);
}
function paneStyle(side: 'left' | 'right'): CSSProperties {
return {
background: 'var(--bl-surface, #fff)',
borderRight:
side === 'left'
? '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))'
: undefined,
overflowX: 'auto',
};
}
/**
* Tiny line-level LCS — O(n·m) memory, fine for hunks <2,000 lines.
* Hosts that diff full files should pre-compute with a real Myers
* implementation and split into <CodeDiff> per hunk.
*/
function diffLines(before: string, after: string): DiffOp[] {
const a = before.split('\n');
const b = after.split('\n');
const n = a.length;
const m = b.length;
// Build LCS length table.
const dp: number[][] = Array.from({ length: n + 1 }, () =>
new Array<number>(m + 1).fill(0),
);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
if (a[i] === b[j]) {
dp[i]![j] = (dp[i + 1]?.[j + 1] ?? 0) + 1;
} else {
dp[i]![j] = Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
}
}
}
// Walk back to recover ops.
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ kind: 'eq', text: a[i] ?? '' });
i++;
j++;
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
ops.push({ kind: 'del', text: a[i] ?? '' });
i++;
} else {
ops.push({ kind: 'add', text: b[j] ?? '' });
j++;
}
}
while (i < n) ops.push({ kind: 'del', text: a[i++] ?? '' });
while (j < m) ops.push({ kind: 'add', text: b[j++] ?? '' });
return ops;
}