Add edit comment in plan and diff panel (#328)

* feat: add view ready and destroy callbacks to CodeMirrorEditor

Provide onViewReady callback that receives the EditorView once created
Provide onViewDestroy callback invoked when the editor is destroyed

* feat: render inline drafts in PlanView with portal

Add editorView state to track the active CodeMirror instance
Render inline drafts through a portal anchored to the editor scroll container
Position drafts relative to the corresponding start line in the editor

* feat(PierreDiffViewer): enable editing inline diff drafts

Enable editing of existing inline diff drafts with draftID handling
Auto-focus comment input when opening editor and place cursor at end

* feat: enable editing inline drafts and auto focus

Enable editing of existing inline drafts via new editingDraftId state and updateDraft
Auto focus the comment input and place cursor at end when a line is selected
Add edit action button in the inline actions bar to start editing

* feat: add updateDraft to inline comment drafts store

Add updateDraft action to modify a specific draft by session and id
Merge updates into the target draft without altering other fields
Retains existing draft management actions like remove and clear
This commit is contained in:
Nelson Pires
2026-02-06 02:51:03 +02:00
committed by GitHub
parent fd08cc4e5d
commit 16d7a1820b
4 changed files with 217 additions and 39 deletions
@@ -16,6 +16,8 @@ type CodeMirrorEditorProps = {
readOnly?: boolean;
lineNumbersConfig?: Parameters<typeof lineNumbers>[0];
highlightLines?: { start: number; end: number };
onViewReady?: (view: EditorView) => void;
onViewDestroy?: () => void;
};
const lineNumbersCompartment = new Compartment();
@@ -55,11 +57,23 @@ const createHighlightLinesExtension = (range?: { start: number; end: number }):
}, { decorations: (v) => v.decorations });
};
export function CodeMirrorEditor({ value, onChange, extensions, className, readOnly, lineNumbersConfig, highlightLines }: CodeMirrorEditorProps) {
export function CodeMirrorEditor({
value,
onChange,
extensions,
className,
readOnly,
lineNumbersConfig,
highlightLines,
onViewReady,
onViewDestroy,
}: CodeMirrorEditorProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
const valueRef = React.useRef(value);
const onChangeRef = React.useRef(onChange);
const onViewReadyRef = React.useRef(onViewReady);
const onViewDestroyRef = React.useRef(onViewDestroy);
React.useEffect(() => {
valueRef.current = value;
@@ -69,6 +83,11 @@ export function CodeMirrorEditor({ value, onChange, extensions, className, readO
onChangeRef.current = onChange;
}, [onChange]);
React.useEffect(() => {
onViewReadyRef.current = onViewReady;
onViewDestroyRef.current = onViewDestroy;
}, [onViewReady, onViewDestroy]);
React.useEffect(() => {
if (!hostRef.current) {
return;
@@ -101,7 +120,12 @@ export function CodeMirrorEditor({ value, onChange, extensions, className, readO
parent: hostRef.current,
});
if (viewRef.current) {
onViewReadyRef.current?.(viewRef.current);
}
return () => {
onViewDestroyRef.current?.();
viewRef.current?.destroy();
viewRef.current = null;
};
@@ -1,7 +1,7 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange } from '@pierre/diffs';
import { RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
import { RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -173,7 +173,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = useState('');
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
const [pendingFocus, setPendingFocus] = useState(false);
const commentContainerRef = useRef<HTMLDivElement>(null);
const commentInputRef = useRef<HTMLTextAreaElement | null>(null);
// Refs to prevent infinite loops when syncing selection with diff instance
const selectionRef = useRef<SelectedLineRange | null>(null);
@@ -208,6 +211,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
// Inline comment drafts
const addDraft = useInlineCommentDraftStore(state => state.addDraft);
const updateDraft = useInlineCommentDraftStore(state => state.updateDraft);
const removeDraft = useInlineCommentDraftStore(state => state.removeDraft);
const allDrafts = useInlineCommentDraftStore(state => state.drafts);
@@ -281,6 +285,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
setSelection(range);
if (!range) {
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
}
}, [isMobile]);
@@ -309,6 +315,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
if (!isLineNumber) {
setSelection(null);
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
}
};
@@ -326,8 +334,25 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const handleCancelComment = useCallback(() => {
setCommentText('');
setSelection(null);
setEditingDraftId(null);
setPendingFocus(false);
}, []);
useEffect(() => {
if (!pendingFocus || !selection || isMobile) return;
if (typeof window === 'undefined') return;
const frame = window.requestAnimationFrame(() => {
const input = commentInputRef.current;
input?.focus();
if (input) {
const length = input.value.length;
input.setSelectionRange(length, length);
}
setPendingFocus(false);
});
return () => window.cancelAnimationFrame(frame);
}, [pendingFocus, selection, isMobile]);
const handleSaveComment = useCallback(() => {
if (!selection || !commentText.trim()) return;
@@ -340,24 +365,52 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const code = extractSelectedCode(original, modified, selection);
const side = selection.side === 'deletions' ? 'original' : 'modified';
addDraft({
sessionKey,
source: 'diff',
fileLabel: fileName,
startLine: selection.start,
endLine: selection.end,
side,
code,
language,
text: commentText.trim(),
});
if (editingDraftId) {
updateDraft(sessionKey, editingDraftId, {
fileLabel: fileName,
startLine: selection.start,
endLine: selection.end,
side,
code,
language,
text: commentText.trim(),
});
} else {
addDraft({
sessionKey,
source: 'diff',
fileLabel: fileName,
startLine: selection.start,
endLine: selection.end,
side,
code,
language,
text: commentText.trim(),
});
}
// Clear selection and comment text
setCommentText('');
setSelection(null);
setEditingDraftId(null);
toast.success('Comment saved');
}, [selection, commentText, original, modified, fileName, language, addDraft, getSessionKey]);
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
}, [selection, commentText, original, modified, fileName, language, addDraft, updateDraft, getSessionKey, editingDraftId]);
const applySelection = useCallback((range: SelectedLineRange) => {
setSelection(range);
const instance = diffInstanceRef.current;
if (!instance) return;
try {
isApplyingSelectionRef.current = true;
instance.setSelectedLines(range);
lastAppliedSelectionRef.current = range;
} catch {
// ignore
} finally {
isApplyingSelectionRef.current = false;
}
}, []);
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
@@ -558,6 +611,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
>
{/* Textarea - auto-grows from 1 line to max 5 lines */}
<Textarea
ref={commentInputRef}
value={commentText}
onChange={(e) => {
setCommentText(e.target.value);
@@ -616,7 +670,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
color: currentTheme?.colors?.status?.successForeground,
}}
>
Comment
{editingDraftId ? 'Save' : 'Comment'}
</Button>
</div>
</div>
@@ -670,7 +724,26 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
<RiMoreLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onClick={() => {
const selectionSide = draft.side === 'original' ? 'deletions' : 'additions';
applySelection({
start: draft.startLine,
end: draft.endLine,
side: selectionSide,
});
setCommentText(draft.text);
setEditingDraftId(draft.id);
setPendingFocus(true);
}}
>
<RiEditLine className="h-4 w-4 mr-2" />
Edit comment
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => removeDraft(draft.sessionKey, draft.id)}
className="text-destructive"
+80 -22
View File
@@ -14,7 +14,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
@@ -27,6 +27,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { createPortal } from 'react-dom';
const normalize = (value: string): string => {
if (!value) return '';
@@ -96,9 +97,11 @@ export const PlanView: React.FC = () => {
const { isMobile } = useDeviceInfo();
const { currentTheme } = useThemeSystem();
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const [editorView, setEditorView] = React.useState<EditorView | null>(null);
// Inline comment drafts
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
@@ -134,6 +137,9 @@ export const PlanView: React.FC = () => {
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = React.useState('');
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
const [pendingFocus, setPendingFocus] = React.useState(false);
const commentInputRef = React.useRef<HTMLTextAreaElement | null>(null);
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
@@ -173,8 +179,25 @@ export const PlanView: React.FC = () => {
React.useEffect(() => {
setLineSelection(null);
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
}, [content]);
React.useEffect(() => {
if (!pendingFocus || !lineSelection || isMobile) return;
if (typeof window === 'undefined') return;
const frame = window.requestAnimationFrame(() => {
const input = commentInputRef.current;
input?.focus();
if (input) {
const length = input.value.length;
input.setSelectionRange(length, length);
}
setPendingFocus(false);
});
return () => window.cancelAnimationFrame(frame);
}, [pendingFocus, lineSelection, isMobile]);
React.useEffect(() => {
if (!lineSelection) return;
@@ -186,6 +209,8 @@ export const PlanView: React.FC = () => {
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
setLineSelection(null);
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
};
const timeoutId = window.setTimeout(() => {
@@ -209,6 +234,8 @@ export const PlanView: React.FC = () => {
const handleCancelComment = React.useCallback(() => {
setCommentText('');
setLineSelection(null);
setEditingDraftId(null);
setPendingFocus(false);
}, []);
const handleSaveComment = React.useCallback(() => {
@@ -224,22 +251,34 @@ export const PlanView: React.FC = () => {
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
addDraft({
sessionKey,
source: 'plan',
fileLabel,
startLine: lineSelection.start,
endLine: lineSelection.end,
code,
language,
text: commentText.trim(),
});
if (editingDraftId) {
updateDraft(sessionKey, editingDraftId, {
fileLabel,
startLine: lineSelection.start,
endLine: lineSelection.end,
code,
language,
text: commentText.trim(),
});
} else {
addDraft({
sessionKey,
source: 'plan',
fileLabel,
startLine: lineSelection.start,
endLine: lineSelection.end,
code,
language,
text: commentText.trim(),
});
}
setCommentText('');
setLineSelection(null);
setEditingDraftId(null);
toast.success('Comment saved');
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey, extractSelectedCode]);
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
const editorExtensions = React.useMemo(() => {
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
@@ -358,6 +397,7 @@ export const PlanView: React.FC = () => {
>
<div className="w-full rounded-xl border bg-background flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
<Textarea
ref={commentInputRef}
value={commentText}
onChange={(e) => {
setCommentText(e.target.value);
@@ -414,7 +454,7 @@ export const PlanView: React.FC = () => {
color: currentTheme?.colors?.status?.successForeground,
}}
>
Comment
{editingDraftId ? 'Save' : 'Comment'}
</Button>
</div>
</div>
@@ -426,6 +466,7 @@ export const PlanView: React.FC = () => {
// Render saved comment cards
const renderSavedComments = () => {
if (mdViewMode === 'preview') return null;
if (!editorView) return null;
const sessionKey = getSessionKey();
if (!sessionKey) return null;
@@ -436,13 +477,13 @@ export const PlanView: React.FC = () => {
if (fileDrafts.length === 0) return null;
return (
return createPortal(
<div className="absolute inset-0 pointer-events-none z-40">
{fileDrafts.map((draft) => {
// For CodeMirror, we need to position based on line
// This is a simplified version - in production, you'd query the editor's DOM
const lineHeight = 24; // Approximate line height in pixels
const top = (draft.startLine - 1) * lineHeight;
const maxLine = editorView.state.doc.lines;
const safeLine = Math.min(Math.max(1, draft.startLine), maxLine);
const line = editorView.state.doc.line(safeLine);
const top = editorView.lineBlockAt(line.from).top;
return (
<div
@@ -477,7 +518,21 @@ export const PlanView: React.FC = () => {
<RiMoreLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onClick={() => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
setPendingFocus(true);
}}
>
<RiEditLine className="h-4 w-4 mr-2" />
Edit comment
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => removeDraft(draft.sessionKey, draft.id)}
className="text-destructive"
@@ -492,7 +547,8 @@ export const PlanView: React.FC = () => {
</div>
);
})}
</div>
</div>,
editorView.scrollDOM
);
};
@@ -611,8 +667,10 @@ export const PlanView: React.FC = () => {
// read-only
}}
readOnly={true}
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)]"
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
extensions={editorExtensions}
onViewReady={setEditorView}
onViewDestroy={() => setEditorView(null)}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
@@ -24,6 +24,7 @@ interface InlineCommentDraftState {
interface InlineCommentDraftActions {
addDraft: (draft: Omit<InlineCommentDraft, 'id' | 'createdAt'>) => void;
updateDraft: (sessionKey: string, draftId: string, updates: Partial<Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>>) => void;
removeDraft: (sessionKey: string, draftId: string) => void;
clearDrafts: (sessionKey: string) => void;
getDrafts: (sessionKey: string) => InlineCommentDraft[];
@@ -61,6 +62,28 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
return id;
},
updateDraft: (sessionKey, draftId, updates) => {
set((state) => {
const currentDrafts = state.drafts[sessionKey] ?? [];
const newDrafts = currentDrafts.map((draft) => {
if (draft.id !== draftId) {
return draft;
}
return {
...draft,
...updates,
};
});
return {
drafts: {
...state.drafts,
[sessionKey]: newDrafts,
},
};
});
},
removeDraft: (sessionKey, draftId) => {
set((state) => {
const currentDrafts = state.drafts[sessionKey] ?? [];