diff --git a/packages/ui/src/components/comments/CodeMirrorCommentWidgets.tsx b/packages/ui/src/components/comments/CodeMirrorCommentWidgets.tsx index e697182c..f3924634 100644 --- a/packages/ui/src/components/comments/CodeMirrorCommentWidgets.tsx +++ b/packages/ui/src/components/comments/CodeMirrorCommentWidgets.tsx @@ -14,6 +14,7 @@ interface CodeMirrorCommentWidgetsOptions { drafts: InlineCommentDraft[]; editingDraftId: string | null; commentText: string; + onTextChange: (text: string) => void; selection: LineRange | null; isDragging: boolean; fileLabel: string; @@ -30,6 +31,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO drafts, editingDraftId, commentText, + onTextChange, selection, isDragging, fileLabel, @@ -53,6 +55,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO void; onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void; onCancel: () => void; fileLabel?: string; @@ -19,6 +20,7 @@ export interface InlineCommentInputProps { export function InlineCommentInput({ initialText = '', + onTextChange, onSave, onCancel, fileLabel, @@ -33,6 +35,11 @@ export function InlineCommentInput({ const { isMobile } = useDeviceInfo(); const [text, setText] = React.useState(initialText); const textareaRef = useRef(null); + + const handleTextChange = (value: string) => { + setText(value); + onTextChange?.(value); + }; // Stable range snapshot to prevent race with selection clearing const stableRangeRef = useRef(lineRange); @@ -145,7 +152,7 @@ export function InlineCommentInput({ simple ref={textareaRef} value={text} - onChange={(e) => setText(e.target.value)} + onChange={(e) => handleTextChange(e.target.value)} onKeyDown={handleKeyDown} placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')} outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]" diff --git a/packages/ui/src/components/comments/PierreDiffCommentOverlays.tsx b/packages/ui/src/components/comments/PierreDiffCommentOverlays.tsx index 73b8d1d8..1b930510 100644 --- a/packages/ui/src/components/comments/PierreDiffCommentOverlays.tsx +++ b/packages/ui/src/components/comments/PierreDiffCommentOverlays.tsx @@ -12,6 +12,7 @@ interface PierreDiffCommentOverlaysProps { selection: SelectedLineRange | null; editingDraftId: string | null; commentText: string; + onTextChange: (text: string) => void; fileLabel: string; onSave: (text: string, range?: SelectedLineRange) => void; onCancel: () => void; @@ -48,6 +49,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps) selection, editingDraftId, commentText, + onTextChange, fileLabel, onSave, onCancel, @@ -178,6 +180,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps) return createPortal( = ({ mode = 'full' }) => { }); const { - drafts: filesFileDrafts, - commentText, - editingDraftId, + drafts: filesFileDrafts, + commentText, + setCommentText, + editingDraftId, setSelection: setCommentSelection, saveComment, cancel, @@ -1023,8 +1024,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (target.closest('.cm-gutterElement')) return; if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; - setLineSelection(null); - cancel(); + if (!commentText.trim()) { + setLineSelection(null); + cancel(); + } }; const timeoutId = setTimeout(() => { @@ -1035,7 +1038,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; - }, [cancel, editingDraftId, lineSelection]); + }, [cancel, commentText, editingDraftId, lineSelection]); const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { const finalRange = range ?? lineSelection ?? undefined; @@ -2964,9 +2967,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const blockWidgets = React.useMemo(() => { return buildCodeMirrorCommentWidgets({ drafts: filesFileDrafts, - editingDraftId, - commentText, - selection: lineSelection, + editingDraftId, + commentText, + onTextChange: setCommentText, + selection: lineSelection, isDragging, fileLabel: selectedFile?.path ?? '', newWidgetId: 'files-new-comment-input', @@ -2982,7 +2986,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }, onDelete: deleteDraft, }); - }, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, startEdit]); + }, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, setCommentText, startEdit]); const renderShikiFileView = React.useCallback((file: FileNode, content: string) => { return ( @@ -3762,9 +3766,23 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } event.preventDefault(); - const lineNumber = view.state.doc.lineAt(line.from).number; - - // Mobile: tap-to-extend selection + const lineNumber = view.state.doc.lineAt(line.from).number; + + if ( + lineSelection && + !event.shiftKey && + Math.min(lineSelection.start, lineSelection.end) === lineNumber && + Math.max(lineSelection.start, lineSelection.end) === lineNumber + ) { + setLineSelection(null); + cancel(); + isSelectingRef.current = false; + selectionStartRef.current = null; + setIsDragging(false); + return true; + } + + // Mobile: tap-to-extend selection if (isMobile && lineSelection && !event.shiftKey) { const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); const end = Math.max(lineSelection.start, lineSelection.end, lineNumber); diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 06bacccc..7da7caac 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -253,6 +253,7 @@ export const PierreDiffViewer: React.FC = ({ const selectionRef = useRef(null); const editingDraftIdRef = useRef(null); + const commentTextRef = useRef(''); // Use a ref to track if we're currently applying a selection programmatically // to avoid loop with onLineSelected callback const isApplyingSelectionRef = useRef(false); @@ -266,6 +267,10 @@ export const PierreDiffViewer: React.FC = ({ editingDraftIdRef.current = editingDraftId; }, [editingDraftId]); + useEffect(() => { + commentTextRef.current = commentText; + }, [commentText]); + const handleSelectionChange = useCallback((range: SelectedLineRange | null) => { // Ignore callbacks while we're programmatically applying selection if (isApplyingSelectionRef.current) { @@ -274,6 +279,10 @@ export const PierreDiffViewer: React.FC = ({ const prevSelection = selectionRef.current; + if (!range && prevSelection && commentTextRef.current.trim()) { + return; + } + // Mobile tap-to-extend: if selection exists and new tap is on same side, extend range if (isMobile && prevSelection && range && range.side === prevSelection.side) { const start = Math.min(prevSelection.start, range.start); @@ -715,6 +724,7 @@ export const PierreDiffViewer: React.FC = ({ selection={selection} editingDraftId={editingDraftId} commentText={commentText} + onTextChange={setCommentText} fileLabel={(fileName?.split('/').pop()) ?? ''} onSave={handleSaveComment} onCancel={handleCancelComment} diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 7d7a820a..189cfd01 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -277,6 +277,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { const { drafts: planFileDrafts, commentText, + setCommentText, editingDraftId, setSelection: setCommentSelection, saveComment, @@ -328,8 +329,10 @@ export const PlanView: React.FC = ({ targetPath = null }) => { if (target.closest('.cm-gutterElement')) return; if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; - setLineSelection(null); - cancel(); + if (!commentText.trim()) { + setLineSelection(null); + cancel(); + } }; const timeoutId = window.setTimeout(() => { @@ -340,7 +343,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { window.clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; - }, [cancel, editingDraftId, isMobile, lineSelection]); + }, [cancel, commentText, editingDraftId, isMobile, lineSelection]); const editorExtensions = React.useMemo(() => { const extensions = [createFlexokiCodeMirrorTheme(currentTheme)]; @@ -602,6 +605,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { drafts: planFileDrafts, editingDraftId, commentText, + onTextChange: setCommentText, selection: lineSelection, isDragging, fileLabel: planFileLabel, @@ -615,7 +619,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { }, onDelete: deleteDraft, }); - }, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, startEdit]); + }, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, setCommentText, startEdit]); return (
@@ -811,6 +815,20 @@ export const PlanView: React.FC = ({ targetPath = null }) => { event.preventDefault(); const lineNumber = view.state.doc.lineAt(line.from).number; + if ( + lineSelection && + !event.shiftKey && + Math.min(lineSelection.start, lineSelection.end) === lineNumber && + Math.max(lineSelection.start, lineSelection.end) === lineNumber + ) { + setLineSelection(null); + cancel(); + isSelectingRef.current = false; + selectionStartRef.current = null; + setIsDragging(false); + return true; + } + if (isMobile && lineSelection && !event.shiftKey) { const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);