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 (
<div
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
)}
style={{
@@ -112,7 +112,7 @@ export function InlineCommentInput({
return (
<div
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
)}
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;
}
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 (
<aside
ref={panelRef}
@@ -207,13 +218,7 @@ export const ContextPanel: React.FC = () => {
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
)}
onKeyDownCapture={handlePanelKeyDownCapture}
style={isExpanded
? undefined
: {
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
}}
style={panelStyle}
>
{!isExpanded && (
<div
@@ -165,6 +165,18 @@ export function CodeMirrorEditor({
// Scoped map for widget containers to avoid global collisions and memory leaks
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(() => {
valueRef.current = value;
}, [value]);
@@ -201,6 +213,7 @@ export function CodeMirrorEditor({
indentUnit.of(' '),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((update) => {
syncEditorCssVars(update.view);
if (update.docChanged || update.viewportChanged || update.geometryChanged) {
forceUpdate();
}
@@ -226,6 +239,7 @@ export function CodeMirrorEditor({
forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200);
viewRef.current.requestMeasure();
requestAnimationFrame(() => syncEditorCssVars(viewRef.current));
if (viewRef.current) {
onViewReadyRef.current?.(viewRef.current);
@@ -258,11 +272,12 @@ export function CodeMirrorEditor({
forceParsingCompat(view, view.state.doc.length, 200);
view.requestMeasure();
requestAnimationFrame(() => syncEditorCssVars(view));
// 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
forceUpdate();
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch]);
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch, syncEditorCssVars]);
React.useEffect(() => {
const view = viewRef.current;
+62 -19
View File
@@ -66,7 +66,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useGitStatus } from '@/stores/useGitStore';
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 { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
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);
}, [selectedFile, currentSessionId, allDrafts]);
const floatingComments = useFloatingComments({
editorView: editorViewRef.current,
wrapperRef: editorWrapperRef,
fileDrafts: filesFileDrafts,
editingDraftId,
commentText: '',
lineSelection,
isDragging,
fileLabel: selectedFile?.path ?? '',
onSaveComment: handleSaveComment,
onCancelComment: () => setLineSelection(null),
onEditDraft: (draft) => {
setEditingDraftId(draft.id);
setLineSelection(null);
},
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
});
const blockWidgets = React.useMemo(() => {
const widgets: import('@/components/ui/CodeMirrorEditor').BlockWidgetDef[] = [];
for (const draft of filesFileDrafts) {
if (draft.id === editingDraftId) {
widgets.push({
afterLine: draft.endLine,
id: `edit-${draft.id}`,
content: (
<InlineCommentInput
key={draft.id}
initialText={draft.text}
fileLabel={selectedFile?.path}
lineRange={{ start: draft.startLine, end: draft.endLine }}
isEditing={true}
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 = (
<div
@@ -2184,6 +2227,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
onChange={setDraftContent}
extensions={editorExtensions}
className="h-full"
blockWidgets={blockWidgets}
onViewReady={(view) => {
editorViewRef.current = view;
window.requestAnimationFrame(() => {
@@ -2269,7 +2313,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
},
}}
/>
{floatingComments}
</div>
)}
</ScrollableOverlay>
@@ -1,5 +1,5 @@
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 {
FileDiff as PierreFileDiff,
VirtualizedFileDiff,
@@ -275,115 +275,48 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
if (meta.type === 'saved' || meta.type === 'edit') {
return `draft-${meta.draft.id}`;
} 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 '';
}, []);
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
const div = document.createElement('div');
// Invisible — comments are rendered as floating elements outside shadow DOM
div.style.display = 'none';
div.style.position = 'relative';
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta);
div.dataset.annotationId = id;
div.dataset.annotationSide = annotation.side;
div.dataset.annotationLine = String(annotation.lineNumber);
return div;
}, [getAnnotationId]);
// Compute floating comment positions by finding target lines in Pierre's shadow DOM
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 annotationTargetsRef = useRef<Record<string, HTMLElement | null>>({});
const getAnchorPositions = useCallback((wrapper: HTMLElement, root: ShadowRoot, range: { start: number; end: number; side?: string }) => {
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 resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
const wrapper = diffRootRef.current;
if (!wrapper) return;
if (!wrapper) return null;
const host = wrapper.querySelector('diffs-container') ?? diffContainerRef.current?.querySelector('diffs-container');
const shadow = (host as HTMLElement | null)?.shadowRoot;
if (!shadow) return;
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 });
const cached = annotationTargetsRef.current[id];
if (cached && wrapper.contains(cached)) {
return cached;
}
if (selection && !editingDraftId) {
const side = selection.side ?? 'additions';
next['__new__'] = computePos({ start: selection.start, end: selection.end, side });
}
const host = wrapper.querySelector('diffs-container');
if (!host) return null;
setCommentPositions(next);
}, [allDrafts, editingDraftId, fileName, findLineElement, getAnchorPositions, getSessionKey, selection]);
const shadowRoot = host.shadowRoot;
if (!shadowRoot) return null;
const updateCommentPositionsRef = useRef(updateCommentPositions);
useEffect(() => {
updateCommentPositionsRef.current = updateCommentPositions;
}, [updateCommentPositions]);
const target = shadowRoot.querySelector(`[data-annotation-id="${id}"]`) as HTMLElement | null;
annotationTargetsRef.current[id] = target;
return target;
}, []);
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
// Use provided range override or fall back to current selection
@@ -667,10 +600,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
containerWrapper: container,
});
// Update floating comment positions after Pierre renders
requestAnimationFrame(() => {
forceUpdate();
updateCommentPositionsRef.current();
});
return () => {
@@ -699,9 +630,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
void err;
}
forceUpdate();
updateCommentPositions();
});
}, [lineAnnotations, updateCommentPositions]);
}, [lineAnnotations]);
useEffect(() => {
const instance = diffInstanceRef.current;
@@ -788,10 +718,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
};
}, [diffThemeKey, fileName, handleSelectionChange]);
useEffect(() => {
requestAnimationFrame(updateCommentPositions);
}, [selection, editingDraftId, allDrafts, updateCommentPositions]);
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
useEffect(() => {
const container = diffContainerRef.current;
@@ -804,35 +730,20 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const diffsContainer = container.querySelector('diffs-container');
if (!diffsContainer) return;
// Watch for annotation nodes being added/removed
observer = new MutationObserver((mutations) => {
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')
)
);
const shadowRoot = diffsContainer.shadowRoot;
if (!shadowRoot) return;
if (hasAnnotationChanges) {
// Debounce with RAF to batch multiple mutations
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
forceUpdate();
rafId = null;
});
}
observer = new MutationObserver(() => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
forceUpdate();
rafId = null;
});
});
// Observe both shadow root and light DOM
if (diffsContainer.shadowRoot) {
observer.observe(diffsContainer.shadowRoot, { childList: true, subtree: true });
}
observer.observe(diffsContainer, { childList: true, subtree: true });
observer.observe(shadowRoot, { childList: true, subtree: true });
};
// Delay setup to allow Pierre to initialize
const timeoutId = setTimeout(setupObserver, 100);
return () => {
@@ -840,91 +751,76 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
if (rafId) cancelAnimationFrame(rafId);
observer?.disconnect();
};
}, [diffThemeKey, fileName]); // Re-setup when diff changes
}, [diffThemeKey, fileName]);
if (typeof window === 'undefined') {
return null;
}
// Floating comment elements positioned absolutely over the diff
const sessionKey = getSessionKey();
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
const fileLabel = fileName || 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
const floatingComments = (
const commentPortals = (
<>
{fileDrafts.map((d) => {
const pos = commentPositions[d.id];
if (!pos) 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 };
const target = resolveAnnotationTarget(`draft-${d.id}`);
if (!target) 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}>
<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 }
}>
return createPortal(
<InlineCommentInput
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined}
isEditing={false}
lineRange={{
start: d.startLine,
end: d.endLine,
side: d.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
</div>
</div>
)}
/>,
target,
`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={diffContainerRef} className="size-full" />
{floatingComments}
</div>
</ScrollableOverlay>
{commentPortals}
</div>
</div>
);
@@ -953,8 +849,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
<div className={cn("relative", "w-full")}>
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
<div ref={diffContainerRef} className="w-full" />
{floatingComments}
</div>
{commentPortals}
</div>
);
};
+131 -107
View File
@@ -1,12 +1,13 @@
import React from 'react';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { useFloatingComments } from '@/components/comments/useFloatingComments';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/stores/useUIStore';
import { InlineCommentCard } from '@/components/comments/InlineCommentCard';
import { InlineCommentInput } from '@/components/comments/InlineCommentInput';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useDeviceInfo } from '@/lib/device';
@@ -389,24 +390,55 @@ export const PlanView: React.FC = () => {
return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel);
}, [getSessionKey, allDrafts, planFileLabel]);
const floatingComments = useFloatingComments({
editorView: editorViewRef.current,
wrapperRef: editorWrapperRef,
fileDrafts: planFileDrafts,
editingDraftId,
commentText,
lineSelection,
isDragging,
fileLabel: planFileLabel,
onSaveComment: handleSaveComment,
onCancelComment: handleCancelComment,
onEditDraft: (draft) => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
},
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
});
const blockWidgets = React.useMemo(() => {
const widgets: Array<{ afterLine: number; id: string; content: React.ReactNode }> = [];
for (const draft of planFileDrafts) {
const content = draft.id === editingDraftId ? (
<InlineCommentInput
key={`edit-${draft.id}`}
initialText={commentText}
fileLabel={draft.fileLabel}
lineRange={{ start: draft.startLine, end: draft.endLine }}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
) : (
<InlineCommentCard
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 (
<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="relative h-full">
<div
className="h-full"
style={{
['--oc-plan-comment-pad' as string]: '0px',
}}
>
<div className="h-full oc-plan-editor">
{mdViewMode === 'preview' ? (
<div className="h-full overflow-auto p-3">
<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 className="h-full oc-plan-editor">
{mdViewMode === 'preview' ? (
<div className="h-full overflow-auto p-3">
<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>
}
>
<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),
}
>
<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 [&_.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;
: 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;
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 });
setIsDragging(true);
return false;
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
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;
},
}}
/>
{floatingComments}
</div>
)}
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 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>
)}