From 823cefd4b5555a68c294dab48ba6249b656add41 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 13 Jun 2026 00:48:33 +0300 Subject: [PATCH] fix: preserve inline comment drafts on focus changes Keeps typed or pasted comment text when the editor remounts Prevents focus changes from dismissing active inline comments Allows cancelling by pressing the selected line number again --- .../comments/CodeMirrorCommentWidgets.tsx | 4 ++ .../comments/InlineCommentInput.tsx | 9 +++- .../comments/PierreDiffCommentOverlays.tsx | 4 ++ .../ui/src/components/views/FilesView.tsx | 44 +++++++++++++------ .../src/components/views/PierreDiffViewer.tsx | 10 +++++ packages/ui/src/components/views/PlanView.tsx | 26 +++++++++-- 6 files changed, 79 insertions(+), 18 deletions(-) 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);