+ );
+}
diff --git a/apps/web/components/git-diff-view/dom-utils.ts b/apps/web/components/git-diff-view/dom-utils.ts
new file mode 100644
index 0000000000..24a544358a
--- /dev/null
+++ b/apps/web/components/git-diff-view/dom-utils.ts
@@ -0,0 +1,309 @@
+/**
+ * DOM utilities for @git-diff-view/react library integration.
+ *
+ * This module uses the library's stable data attributes for DOM queries:
+ * - data-line: line number on
elements
+ * - data-side: "old" | "new" on rows (split view only)
+ * - data-state: "diff" | "plain" | "hunk" | "widget" | "extend"
+ *
+ * These data attributes are more stable than CSS class names across library versions.
+ *
+ * Fallback to class-based selectors is provided for unified view where
+ * data-side is not present.
+ */
+
+import { SplitSide } from '@git-diff-view/react';
+
+// Stable data attribute selectors
+export const SELECTORS = {
+ // Row with line number
+ ROW_WITH_LINE: 'tr[data-line]',
+
+ // Split view specific
+ ROW_OLD_SIDE: 'tr[data-side="old"]',
+ ROW_NEW_SIDE: 'tr[data-side="new"]',
+
+ // Fallback class selectors for unified view
+ CELL_LINE_NUM: 'td.diff-line-num',
+ CELL_OLD_NUM: 'td.diff-line-old-num',
+ CELL_NEW_NUM: 'td.diff-line-new-num',
+ CELL_OLD_CONTENT: 'td.diff-line-old-content',
+ CELL_NEW_CONTENT: 'td.diff-line-new-content',
+} as const;
+
+// Span indices in unified view line number cell
+const UNIFIED_SPAN_OLD_INDEX = 0;
+const UNIFIED_SPAN_NEW_INDEX = 2;
+
+export interface LineInfo {
+ lineNumber: number;
+ side: SplitSide;
+}
+
+/**
+ * Parse line number from data attribute or text content
+ */
+function parseLineNumber(value: string | null): number | null {
+ if (!value) return null;
+ const match = value.trim().match(/^[+-]?(\d+)$/);
+ return match ? parseInt(match[1], 10) : null;
+}
+
+/**
+ * Get line info from a row using data attributes (preferred) or class-based fallback
+ */
+function getLineInfoFromRow(
+ row: Element,
+ clickedElement: HTMLElement,
+ viewMode: 'split' | 'unified'
+): LineInfo | null {
+ if (viewMode === 'split') {
+ // Check if clicked on a placeholder cell (empty cell on one side) - return null
+ const isOldPlaceholder = clickedElement.closest('td.diff-line-old-placeholder');
+ const isNewPlaceholder = clickedElement.closest('td.diff-line-new-placeholder');
+ if (isOldPlaceholder || isNewPlaceholder) {
+ return null;
+ }
+
+ // Split view: determine which side was clicked and check if it has a line number
+ const oldNumCell = row.querySelector(SELECTORS.CELL_OLD_NUM);
+ const newNumCell = row.querySelector(SELECTORS.CELL_NEW_NUM);
+ const oldContent = row.querySelector(SELECTORS.CELL_OLD_CONTENT);
+ const newContent = row.querySelector(SELECTORS.CELL_NEW_CONTENT);
+
+ // Check if clicked on old side
+ if (oldNumCell && (oldNumCell.contains(clickedElement) || oldContent?.contains(clickedElement))) {
+ const num = parseLineNumber(oldNumCell.textContent);
+ // Only return if there's actually a line number (not an empty cell)
+ if (num !== null) return { lineNumber: num, side: SplitSide.old };
+ // Empty cell on old side - return null to skip
+ return null;
+ }
+
+ // Check if clicked on new side
+ if (newNumCell && (newNumCell.contains(clickedElement) || newContent?.contains(clickedElement))) {
+ const num = parseLineNumber(newNumCell.textContent);
+ // Only return if there's actually a line number (not an empty cell)
+ if (num !== null) return { lineNumber: num, side: SplitSide.new };
+ // Empty cell on new side - return null to skip
+ return null;
+ }
+
+ // Fallback: try data attributes
+ const dataLine = row.getAttribute('data-line');
+ const dataSide = row.getAttribute('data-side');
+ const lineNumber = parseLineNumber(dataLine);
+ if (lineNumber !== null && dataSide) {
+ return {
+ lineNumber,
+ side: dataSide === 'old' ? SplitSide.old : SplitSide.new,
+ };
+ }
+ } else {
+ // Unified view: no data-side, need to parse line number cell
+ const lineNumCell = row.querySelector(SELECTORS.CELL_LINE_NUM);
+ if (!lineNumCell) return null;
+
+ const spans = lineNumCell.querySelectorAll('span');
+
+ // Unified view span layout: [old number] [separator] [new number]
+ let oldNum: number | null = null;
+ let newNum: number | null = null;
+
+ if (spans[UNIFIED_SPAN_OLD_INDEX]) {
+ oldNum = parseLineNumber(spans[UNIFIED_SPAN_OLD_INDEX].textContent);
+ }
+ if (spans[UNIFIED_SPAN_NEW_INDEX]) {
+ newNum = parseLineNumber(spans[UNIFIED_SPAN_NEW_INDEX].textContent);
+ }
+
+ // Prefer new side if available (additions show on new side)
+ if (newNum !== null) {
+ return { lineNumber: newNum, side: SplitSide.new };
+ }
+ if (oldNum !== null) {
+ return { lineNumber: oldNum, side: SplitSide.old };
+ }
+
+ // Final fallback: parse all text
+ const text = lineNumCell.textContent?.trim() || '';
+ const numbers = text.match(/\d+/g);
+ if (numbers && numbers.length > 0) {
+ const num = parseInt(numbers[numbers.length - 1], 10);
+ return {
+ lineNumber: num,
+ side: numbers.length > 1 ? SplitSide.new : SplitSide.old,
+ };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Extract line number and side from a DOM element within the diff viewer
+ */
+export function getLineInfoFromElement(
+ element: HTMLElement,
+ wrapper: HTMLElement | null,
+ viewMode: 'split' | 'unified'
+): LineInfo | null {
+ let current: HTMLElement | null = element;
+
+ while (current && current !== wrapper) {
+ const row = current.closest('tr');
+ if (row) {
+ const info = getLineInfoFromRow(row, element, viewMode);
+ if (info) return info;
+ }
+ current = current.parentElement;
+ }
+
+ return null;
+}
+
+export interface RowWithLineNumber {
+ row: HTMLElement;
+ lineNumber: number;
+ rowIndex: number; // Index in DOM for detecting visual gaps
+}
+
+/**
+ * Find all rows in a line number range for overlay positioning
+ */
+export function findRowsInRange(
+ wrapper: HTMLElement,
+ viewMode: 'split' | 'unified',
+ side: SplitSide,
+ startLine: number,
+ endLine: number
+): HTMLElement[] {
+ return findRowsInRangeWithLineNumbers(wrapper, viewMode, side, startLine, endLine).map(r => r.row);
+}
+
+/**
+ * Find all rows in a line number range, returning both row and line number
+ * This allows grouping by consecutive line numbers for split view
+ */
+export function findRowsInRangeWithLineNumbers(
+ wrapper: HTMLElement,
+ viewMode: 'split' | 'unified',
+ side: SplitSide,
+ startLine: number,
+ endLine: number
+): RowWithLineNumber[] {
+ const items: RowWithLineNumber[] = [];
+
+ if (viewMode === 'split') {
+ // Get all rows to track row indices
+ const allRows = Array.from(wrapper.querySelectorAll('tr'));
+ const rowIndexMap = new Map();
+ allRows.forEach((row, idx) => rowIndexMap.set(row as HTMLElement, idx));
+
+ // Split view: use class-based selectors to get line numbers from cells
+ const sideClass = side === SplitSide.old ? SELECTORS.CELL_OLD_NUM : SELECTORS.CELL_NEW_NUM;
+ const lineNumCells = wrapper.querySelectorAll(sideClass);
+
+ lineNumCells.forEach((cell) => {
+ const num = parseLineNumber(cell.textContent);
+ if (num !== null && num >= startLine && num <= endLine) {
+ const row = cell.closest('tr') as HTMLElement;
+ if (row) {
+ const rowIndex = rowIndexMap.get(row) ?? -1;
+ items.push({ row, lineNumber: num, rowIndex });
+ }
+ }
+ });
+
+ // Sort by row index to ensure DOM order
+ items.sort((a, b) => a.rowIndex - b.rowIndex);
+ } else {
+ // Unified view: iterate through all line rows
+ const allRows = Array.from(wrapper.querySelectorAll('tr'));
+ const rowIndexMap = new Map();
+ allRows.forEach((row, idx) => rowIndexMap.set(row, idx));
+
+ const rows = wrapper.querySelectorAll(SELECTORS.ROW_WITH_LINE);
+
+ rows.forEach((row) => {
+ const lineNumCell = row.querySelector(SELECTORS.CELL_LINE_NUM);
+ if (!lineNumCell) return;
+
+ const spans = lineNumCell.querySelectorAll('span');
+ let targetNum: number | null = null;
+
+ // Check the appropriate span based on side - NO fallback to avoid matching wrong side
+ const spanIndex = side === SplitSide.old ? UNIFIED_SPAN_OLD_INDEX : UNIFIED_SPAN_NEW_INDEX;
+ if (spans[spanIndex]) {
+ targetNum = parseLineNumber(spans[spanIndex].textContent);
+ }
+
+ // Only use fallback if there are no spans (old library version)
+ if (targetNum === null && spans.length === 0) {
+ const text = lineNumCell.textContent?.trim() || '';
+ const numbers = text.match(/\d+/g);
+ if (numbers && numbers.length > 0) {
+ const idx = side === SplitSide.old ? 0 : numbers.length - 1;
+ targetNum = parseInt(numbers[idx], 10);
+ }
+ }
+
+ if (targetNum !== null && targetNum >= startLine && targetNum <= endLine) {
+ const rowIndex = rowIndexMap.get(row) ?? -1;
+ items.push({ row: row as HTMLElement, lineNumber: targetNum, rowIndex });
+ }
+ });
+
+ // Sort by row index
+ items.sort((a, b) => a.rowIndex - b.rowIndex);
+ }
+
+ // Remove duplicates based on row
+ const seen = new Set();
+ return items.filter(item => {
+ if (seen.has(item.row)) return false;
+ seen.add(item.row);
+ return true;
+ });
+}
+
+/**
+ * Find a specific row by line number for widget positioning
+ */
+export function findRowByLineNumber(
+ wrapper: HTMLElement,
+ viewMode: 'split' | 'unified',
+ side: SplitSide,
+ lineNumber: number
+): HTMLElement | null {
+ const rows = findRowsInRange(wrapper, viewMode, side, lineNumber, lineNumber);
+ return rows[0] || null;
+}
+
+/**
+ * Group rows into contiguous ranges based on consecutive ROW INDICES.
+ * This detects visual gaps caused by rows with empty cells on the selected side.
+ * Used for split view to show separate overlays for non-adjacent rows.
+ */
+export function groupByConsecutiveRowIndices(items: RowWithLineNumber[]): HTMLElement[][] {
+ if (items.length === 0) return [];
+
+ const groups: HTMLElement[][] = [];
+ let currentGroup: HTMLElement[] = [items[0].row];
+ let lastRowIndex = items[0].rowIndex;
+
+ for (let i = 1; i < items.length; i++) {
+ const { row, rowIndex } = items[i];
+
+ if (rowIndex === lastRowIndex + 1) {
+ currentGroup.push(row);
+ } else {
+ groups.push(currentGroup);
+ currentGroup = [row];
+ }
+ lastRowIndex = rowIndex;
+ }
+
+ groups.push(currentGroup);
+ return groups;
+}
diff --git a/apps/web/components/git-diff-view/git-diff-viewer.css b/apps/web/components/git-diff-view/git-diff-viewer.css
new file mode 100644
index 0000000000..a36200f70b
--- /dev/null
+++ b/apps/web/components/git-diff-view/git-diff-viewer.css
@@ -0,0 +1,33 @@
+/* Custom styles for @git-diff-view/react demo */
+
+/* CSS Custom Properties */
+:root {
+ --diff-selection-bg: oklch(0.85 0.08 260 / 30%);
+ --diff-selection-border: oklch(0.6 0.15 260);
+}
+
+.dark {
+ --diff-selection-bg: oklch(0.35 0.08 260 / 40%);
+ --diff-selection-border: oklch(0.65 0.15 260);
+}
+
+/* Multi-line selection overlay */
+.diff-multiline-selection-overlay {
+ position: absolute;
+ background-color: var(--diff-selection-bg);
+ border: 2px solid var(--diff-selection-border);
+ border-radius: 2px;
+ pointer-events: none;
+ z-index: 10;
+}
+
+/* Ensure proper reset for color in widget/extend wrappers */
+.diff-line-extend-wrapper,
+.diff-line-widget-wrapper {
+ color: inherit;
+}
+
+.diff-line-extend-wrapper *,
+.diff-line-widget-wrapper * {
+ color: inherit;
+}
diff --git a/apps/web/components/git-diff-view/git-diff-viewer.tsx b/apps/web/components/git-diff-view/git-diff-viewer.tsx
new file mode 100644
index 0000000000..11663ac84c
--- /dev/null
+++ b/apps/web/components/git-diff-view/git-diff-viewer.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
+import { DiffView, DiffModeEnum, SplitSide } from '@git-diff-view/react';
+import { generateDiffFile } from '@git-diff-view/file';
+import { useTheme } from 'next-themes';
+import { Button } from '@kandev/ui/button';
+import { Badge } from '@kandev/ui/badge';
+import { IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react';
+import { cn } from '@/lib/utils';
+
+import { useDiffComments } from './use-diff-comments';
+import { InlineCommentWidget } from './comment-widget';
+import { CommentRenderer } from './comment-renderer';
+import { DragSelectionOverlay } from './selection-overlay';
+import { CommentRangeIndicator } from './comment-indicator';
+import { PositionedCommentWidget } from './multi-line-comment';
+import { getLineInfoFromElement } from './dom-utils';
+import type { GitDiffViewerProps, ExtendLineData, DragSelectionState } from './types';
+
+import '@git-diff-view/react/styles/diff-view.css';
+import './git-diff-viewer.css';
+
+export function GitDiffViewer({
+ oldContent,
+ newContent,
+ filePath,
+ language = 'typescript',
+ defaultViewMode = 'split',
+ showToolbar = true,
+ enableComments = true,
+ comments: externalComments,
+ onCommentAdd,
+ onCommentDelete,
+ className,
+}: GitDiffViewerProps) {
+ const { resolvedTheme } = useTheme();
+ const [viewMode, setViewMode] = useState<'split' | 'unified'>(defaultViewMode);
+ const [widgetStartLines, setWidgetStartLines] = useState>({});
+ const widgetStartLinesRef = useRef(widgetStartLines);
+
+ useEffect(() => {
+ widgetStartLinesRef.current = widgetStartLines;
+ }, [widgetStartLines]);
+
+ // Drag selection state for multi-line comments
+ const [dragSelection, setDragSelection] = useState(null);
+ const [showDragWidget, setShowDragWidget] = useState(false);
+ const justCompletedMultiLineRef = useRef(false);
+
+ const wrapperRef = useRef(null);
+
+ // Comment management
+ const { comments, addComment, deleteComment } = useDiffComments({
+ filePath,
+ externalComments,
+ onCommentAdd,
+ onCommentDelete,
+ });
+
+ const diffModeEnum = viewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified;
+ const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
+
+ // Generate diff file
+ const diffFile = useMemo(() => {
+ try {
+ const file = generateDiffFile(filePath, oldContent, filePath, newContent, language, language);
+ file.initRaw();
+ return file;
+ } catch (e) {
+ console.error('Failed to generate diff:', e);
+ return null;
+ }
+ }, [filePath, oldContent, newContent, language]);
+
+ // Build extendData for displaying existing comments
+ const extendData = useMemo(() => {
+ const oldFileData: Record = {};
+ const newFileData: Record = {};
+
+ comments.forEach((comment) => {
+ const lineKey = String(comment.endLine);
+ const entry: ExtendLineData = { type: 'comment', comment };
+
+ if (comment.side === SplitSide.old) {
+ oldFileData[lineKey] = { data: entry };
+ } else {
+ newFileData[lineKey] = { data: entry };
+ }
+ });
+
+ return { oldFile: oldFileData, newFile: newFileData };
+ }, [comments]);
+
+ // Mouse handlers for drag selection
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if (!enableComments || e.button !== 0) return;
+
+ const target = e.target as HTMLElement;
+ const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode);
+
+ if (lineInfo) {
+ setDragSelection({
+ startLine: lineInfo.lineNumber,
+ endLine: lineInfo.lineNumber,
+ side: lineInfo.side,
+ isActive: true,
+ });
+ setShowDragWidget(false);
+ justCompletedMultiLineRef.current = false;
+ }
+ },
+ [enableComments, viewMode]
+ );
+
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (!dragSelection?.isActive) return;
+
+ const target = e.target as HTMLElement;
+ const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode);
+
+ if (lineInfo && lineInfo.side === dragSelection.side && lineInfo.lineNumber !== dragSelection.endLine) {
+ setDragSelection((prev) => (prev ? { ...prev, endLine: lineInfo.lineNumber } : null));
+ }
+ },
+ [dragSelection, viewMode]
+ );
+
+ const handleMouseUp = useCallback(() => {
+ if (!dragSelection?.isActive) return;
+
+ const { startLine, endLine, side } = dragSelection;
+ const rangeStart = Math.min(startLine, endLine);
+ const rangeEnd = Math.max(startLine, endLine);
+
+ if (rangeStart !== rangeEnd) {
+ setDragSelection({ startLine: rangeStart, endLine: rangeEnd, side, isActive: false });
+ setShowDragWidget(true);
+ justCompletedMultiLineRef.current = true;
+ } else {
+ setDragSelection(null);
+ }
+ }, [dragSelection]);
+
+ const handleMouseLeave = useCallback(() => {
+ if (dragSelection?.isActive) {
+ setDragSelection(null);
+ }
+ }, [dragSelection]);
+
+ // Prevent library's single-line widget after multi-line selection
+ const handleClick = useCallback((e: React.MouseEvent) => {
+ if (justCompletedMultiLineRef.current) {
+ e.stopPropagation();
+ e.preventDefault();
+ justCompletedMultiLineRef.current = false;
+ }
+ }, []);
+
+ // Handle widget line rendering for single-line comments
+ const renderWidgetLine = useCallback(
+ (props: { side: SplitSide; lineNumber: number; onClose: () => void }) => {
+ if (!diffFile || !enableComments) return null;
+
+ const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`;
+ const startLine = widgetStartLinesRef.current[widgetKey] ?? props.lineNumber;
+
+ return (
+ {
+ addComment(comment.startLine, comment.endLine, comment.side, comment.content);
+ setWidgetStartLines((prev) => {
+ const next = { ...prev };
+ delete next[widgetKey];
+ return next;
+ });
+ props.onClose();
+ }}
+ onCancel={() => {
+ setWidgetStartLines((prev) => {
+ const next = { ...prev };
+ delete next[widgetKey];
+ return next;
+ });
+ props.onClose();
+ }}
+ onStartLineChange={(newStartLine) => {
+ setWidgetStartLines((prev) => ({ ...prev, [widgetKey]: newStartLine }));
+ }}
+ />
+ );
+ },
+ [filePath, diffFile, viewMode, enableComments, addComment]
+ );
+
+ // Render existing comments inline
+ const renderExtendLine = useCallback(
+ (lineData: { data: ExtendLineData }) => {
+ if (!lineData.data) return null;
+ return deleteComment(id)} />;
+ },
+ [deleteComment]
+ );
+
+ if (!diffFile) {
+ return (
+