Restore embedded inline comments in Plan/File/Diff views (#456)

* fix(plan-comments): restore embedded inline comment widgets in Plan view

Reinstate CodeMirror block-widget comments so plan annotations stay anchored to selected lines and preserve drag/selection behavior without floating overlays.

* fix(file-comments): return Files editor comments to embedded widgets

Use inline block widgets for file drafts while keeping full-path draft scoping, so similarly named files no longer risk comment collisions.

* fix(diff-comments): render inline comments through annotation portals

Replace absolute floating positioning with annotation-target portals to keep diff comments attached to their lines across shadow DOM updates.

* chore(comments): remove deprecated floating comment hook

Drop the unused floating-comment implementation now that plan, file, and diff views all use embedded comment rendering paths.

* fix(codemirror): expose gutter width as CSS variable

Measure the current CodeMirror gutter and publish --oc-editor-gutter-width on the editor host so inline widgets can size to the visible code area without hardcoded dimensions.

* fix(context-panel): publish panel width for embedded widgets

Set --oc-context-panel-width on the context panel in both docked and expanded modes so comment widgets can follow the active panel width dynamically.

* fix(file-comments): constrain inline input to editor content width

Use context-panel and editor-gutter CSS variables to cap comment input width to the visible editor content area, keeping action buttons fully visible in no-wrap mode.

* fix(file-comments): constrain inline comment cards to content area

Apply the same variable-based width cap to saved comment cards so card actions stay visible when long lines force horizontal scrolling.

* fix(diff-comments): stabilize new comment annotation identity

Derive new-comment annotation ids from selection side and line range, and reuse that id for portal target lookup and keys to avoid remount glitches.
This commit is contained in:
Nelson Pires
2026-02-20 16:52:01 +02:00
committed by GitHub
parent 1d6895a9e7
commit 49170fe242
8 changed files with 309 additions and 524 deletions
@@ -36,7 +36,7 @@ export function InlineCommentCard({
return ( return (
<div <div
className={cn( className={cn(
"rounded-lg border shadow-sm w-full overflow-hidden transition-all duration-200", "rounded-lg border shadow-sm w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden transition-all duration-200",
className className
)} )}
style={{ style={{
@@ -112,7 +112,7 @@ export function InlineCommentInput({
return ( return (
<div <div
className={cn( className={cn(
"rounded-lg border shadow-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200", "rounded-lg border shadow-sm w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden animate-in fade-in zoom-in-95 duration-200",
className className
)} )}
style={{ style={{
@@ -1,198 +0,0 @@
import React from 'react';
import type { EditorView } from '@codemirror/view';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { InlineCommentCard } from './InlineCommentCard';
import { InlineCommentInput } from './InlineCommentInput';
type SelectedLineRange = { start: number; end: number };
type CommentPos = {
top: number;
flipUp: boolean;
};
const COMMENT_POPOVER_HEIGHT = 200;
function getLineTop(view: EditorView, wrapper: HTMLElement, lineNumber: number, position: 'top' | 'bottom'): number | undefined {
const lineCount = view.state.doc.lines;
if (lineNumber < 1 || lineNumber > lineCount) return undefined;
const line = view.state.doc.line(lineNumber);
const coords = view.coordsAtPos(line.from);
if (!coords) return undefined;
const wrapperRect = wrapper.getBoundingClientRect();
if (position === 'bottom') {
return coords.bottom - wrapperRect.top;
}
return coords.top - wrapperRect.top;
}
function shouldFlipUp(view: EditorView, endLine: number, scrollContainer: HTMLElement | null): boolean {
const lineCount = view.state.doc.lines;
if (endLine < 1 || endLine > lineCount) return false;
const line = view.state.doc.line(endLine);
const coords = view.coordsAtPos(line.from);
if (!coords) return false;
const viewportBottom = scrollContainer
? scrollContainer.getBoundingClientRect().bottom
: window.innerHeight;
return (coords.bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom;
}
function computePosition(
view: EditorView,
wrapper: HTMLElement,
scrollContainer: HTMLElement | null,
range: { start: number; end: number },
): CommentPos | undefined {
const flipUp = shouldFlipUp(view, range.end, scrollContainer);
const top = flipUp
? getLineTop(view, wrapper, range.start, 'top')
: getLineTop(view, wrapper, range.end, 'bottom');
if (top === undefined) return undefined;
return { top, flipUp };
}
type FloatingCommentsProps = {
editorView: EditorView | null;
wrapperRef: React.RefObject<HTMLElement | null>;
fileDrafts: InlineCommentDraft[];
editingDraftId: string | null;
commentText: string;
lineSelection: SelectedLineRange | null;
isDragging: boolean;
fileLabel: string;
onSaveComment: (text: string, range?: SelectedLineRange) => void;
onCancelComment: () => void;
onEditDraft: (draft: InlineCommentDraft) => void;
onDeleteDraft: (draft: InlineCommentDraft) => void;
};
export function useFloatingComments({
editorView,
wrapperRef,
fileDrafts,
editingDraftId,
commentText,
lineSelection,
isDragging,
fileLabel,
onSaveComment,
onCancelComment,
onEditDraft,
onDeleteDraft,
}: FloatingCommentsProps): React.ReactNode {
const [positions, setPositions] = React.useState<Record<string, CommentPos | undefined>>({});
const updatePositions = React.useCallback(() => {
const view = editorView;
const wrapper = wrapperRef.current;
if (!view || !wrapper) return;
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
const next: Record<string, CommentPos | undefined> = {};
for (const d of fileDrafts) {
next[d.id] = computePosition(view, wrapper, scrollContainer, {
start: d.startLine,
end: d.endLine,
});
}
if (lineSelection && !editingDraftId && !isDragging) {
next['__new__'] = computePosition(view, wrapper, scrollContainer, {
start: lineSelection.start,
end: lineSelection.end,
});
}
setPositions(next);
}, [editorView, wrapperRef, fileDrafts, editingDraftId, lineSelection, isDragging]);
React.useEffect(() => {
requestAnimationFrame(updatePositions);
}, [updatePositions]);
// Also update on scroll
React.useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
if (!scrollContainer) return;
const onScroll = () => requestAnimationFrame(updatePositions);
scrollContainer.addEventListener('scroll', onScroll, { passive: true });
return () => scrollContainer.removeEventListener('scroll', onScroll);
}, [wrapperRef, updatePositions]);
const popoverStyle = (flipUp: boolean): React.CSSProperties => flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
return (
<>
{fileDrafts.map((d) => {
const pos = positions[d.id];
if (!pos) return null;
if (d.id === editingDraftId) {
return (
<div
key={`edit-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={popoverStyle(pos.flipUp)}>
<InlineCommentInput
initialText={commentText}
fileLabel={fileLabel}
lineRange={{ start: d.startLine, end: d.endLine }}
isEditing={true}
onSave={onSaveComment}
onCancel={onCancelComment}
/>
</div>
</div>
);
}
return (
<div
key={`saved-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
>
<InlineCommentCard
draft={d}
onEdit={() => onEditDraft(d)}
onDelete={() => onDeleteDraft(d)}
/>
</div>
);
})}
{lineSelection && !editingDraftId && !isDragging && positions['__new__'] && (
<div
key="new-comment"
style={{ position: 'absolute', right: 24, top: positions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={popoverStyle(positions['__new__'].flipUp)}>
<InlineCommentInput
initialText={commentText}
fileLabel={fileLabel}
lineRange={lineSelection}
isEditing={false}
onSave={onSaveComment}
onCancel={onCancelComment}
/>
</div>
</div>
)}
</>
);
}
@@ -194,6 +194,17 @@ export const ContextPanel: React.FC = () => {
return null; return null;
} }
const panelStyle: React.CSSProperties = isExpanded
? {
['--oc-context-panel-width' as string]: '100vw',
}
: {
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
['--oc-context-panel-width' as string]: `${width}px`,
};
return ( return (
<aside <aside
ref={panelRef} ref={panelRef}
@@ -207,13 +218,7 @@ export const ContextPanel: React.FC = () => {
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out' isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
)} )}
onKeyDownCapture={handlePanelKeyDownCapture} onKeyDownCapture={handlePanelKeyDownCapture}
style={isExpanded style={panelStyle}
? undefined
: {
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
}}
> >
{!isExpanded && ( {!isExpanded && (
<div <div
@@ -165,6 +165,18 @@ export function CodeMirrorEditor({
// Scoped map for widget containers to avoid global collisions and memory leaks // Scoped map for widget containers to avoid global collisions and memory leaks
const widgetContainersRef = React.useRef(new Map<string, HTMLElement>()); const widgetContainersRef = React.useRef(new Map<string, HTMLElement>());
const syncEditorCssVars = React.useCallback((view?: EditorView | null) => {
const host = hostRef.current;
const resolvedView = view ?? viewRef.current;
if (!host || !resolvedView) {
return;
}
const gutters = resolvedView.dom.querySelector('.cm-gutters');
const gutterWidth = gutters instanceof HTMLElement ? gutters.getBoundingClientRect().width : 0;
host.style.setProperty('--oc-editor-gutter-width', `${gutterWidth}px`);
}, []);
React.useEffect(() => { React.useEffect(() => {
valueRef.current = value; valueRef.current = value;
}, [value]); }, [value]);
@@ -201,6 +213,7 @@ export function CodeMirrorEditor({
indentUnit.of(' '), indentUnit.of(' '),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
syncEditorCssVars(update.view);
if (update.docChanged || update.viewportChanged || update.geometryChanged) { if (update.docChanged || update.viewportChanged || update.geometryChanged) {
forceUpdate(); forceUpdate();
} }
@@ -226,6 +239,7 @@ export function CodeMirrorEditor({
forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200); forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200);
viewRef.current.requestMeasure(); viewRef.current.requestMeasure();
requestAnimationFrame(() => syncEditorCssVars(viewRef.current));
if (viewRef.current) { if (viewRef.current) {
onViewReadyRef.current?.(viewRef.current); onViewReadyRef.current?.(viewRef.current);
@@ -258,11 +272,12 @@ export function CodeMirrorEditor({
forceParsingCompat(view, view.state.doc.length, 200); forceParsingCompat(view, view.state.doc.length, 200);
view.requestMeasure(); view.requestMeasure();
requestAnimationFrame(() => syncEditorCssVars(view));
// Force a re-render to ensure Portals can find the new widget containers in the DOM // Force a re-render to ensure Portals can find the new widget containers in the DOM
// The containers are created synchronously by CodeMirror during dispatch -> toDOM // The containers are created synchronously by CodeMirror during dispatch -> toDOM
forceUpdate(); forceUpdate();
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch]); }, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch, syncEditorCssVars]);
React.useEffect(() => { React.useEffect(() => {
const view = viewRef.current; const view = viewRef.current;
+62 -19
View File
@@ -66,7 +66,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useGitStatus } from '@/stores/useGitStore'; import { useGitStatus } from '@/stores/useGitStore';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { useFloatingComments } from '@/components/comments/useFloatingComments'; import { InlineCommentCard } from '@/components/comments/InlineCommentCard';
import { InlineCommentInput } from '@/components/comments/InlineCommentInput';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
@@ -1798,23 +1799,65 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return sessionDrafts.filter((d) => d.source === 'file' && d.fileLabel === selectedFile.path); return sessionDrafts.filter((d) => d.source === 'file' && d.fileLabel === selectedFile.path);
}, [selectedFile, currentSessionId, allDrafts]); }, [selectedFile, currentSessionId, allDrafts]);
const floatingComments = useFloatingComments({ const blockWidgets = React.useMemo(() => {
editorView: editorViewRef.current, const widgets: import('@/components/ui/CodeMirrorEditor').BlockWidgetDef[] = [];
wrapperRef: editorWrapperRef,
fileDrafts: filesFileDrafts, for (const draft of filesFileDrafts) {
editingDraftId, if (draft.id === editingDraftId) {
commentText: '', widgets.push({
lineSelection, afterLine: draft.endLine,
isDragging, id: `edit-${draft.id}`,
fileLabel: selectedFile?.path ?? '', content: (
onSaveComment: handleSaveComment, <InlineCommentInput
onCancelComment: () => setLineSelection(null), key={draft.id}
onEditDraft: (draft) => { initialText={draft.text}
setEditingDraftId(draft.id); fileLabel={selectedFile?.path}
setLineSelection(null); lineRange={{ start: draft.startLine, end: draft.endLine }}
}, isEditing={true}
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id), onSave={handleSaveComment}
}); onCancel={() => setEditingDraftId(null)}
/>
),
});
} else {
widgets.push({
afterLine: draft.endLine,
id: `card-${draft.id}`,
content: (
<InlineCommentCard
key={draft.id}
draft={draft}
onEdit={() => {
setEditingDraftId(draft.id);
setLineSelection(null);
}}
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
/>
),
});
}
}
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'files-new-comment-input',
content: (
<InlineCommentInput
key="new-comment"
initialText=""
fileLabel={selectedFile?.path}
lineRange={lineSelection}
isEditing={false}
onSave={handleSaveComment}
onCancel={() => setLineSelection(null)}
/>
),
});
}
return widgets;
}, [filesFileDrafts, editingDraftId, lineSelection, isDragging, selectedFile?.path, handleSaveComment, removeDraft]);
const fileViewer = ( const fileViewer = (
<div <div
@@ -2184,6 +2227,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
onChange={setDraftContent} onChange={setDraftContent}
extensions={editorExtensions} extensions={editorExtensions}
className="h-full" className="h-full"
blockWidgets={blockWidgets}
onViewReady={(view) => { onViewReady={(view) => {
editorViewRef.current = view; editorViewRef.current = view;
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@@ -2269,7 +2313,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}, },
}} }}
/> />
{floatingComments}
</div> </div>
)} )}
</ScrollableOverlay> </ScrollableOverlay>
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
// createPortal no longer needed — comments float absolutely outside shadow DOM import { createPortal } from 'react-dom';
import { import {
FileDiff as PierreFileDiff, FileDiff as PierreFileDiff,
VirtualizedFileDiff, VirtualizedFileDiff,
@@ -275,115 +275,48 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
if (meta.type === 'saved' || meta.type === 'edit') { if (meta.type === 'saved' || meta.type === 'edit') {
return `draft-${meta.draft.id}`; return `draft-${meta.draft.id}`;
} else if (meta.type === 'new') { } else if (meta.type === 'new') {
return 'new-comment-input'; const start = Math.min(meta.selection.start, meta.selection.end);
const end = Math.max(meta.selection.start, meta.selection.end);
const side = meta.selection.side ?? 'additions';
return `new-comment-${side}-${start}-${end}`;
} }
return ''; return '';
}, []); }, []);
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => { const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
const div = document.createElement('div'); const div = document.createElement('div');
// Invisible — comments are rendered as floating elements outside shadow DOM div.style.position = 'relative';
div.style.display = 'none';
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata; const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta); const id = getAnnotationId(meta);
div.dataset.annotationId = id; div.dataset.annotationId = id;
div.dataset.annotationSide = annotation.side;
div.dataset.annotationLine = String(annotation.lineNumber);
return div; return div;
}, [getAnnotationId]); }, [getAnnotationId]);
// Compute floating comment positions by finding target lines in Pierre's shadow DOM const annotationTargetsRef = useRef<Record<string, HTMLElement | null>>({});
const findLineElement = useCallback((root: ShadowRoot, line: number, side?: string) => {
const nodes = Array.from(
root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)
).filter((n): n is HTMLElement => n instanceof HTMLElement);
if (nodes.length === 0) return undefined;
if (!side) return nodes[0];
const match = nodes.find((n) => {
const lineType = n.closest('[data-line-type]')?.getAttribute('data-line-type') ?? n.getAttribute('data-line-type');
if (side === 'deletions') return lineType === 'change-deletion';
return lineType !== 'change-deletion';
});
return match ?? nodes[0];
}, []);
const getAnchorPositions = useCallback((wrapper: HTMLElement, root: ShadowRoot, range: { start: number; end: number; side?: string }) => { const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
const wrapperRect = wrapper.getBoundingClientRect();
const first = findLineElement(root, range.start, range.side);
const last = findLineElement(root, range.end, range.side);
// Bottom of last line (for below placement)
const lastEl = last ?? first;
const bottomTop = lastEl
? lastEl.getBoundingClientRect().top - wrapperRect.top + lastEl.getBoundingClientRect().height
: undefined;
// Top of first line (for above placement)
const firstEl = first ?? last;
const aboveTop = firstEl
? firstEl.getBoundingClientRect().top - wrapperRect.top
: undefined;
return { bottomTop, aboveTop };
}, [findLineElement]);
const [commentPositions, setCommentPositions] = useState<Record<string, { top: number; flipUp: boolean } | undefined>>({});
type CommentPos = { top: number; flipUp: boolean };
const COMMENT_POPOVER_HEIGHT = 200; // approximate height of comment popover
const updateCommentPositions = useCallback(() => {
const wrapper = diffRootRef.current; const wrapper = diffRootRef.current;
if (!wrapper) return; if (!wrapper) return null;
const host = wrapper.querySelector('diffs-container') ?? diffContainerRef.current?.querySelector('diffs-container'); const cached = annotationTargetsRef.current[id];
const shadow = (host as HTMLElement | null)?.shadowRoot; if (cached && wrapper.contains(cached)) {
if (!shadow) return; return cached;
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
const viewportBottom = scrollContainer
? scrollContainer.getBoundingClientRect().bottom
: window.innerHeight;
const computePos = (range: { start: number; end: number; side?: string }): CommentPos | undefined => {
const anchors = getAnchorPositions(wrapper, shadow, range);
if (anchors.bottomTop === undefined) return undefined;
// Check if placing below last line would overflow viewport
const lastEl = findLineElement(shadow, range.end, range.side) ?? findLineElement(shadow, range.start, range.side);
const flipUp = lastEl
? (lastEl.getBoundingClientRect().bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom
: false;
return {
top: flipUp ? (anchors.aboveTop ?? anchors.bottomTop) : anchors.bottomTop,
flipUp,
};
};
const next: Record<string, CommentPos | undefined> = {};
const sessionKey = getSessionKey();
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
const fileLabel = fileName || 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
for (const d of fileDrafts) {
const side = d.side === 'original' ? 'deletions' : 'additions';
next[d.id] = computePos({ start: d.startLine, end: d.endLine, side });
} }
if (selection && !editingDraftId) { const host = wrapper.querySelector('diffs-container');
const side = selection.side ?? 'additions'; if (!host) return null;
next['__new__'] = computePos({ start: selection.start, end: selection.end, side });
}
setCommentPositions(next); const shadowRoot = host.shadowRoot;
}, [allDrafts, editingDraftId, fileName, findLineElement, getAnchorPositions, getSessionKey, selection]); if (!shadowRoot) return null;
const updateCommentPositionsRef = useRef(updateCommentPositions); const target = shadowRoot.querySelector(`[data-annotation-id="${id}"]`) as HTMLElement | null;
useEffect(() => { annotationTargetsRef.current[id] = target;
updateCommentPositionsRef.current = updateCommentPositions; return target;
}, [updateCommentPositions]); }, []);
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => { const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
// Use provided range override or fall back to current selection // Use provided range override or fall back to current selection
@@ -667,10 +600,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
containerWrapper: container, containerWrapper: container,
}); });
// Update floating comment positions after Pierre renders
requestAnimationFrame(() => { requestAnimationFrame(() => {
forceUpdate(); forceUpdate();
updateCommentPositionsRef.current();
}); });
return () => { return () => {
@@ -699,9 +630,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
void err; void err;
} }
forceUpdate(); forceUpdate();
updateCommentPositions();
}); });
}, [lineAnnotations, updateCommentPositions]); }, [lineAnnotations]);
useEffect(() => { useEffect(() => {
const instance = diffInstanceRef.current; const instance = diffInstanceRef.current;
@@ -788,10 +718,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
}; };
}, [diffThemeKey, fileName, handleSelectionChange]); }, [diffThemeKey, fileName, handleSelectionChange]);
useEffect(() => {
requestAnimationFrame(updateCommentPositions);
}, [selection, editingDraftId, allDrafts, updateCommentPositions]);
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed // MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
useEffect(() => { useEffect(() => {
const container = diffContainerRef.current; const container = diffContainerRef.current;
@@ -804,35 +730,20 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const diffsContainer = container.querySelector('diffs-container'); const diffsContainer = container.querySelector('diffs-container');
if (!diffsContainer) return; if (!diffsContainer) return;
// Watch for annotation nodes being added/removed const shadowRoot = diffsContainer.shadowRoot;
observer = new MutationObserver((mutations) => { if (!shadowRoot) return;
const hasAnnotationChanges = mutations.some(m =>
Array.from(m.addedNodes).some(n =>
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
) ||
Array.from(m.removedNodes).some(n =>
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
)
);
if (hasAnnotationChanges) { observer = new MutationObserver(() => {
// Debounce with RAF to batch multiple mutations if (rafId) cancelAnimationFrame(rafId);
if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => {
rafId = requestAnimationFrame(() => { forceUpdate();
forceUpdate(); rafId = null;
rafId = null; });
});
}
}); });
// Observe both shadow root and light DOM observer.observe(shadowRoot, { childList: true, subtree: true });
if (diffsContainer.shadowRoot) {
observer.observe(diffsContainer.shadowRoot, { childList: true, subtree: true });
}
observer.observe(diffsContainer, { childList: true, subtree: true });
}; };
// Delay setup to allow Pierre to initialize
const timeoutId = setTimeout(setupObserver, 100); const timeoutId = setTimeout(setupObserver, 100);
return () => { return () => {
@@ -840,91 +751,76 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
if (rafId) cancelAnimationFrame(rafId); if (rafId) cancelAnimationFrame(rafId);
observer?.disconnect(); observer?.disconnect();
}; };
}, [diffThemeKey, fileName]); // Re-setup when diff changes }, [diffThemeKey, fileName]);
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null; return null;
} }
// Floating comment elements positioned absolutely over the diff
const sessionKey = getSessionKey(); const sessionKey = getSessionKey();
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : []; const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
const fileLabel = fileName || 'unknown'; const fileLabel = fileName || 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel); const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
const floatingComments = ( const commentPortals = (
<> <>
{fileDrafts.map((d) => { {fileDrafts.map((d) => {
const pos = commentPositions[d.id]; const target = resolveAnnotationTarget(`draft-${d.id}`);
if (!pos) return null; if (!target) return null;
const popoverStyle: React.CSSProperties = pos.flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
if (d.id === editingDraftId) { if (d.id === editingDraftId) {
return ( return createPortal(
<div
key={`edit-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={popoverStyle}>
<InlineCommentInput
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={{
start: d.startLine,
end: d.endLine,
side: d.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
</div>
</div>
);
}
return (
<div
key={`saved-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
>
<InlineCommentCard
draft={d}
onEdit={() => {
const side = d.side === 'original' ? 'deletions' : 'additions';
applySelection({ start: d.startLine, end: d.endLine, side });
setCommentText(d.text);
setEditingDraftId(d.id);
}}
onDelete={() => removeDraft(d.sessionKey, d.id)}
/>
</div>
);
})}
{selection && !editingDraftId && commentPositions['__new__'] && (
<div
key="new-comment"
style={{ position: 'absolute', right: 24, top: commentPositions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={commentPositions['__new__'].flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
}>
<InlineCommentInput <InlineCommentInput
initialText={commentText} initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''} fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined} lineRange={{
isEditing={false} start: d.startLine,
end: d.endLine,
side: d.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment} onSave={handleSaveComment}
onCancel={handleCancelComment} onCancel={handleCancelComment}
/> />,
</div> target,
</div> `draft-edit-${d.id}`
)} );
}
return createPortal(
<InlineCommentCard
draft={d}
onEdit={() => {
const side = d.side === 'original' ? 'deletions' : 'additions';
applySelection({ start: d.startLine, end: d.endLine, side });
setCommentText(d.text);
setEditingDraftId(d.id);
}}
onDelete={() => removeDraft(d.sessionKey, d.id)}
/>,
target,
`draft-card-${d.id}`
);
})}
{selection && !editingDraftId && (() => {
const newCommentAnnotationId = getAnnotationId({ type: 'new', selection });
const target = resolveAnnotationTarget(newCommentAnnotationId);
if (!target) return null;
return createPortal(
<InlineCommentInput
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined}
isEditing={false}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>,
target,
newCommentAnnotationId
);
})()}
</> </>
); );
@@ -940,9 +836,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
> >
<div ref={diffRootRef} className="size-full relative"> <div ref={diffRootRef} className="size-full relative">
<div ref={diffContainerRef} className="size-full" /> <div ref={diffContainerRef} className="size-full" />
{floatingComments}
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
{commentPortals}
</div> </div>
</div> </div>
); );
@@ -953,8 +849,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
<div className={cn("relative", "w-full")}> <div className={cn("relative", "w-full")}>
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative"> <div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
<div ref={diffContainerRef} className="w-full" /> <div ref={diffContainerRef} className="w-full" />
{floatingComments}
</div> </div>
{commentPortals}
</div> </div>
); );
}; };
+131 -107
View File
@@ -1,12 +1,13 @@
import React from 'react'; import React from 'react';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { useFloatingComments } from '@/components/comments/useFloatingComments';
import { PreviewToggleButton } from './PreviewToggleButton'; import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { InlineCommentCard } from '@/components/comments/InlineCommentCard';
import { InlineCommentInput } from '@/components/comments/InlineCommentInput';
import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
@@ -389,24 +390,55 @@ export const PlanView: React.FC = () => {
return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel); return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel);
}, [getSessionKey, allDrafts, planFileLabel]); }, [getSessionKey, allDrafts, planFileLabel]);
const floatingComments = useFloatingComments({ const blockWidgets = React.useMemo(() => {
editorView: editorViewRef.current, const widgets: Array<{ afterLine: number; id: string; content: React.ReactNode }> = [];
wrapperRef: editorWrapperRef,
fileDrafts: planFileDrafts, for (const draft of planFileDrafts) {
editingDraftId, const content = draft.id === editingDraftId ? (
commentText, <InlineCommentInput
lineSelection, key={`edit-${draft.id}`}
isDragging, initialText={commentText}
fileLabel: planFileLabel, fileLabel={draft.fileLabel}
onSaveComment: handleSaveComment, lineRange={{ start: draft.startLine, end: draft.endLine }}
onCancelComment: handleCancelComment, isEditing={true}
onEditDraft: (draft) => { onSave={handleSaveComment}
setLineSelection({ start: draft.startLine, end: draft.endLine }); onCancel={handleCancelComment}
setCommentText(draft.text); />
setEditingDraftId(draft.id); ) : (
}, <InlineCommentCard
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id), key={`saved-${draft.id}`}
}); draft={draft}
onEdit={() => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
}}
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
/>
);
widgets.push({ afterLine: draft.endLine, id: draft.id, content });
}
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: Math.max(lineSelection.start, lineSelection.end),
id: 'plan-new-comment-input',
content: (
<InlineCommentInput
key="new-comment"
initialText={commentText}
fileLabel={planFileLabel}
lineRange={lineSelection}
isEditing={false}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
),
});
}
return widgets;
}, [planFileDrafts, editingDraftId, commentText, lineSelection, isDragging, planFileLabel, handleSaveComment, handleCancelComment, removeDraft]);
return ( return (
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background"> <div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
@@ -489,103 +521,95 @@ export const PlanView: React.FC = () => {
<div className="p-3 typography-ui text-muted-foreground">Loading…</div> <div className="p-3 typography-ui text-muted-foreground">Loading…</div>
) : ( ) : (
<div className="relative h-full"> <div className="relative h-full">
<div <div className="h-full oc-plan-editor">
className="h-full" {mdViewMode === 'preview' ? (
style={{ <div className="h-full overflow-auto p-3">
['--oc-plan-comment-pad' as string]: '0px', <ErrorBoundary
}} fallback={
> <div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="h-full oc-plan-editor"> <div className="mb-1 font-medium text-destructive">Preview unavailable</div>
{mdViewMode === 'preview' ? ( <div className="text-sm text-muted-foreground">
<div className="h-full overflow-auto p-3"> Switch to edit mode to fix the issue.
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
<div className="text-sm text-muted-foreground">
Switch to edit mode to fix the issue.
</div>
</div> </div>
</div>
}
>
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
</ErrorBoundary>
</div>
) : (
<div className="relative h-full" ref={editorWrapperRef}>
<CodeMirrorEditor
value={content}
onChange={() => {
// read-only
}}
readOnly={true}
className="h-full"
extensions={editorExtensions}
onViewReady={(view) => { editorViewRef.current = view; }}
onViewDestroy={() => { editorViewRef.current = null; }}
blockWidgets={blockWidgets}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
} }
> : undefined}
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" /> lineNumbersConfig={{
</ErrorBoundary> domEventHandlers: {
</div> mousedown: (view, line, event) => {
) : ( if (!(event instanceof MouseEvent)) return false;
<div className="relative h-full" ref={editorWrapperRef}> if (event.button !== 0) return false;
<CodeMirrorEditor event.preventDefault();
value={content}
onChange={() => {
// read-only
}}
readOnly={true}
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
extensions={editorExtensions}
onViewReady={(view) => { editorViewRef.current = view; }}
onViewDestroy={() => { editorViewRef.current = null; }}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view, line, event) => {
if (!(event instanceof MouseEvent)) return false;
if (event.button !== 0) return false;
event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number;
if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
setLineSelection({ start, end });
isSelectingRef.current = false;
selectionStartRef.current = null;
// Mobile tap-extend is atomic, so we don't start drag
setIsDragging(false);
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setIsDragging(true);
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
} else {
setLineSelection({ start: lineNumber, end: lineNumber });
}
return true;
},
mouseover: (view, line, event) => {
if (!(event instanceof MouseEvent)) return false;
if (event.buttons !== 1) return false;
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
const lineNumber = view.state.doc.lineAt(line.from).number; const lineNumber = view.state.doc.lineAt(line.from).number;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber); if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
setLineSelection({ start, end }); setLineSelection({ start, end });
setIsDragging(true);
return false;
},
mouseup: () => {
isSelectingRef.current = false; isSelectingRef.current = false;
selectionStartRef.current = null; selectionStartRef.current = null;
setIsDragging(false); setIsDragging(false);
return false; return true;
}, }
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setIsDragging(true);
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
} else {
setLineSelection({ start: lineNumber, end: lineNumber });
}
return true;
}, },
}} mouseover: (view, line, event) => {
/> if (!(event instanceof MouseEvent)) return false;
{floatingComments} if (event.buttons !== 1) return false;
</div> if (!isSelectingRef.current || selectionStartRef.current === null) return false;
)} const lineNumber = view.state.doc.lineAt(line.from).number;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber);
setLineSelection({ start, end });
setIsDragging(true);
return false;
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return false;
},
},
}}
/>
</div> </div>
)}
</div> </div>
</div> </div>
)} )}