diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index fc99cf73..54bf2274 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -1,12 +1,22 @@ -import React, { useMemo, useRef } from 'react'; +import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { FileDiff } from '@pierre/diffs/react'; -import { parseDiffFromFile, type FileContents, type FileDiffMetadata } from '@pierre/diffs'; +import { parseDiffFromFile, type FileContents, type FileDiffMetadata, type SelectedLineRange } from '@pierre/diffs'; +import { RiSendPlane2Line } from '@remixicon/react'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes'; import { flexokiThemeNames } from '@/lib/shiki/flexokiThemes'; +import { Textarea } from '@/components/ui/textarea'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useDeviceInfo } from '@/lib/device'; +import { cn, getModifierLabel } from '@/lib/utils'; + + interface PierreDiffViewerProps { original: string; modified: string; @@ -40,6 +50,18 @@ const WEBKIT_SCROLL_FIX_CSS = ` [data-code] { -webkit-overflow-scrolling: touch; } + + /* Mobile touch selection support */ + [data-line-number] { + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + cursor: pointer; + } + + /* Ensure interactive line numbers work on touch */ + pre[data-interactive-line-numbers] [data-line-number] { + touch-action: manipulation; + } /* Reduce hunk separator height */ [data-separator-content] { height: 24px !important; @@ -83,6 +105,21 @@ function getCacheKey(fileName: string, original: string, modified: string): stri return `${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`; } +const extractSelectedCode = (original: string, modified: string, range: SelectedLineRange): string => { + // Default to modified if side is ambiguous, as users mostly comment on new code + const isOriginal = range.side === 'deletions'; + const content = isOriginal ? original : modified; + const lines = content.split('\n'); + + // Ensure bounds + const startLine = Math.max(1, range.start); + const endLine = Math.min(lines.length, range.end); + + if (startLine > endLine) return ''; + + return lines.slice(startLine - 1, endLine).join('\n'); +}; + export const PierreDiffViewer: React.FC = ({ original, modified, @@ -92,9 +129,149 @@ export const PierreDiffViewer: React.FC = ({ wrapLines = false, layout = 'fill', }) => { + const isInlineLayout = layout === 'inline'; + const { isMobile } = useDeviceInfo(); + const { inputBarOffset, isKeyboardOpen } = useUIStore(); + const themeSystem = useOptionalThemeSystem(); const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark'; + const setActiveMainTab = useUIStore(state => state.setActiveMainTab); + + const [selection, setSelection] = useState(null); + const [commentText, setCommentText] = useState(''); + + + // Calculate initial center synchronously to avoid flicker + const getMainContentCenter = useCallback(() => { + if (isMobile) return '50%'; + const mainContent = document.querySelector('main.flex-1'); + if (mainContent) { + const rect = mainContent.getBoundingClientRect(); + return `${rect.left + rect.width / 2}px`; + } + return '50%'; + }, [isMobile]); + + const [mainContentCenter, setMainContentCenter] = useState(getMainContentCenter); + + const sendMessage = useSessionStore(state => state.sendMessage); + const currentSessionId = useSessionStore(state => state.currentSessionId); + const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore(); + + // Update main content center on resize + useEffect(() => { + if (isMobile) return; + + const updateCenter = () => { + setMainContentCenter(getMainContentCenter()); + }; + + window.addEventListener('resize', updateCenter); + return () => window.removeEventListener('resize', updateCenter); + }, [isMobile, getMainContentCenter]); + + const handleSelectionChange = useCallback((range: SelectedLineRange | null) => { + // On mobile: implement "tap to extend" behavior + // If user taps a new single line while we have an existing selection, extend the range + if (isMobile && range && selection && range.start === range.end) { + const tappedLine = range.start; + const existingStart = selection.start; + const existingEnd = selection.end; + + // Extend the selection to include the tapped line + const newStart = Math.min(existingStart, existingEnd, tappedLine); + const newEnd = Math.max(existingStart, existingEnd, tappedLine); + + // Only extend if tapping outside current selection + if (tappedLine < existingStart || tappedLine > existingEnd) { + setSelection({ + ...range, + start: newStart, + end: newEnd, + }); + return; + } + } + + setSelection(range); + if (!range) { + setCommentText(''); + } + }, [isMobile, selection]); + + // Dismiss selection when clicking outside line numbers (desktop behavior) + useEffect(() => { + if (!selection) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // Check if click is inside the comment UI portal + const commentUI = document.querySelector('[data-comment-ui]'); + if (commentUI?.contains(target)) return; + + // Check if click is on a line number (inside shadow DOM) + const path = e.composedPath(); + const isLineNumber = path.some((el) => { + if (el instanceof HTMLElement) { + return el.hasAttribute('data-line-number') || el.closest?.('[data-line-number]'); + } + return false; + }); + + if (!isLineNumber) { + setSelection(null); + setCommentText(''); + } + }; + + // Use timeout to avoid immediate dismissal from the same click that selected + const timeoutId = setTimeout(() => { + document.addEventListener('click', handleClickOutside); + }, 100); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('click', handleClickOutside); + }; + }, [selection]); + + const handleSendComment = useCallback(async () => { + if (!selection || !commentText.trim()) return; + if (!currentSessionId || !currentProviderId || !currentModelId) { + console.warn('Cannot send comment: no active session or model not selected'); + return; + } + + const code = extractSelectedCode(original, modified, selection); + const startLine = selection.start; + const endLine = selection.end; + const side = selection.side === 'deletions' ? 'original' : 'modified'; + + const message = `Comment on \`${fileName}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${commentText}`; + + // Clear state and switch tab immediately for responsive UX + setCommentText(''); + setSelection(null); + setActiveMainTab('chat'); + + try { + await sendMessage( + message, + currentProviderId, + currentModelId, + currentAgentName, + undefined, + undefined, + undefined, + currentVariant + ); + } catch (e) { + console.error("Failed to send comment", e); + } + }, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab]); + ensureFlexokiThemesRegistered(); // Cache the last computed diff to avoid recomputing on every render @@ -146,27 +323,186 @@ export const PierreDiffViewer: React.FC = ({ lineDiffType: 'word-alt' as const, overflow: wrapLines ? ('wrap' as const) : ('scroll' as const), disableFileHeader: true, - enableLineSelection: false, + enableLineSelection: true, enableHoverUtility: false, + onLineSelected: handleSelectionChange, unsafeCSS: WEBKIT_SCROLL_FIX_CSS, - }), [isDark, renderSideBySide, wrapLines]); - - const isInlineLayout = layout === 'inline'; + }), [isDark, renderSideBySide, wrapLines, handleSelectionChange]); if (typeof window === 'undefined') { return null; } + // Extracted Comment Interface Content for reuse in Portal or In-Flow + const renderCommentContent = () => { + if (!selection) return null; + return ( +
+
+ {/* Textarea - auto-grows from 1 line to max 5 lines */} +