Opencode/daring jackal (#356)

* feat: export inline comment components

Expose InlineCommentCard and InlineCommentInput from the comments index
Enable importing both components from a single module path

* feat: add InlineCommentCard for inline comments with collapse

Enable collapsing for long comments with Show more and Show less
Provide dropdown menu with edit and delete actions
Show file label and line range in the header

* feat: add InlineCommentInput component

Add InlineCommentInput component for per-line comments
Enable Cmd/Ctrl+Enter to save and Escape to cancel
Show file label and line range when provided and focus input on mount

* feat: render block widgets in CodeMirrorEditor

Add blockWidgets prop to configure widgets after lines
Render React portals into per-widget containers within the editor
Introduce BlockWidget and DOM container mapping to host widgets

* feat(PierreDiffViewer): enable inline comment drafting and theming

Add inline comment draft management for diff lines
Render InlineCommentInput and InlineCommentCard for comments in diff
Integrate theme system to support dark and light modes

* feat: improve inline comment editing in PlanView

Enable auto-scroll for mobile when editing inline comments
Cancel edits when clicking outside comment widgets or code gutter
Extend save handler to accept explicit range and guard against empty text

* feat(pierre-diff-viewer): enable mobile tap-to-extend selection

Extend selection on mobile when tapping the same side to include range
Show saved annotation cards on all devices

* fix(plan): show saved cards on all devices

Render saved cards on all devices
Ensure saved cards appear for mobile layouts as well

* refactor: simplify state usage and remove focus hacks

Remove pending focus state and related effects in PlanView and PierreDiffViewer
Initialize UI store usage via useUIStore() without destructuring values in PlanView and PierreDiffViewer
Align annotation handling to DiffLineAnnotation metadata and include side in lineRange for inline comments
This commit is contained in:
Nelson Pires
2026-02-08 15:42:50 +02:00
committed by GitHub
parent 7af81c2436
commit 1e012d37b4
6 changed files with 775 additions and 689 deletions
@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { RiMoreLine, RiDeleteBinLine, RiEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
interface InlineCommentCardProps {
draft: InlineCommentDraft;
onEdit: () => void;
onDelete: () => void;
className?: string;
}
export function InlineCommentCard({
draft,
onEdit,
onDelete,
className,
}: InlineCommentCardProps) {
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const [isOpen, setIsOpen] = useState(false);
// Check if content is long enough to warrant collapsing (rough estimate)
// In a real app we might measure line height, but length check is a good proxy for now
const isLongContent = draft.text.length > 150 || draft.text.split('\n').length > 3;
return (
<div
className={cn(
"rounded-lg border shadow-sm w-full overflow-hidden transition-all duration-200",
className
)}
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
data-comment-card="true"
>
<div className="flex items-start justify-between gap-2 p-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-1.5">
<span className="truncate max-w-[200px]" title={draft.fileLabel}>
{draft.fileLabel}
</span>
<span></span>
<span>Lines {draft.startLine}-{draft.endLine}</span>
{draft.side && <span>({draft.side})</span>}
</div>
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}>
<div className={cn("text-sm whitespace-pre-wrap break-words leading-relaxed", !isOpen && isLongContent && "line-clamp-3")}>
{draft.text}
</div>
{isLongContent && (
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-0 mt-1 text-xs text-muted-foreground hover:text-foreground w-full justify-start"
>
{isOpen ? (
<>
<RiArrowUpSLine className="size-3 mr-1" />
Show less
</>
) : (
<>
<RiArrowDownSLine className="size-3 mr-1" />
Show more
</>
)}
</Button>
</CollapsibleTrigger>
)}
<CollapsibleContent>
{/* Used for animation purposes if we want to animate height */}
</CollapsibleContent>
</Collapsible>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 -mr-1 text-muted-foreground hover:text-foreground"
>
<RiMoreLine className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<RiEditLine className="size-4 mr-2" />
Edit comment
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<RiDeleteBinLine className="size-4 mr-2" />
Delete comment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
@@ -0,0 +1,138 @@
import React, { useRef, useEffect } from 'react';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useDeviceInfo } from '@/lib/device';
export interface InlineCommentInputProps {
initialText?: string;
onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void;
onCancel: () => void;
fileLabel?: string;
lineRange?: { start: number; end: number; side?: 'additions' | 'deletions' };
isEditing?: boolean;
className?: string;
}
export function InlineCommentInput({
initialText = '',
onSave,
onCancel,
fileLabel,
lineRange,
isEditing = false,
className,
}: InlineCommentInputProps) {
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const { isMobile } = useDeviceInfo();
const [text, setText] = React.useState(initialText);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Stable range snapshot to prevent race with selection clearing
const stableRangeRef = useRef(lineRange);
useEffect(() => {
if (lineRange) {
stableRangeRef.current = lineRange;
}
}, [lineRange]);
// Focus on mount (desktop only) or when becoming visible
useEffect(() => {
if (!isMobile && textareaRef.current) {
textareaRef.current.focus();
// Move cursor to end
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
} else if (isMobile && textareaRef.current) {
// Scroll into view on mobile
textareaRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [isMobile]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (text.trim()) {
onSave(text, stableRangeRef.current);
}
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {
// Stop propagation to prevent parent selection clearing before save
e.stopPropagation();
if (text.trim()) {
onSave(text, stableRangeRef.current);
}
};
return (
<div
className={cn(
"rounded-lg border shadow-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200",
className
)}
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
data-comment-input="true"
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<div className="p-3">
{(fileLabel || lineRange) && (
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
{fileLabel && lineRange && <span></span>}
{lineRange && <span>Lines {lineRange.start}-{lineRange.end}</span>}
</div>
)}
<Textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a comment... (Cmd+Enter to save)"
className="min-h-[80px] text-sm resize-y"
style={{
backgroundColor: currentTheme?.colors?.surface?.subtle,
}}
/>
<div className="flex items-center justify-end gap-2 mt-3">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="h-8 text-muted-foreground hover:text-foreground"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSaveClick}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
disabled={!text.trim()}
className="h-8 min-w-[80px]"
style={{
backgroundColor: currentTheme?.colors?.status?.success,
color: currentTheme?.colors?.status?.successForeground,
}}
>
{isEditing ? 'Save' : 'Comment'}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export * from './InlineCommentCard';
export * from './InlineCommentInput';
@@ -1,13 +1,20 @@
import React from 'react';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState, RangeSetBuilder } from '@codemirror/state';
import { Decoration, EditorView, ViewPlugin, gutters, keymap, lineNumbers } from '@codemirror/view';
import { Compartment, EditorState, RangeSetBuilder, StateField } from '@codemirror/state';
import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType, gutters, keymap, lineNumbers } from '@codemirror/view';
import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
export type BlockWidgetDef = {
afterLine: number;
id: string;
content: React.ReactNode;
};
type CodeMirrorEditorProps = {
value: string;
onChange: (value: string) => void;
@@ -16,6 +23,7 @@ type CodeMirrorEditorProps = {
readOnly?: boolean;
lineNumbersConfig?: Parameters<typeof lineNumbers>[0];
highlightLines?: { start: number; end: number };
blockWidgets?: BlockWidgetDef[];
onViewReady?: (view: EditorView) => void;
onViewDestroy?: () => void;
};
@@ -24,6 +32,83 @@ const lineNumbersCompartment = new Compartment();
const editableCompartment = new Compartment();
const externalExtensionsCompartment = new Compartment();
const highlightLinesCompartment = new Compartment();
const blockWidgetsCompartment = new Compartment();
// Map to store widget container elements by ID
// This allows us to render portals into them even if they are created by CM
const widgetContainers = new Map<string, HTMLElement>();
class BlockWidget extends WidgetType {
constructor(readonly id: string) {
super();
}
toDOM() {
let div = widgetContainers.get(this.id);
if (!div) {
div = document.createElement('div');
div.className = 'oc-block-widget';
div.dataset.widgetId = this.id;
widgetContainers.set(this.id, div);
}
return div;
}
eq(other: BlockWidget) {
return other.id === this.id;
}
destroy() {
// Optional: cleanup if needed, but we might want to keep the element for React to unmount gracefully?
// Actually, if CM destroys the DOM, React portal might complain if we don't unmount.
// But since we render portals based on the 'blockWidgets' prop, if the widget is removed from prop,
// the portal will be removed by React.
// If CM removes it because it's out of viewport, we still want the container to exist in our map?
// No, if CM removes it, we should probably let it go.
// But for now let's keep it simple.
}
}
const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
if (!widgets || widgets.length === 0) return [];
return StateField.define<DecorationSet>({
create(state) {
const builder = new RangeSetBuilder<Decoration>();
// Sort widgets by line number to add them in order
const sorted = [...widgets].sort((a, b) => a.afterLine - b.afterLine);
for (const w of sorted) {
const lineCount = state.doc.lines;
if (w.afterLine > lineCount) continue;
const line = state.doc.line(w.afterLine);
// Add widget decoration
builder.add(line.to, line.to, Decoration.widget({
widget: new BlockWidget(w.id),
block: true,
side: 1,
}));
}
return builder.finish();
},
update(deco, tr) {
// Always rebuild decorations when doc changes or widgets config changes
// But here we only see transaction.
// Since we reconfigure the compartment when props change, this update might mostly handle doc changes.
// For simplicity, we can map existing decorations or rebuild.
// Let's rebuild to ensure correct line placement.
// Wait, we can't access 'widgets' prop here easily unless we use a closure or effect.
// The `create` method runs when state is created.
// When we reconfigure the compartment, `create` might run again or we need `provide`.
// Actually, standard pattern is to map decorations.
return deco.map(tr.changes);
},
provide: f => EditorView.decorations.from(f)
});
};
const createHighlightLinesExtension = (range?: { start: number; end: number }): Extension => {
if (!range) {
@@ -67,6 +152,7 @@ export function CodeMirrorEditor({
highlightLines,
onViewReady,
onViewDestroy,
blockWidgets,
}: CodeMirrorEditorProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
@@ -74,6 +160,7 @@ export function CodeMirrorEditor({
const onChangeRef = React.useRef(onChange);
const onViewReadyRef = React.useRef(onViewReady);
const onViewDestroyRef = React.useRef(onViewDestroy);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
React.useEffect(() => {
valueRef.current = value;
@@ -102,6 +189,9 @@ export function CodeMirrorEditor({
indentUnit.of(' '),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.viewportChanged || update.geometryChanged) {
forceUpdate();
}
if (!update.docChanged) {
return;
}
@@ -112,6 +202,7 @@ export function CodeMirrorEditor({
editableCompartment.of(EditorView.editable.of(!readOnly)),
externalExtensionsCompartment.of(extensions ?? []),
highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)),
blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets)),
],
});
@@ -144,9 +235,10 @@ export function CodeMirrorEditor({
editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
externalExtensionsCompartment.reconfigure(extensions ?? []),
highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)),
blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets)),
],
});
}, [extensions, highlightLines, lineNumbersConfig, readOnly]);
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets]);
React.useEffect(() => {
const view = viewRef.current;
@@ -163,15 +255,25 @@ export function CodeMirrorEditor({
}, [value]);
return (
<div
ref={hostRef}
className={cn(
'h-full w-full',
'[&_.cm-editor]:h-full [&_.cm-editor]:w-full',
'[&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[var(--text-code)] [&_.cm-scroller]:leading-6',
'[&_.cm-lineNumbers]:text-[var(--tools-edit-line-number)]',
className,
)}
/>
<>
<div
ref={hostRef}
className={cn(
'h-full w-full',
'[&_.cm-editor]:h-full [&_.cm-editor]:w-full',
'[&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[var(--text-code)] [&_.cm-scroller]:leading-6',
'[&_.cm-lineNumbers]:text-[var(--tools-edit-line-number)]',
className,
)}
/>
{blockWidgets?.map((w) => {
// Look for the widget container in the editor DOM
// Since we store them in a map too (as backup/optimization), we could check there,
// but querySelector is safer to ensure it's actually in the DOM
const container = viewRef.current?.dom.querySelector(`[data-widget-id="${w.id}"]`);
if (!container) return null;
return createPortal(w.content, container, w.id);
})}
</>
);
}
@@ -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, RiEditLine } from '@remixicon/react';
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange, type DiffLineAnnotation, type AnnotationSide } from '@pierre/diffs';
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -10,20 +10,11 @@ import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/
import { getDefaultTheme } from '@/lib/theme/themes';
import { toast } from '@/components/ui';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel } from '@/lib/utils';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
interface PierreDiffViewerProps {
@@ -143,218 +134,129 @@ const isSameSelection = (left: SelectedLineRange | null, right: SelectedLineRang
return left.start === right.start && left.end === right.end && left.side === right.side;
};
type AnnotationData =
| { type: 'saved' | 'edit'; draft: InlineCommentDraft }
| { type: 'new'; selection: SelectedLineRange };
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
original,
modified,
language,
fileName = 'file',
fileName,
renderSideBySide,
wrapLines = false,
wrapLines,
layout = 'fill',
}) => {
const themeContext = useOptionalThemeSystem();
const isDark = themeContext?.themeMode === 'dark';
const lightTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.lightThemeId) ?? getDefaultTheme(false);
const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true);
useUIStore();
const { isMobile } = useDeviceInfo();
const { inputBarOffset, isKeyboardOpen } = useUIStore();
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLight.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDark.metadata.id;
const lightTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === lightThemeId) ??
fallbackLight;
const darkTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
fallbackDark;
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);
const isApplyingSelectionRef = useRef(false);
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
// Keep selectionRef in sync with state
useEffect(() => {
selectionRef.current = selection;
}, [selection]);
// Calculate initial center and width synchronously to avoid flicker
const getMainContentMetrics = useCallback(() => {
if (isMobile) return { center: '50%', width: '100vw' };
const mainContent = document.querySelector('main.flex-1');
if (mainContent) {
const rect = mainContent.getBoundingClientRect();
return {
center: `${rect.left + rect.width / 2}px`,
width: `${rect.width}px`
};
}
return { center: '50%', width: '100vw' };
}, [isMobile]);
const [mainContentMetrics, setMainContentMetrics] = useState(getMainContentMetrics);
const mainContentCenter = mainContentMetrics.center;
const mainContentWidth = mainContentMetrics.width;
const currentSessionId = useSessionStore(state => state.currentSessionId);
const newSessionDraftOpen = useSessionStore(state => state.newSessionDraft?.open);
// 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);
// Filter drafts locally to avoid returning new array refs from selector
const drafts = useMemo(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
if (!sessionKey) return [];
return (allDrafts[sessionKey] ?? []).filter(
d => d.source === 'diff' && d.fileLabel === fileName
);
}, [currentSessionId, newSessionDraftOpen, fileName, allDrafts]);
const { currentTheme } = useThemeSystem();
// Get session key for drafts
const getSessionKey = useCallback(() => {
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
}, [currentSessionId, newSessionDraftOpen]);
// Update main content metrics on resize
useEffect(() => {
if (isMobile) return;
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = useState('');
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
const updateMetrics = () => {
setMainContentMetrics(getMainContentMetrics());
};
// Use a ref to track if we're currently applying a selection programmatically
// to avoid loop with onLineSelected callback
const isApplyingSelectionRef = useRef(false);
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
window.addEventListener('resize', updateMetrics);
return () => window.removeEventListener('resize', updateMetrics);
}, [isMobile, getMainContentMetrics]);
// Stable handler that uses refs to avoid recreating on selection changes
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
// Ignore callbacks while we're programmatically applying selection
if (isApplyingSelectionRef.current) {
return;
}
const lastApplied = lastAppliedSelectionRef.current;
if (isSameSelection(range, lastApplied)) {
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
if (isMobile && selection && range && range.side === selection.side) {
const start = Math.min(selection.start, range.start);
const end = Math.max(selection.end, range.end);
setSelection({ ...range, start, end });
return;
}
const currentSelection = selectionRef.current;
if (isSameSelection(range, currentSelection)) {
return;
}
// On mobile: implement "tap to extend" behavior
// If user taps a new single line while we have an existing selection, extend the range
if (isMobile && range && currentSelection && range.start === range.end) {
const tappedLine = range.start;
const existingStart = currentSelection.start;
const existingEnd = currentSelection.end;
// Extend the selection to include the tapped line
const newStart = Math.min(existingStart, existingEnd, tappedLine);
const newEnd = Math.max(existingStart, existingEnd, tappedLine);
// Only extend if tapping outside current selection
if (tappedLine < existingStart || tappedLine > existingEnd) {
setSelection({
...range,
start: newStart,
end: newEnd,
});
return;
}
}
setSelection(range);
if (!range) {
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
}
}, [isMobile]);
// Dismiss selection when clicking outside line numbers (desktop behavior)
useEffect(() => {
if (!selection) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Check if click is inside the comment UI portal
if (commentContainerRef.current?.contains(target)) return;
// Check if click is inside toast (sonner)
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
// Check if click is on a line number (inside shadow DOM)
const path = e.composedPath();
const isLineNumber = path.some((el) => {
if (el instanceof HTMLElement) {
return el.hasAttribute('data-line-number') || el.closest?.('[data-line-number]');
}
return false;
});
if (!isLineNumber) {
setSelection(null);
// Clear editing state when selection changes user-driven
if (range) {
// Don't clear if we're just updating the selection for the same draft?
// For now, simple behavior: new selection = new comment flow
if (!editingDraftId) {
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
}
};
// Use timeout to avoid immediate dismissal from the same click that selected
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 100);
return () => {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [selection]);
}
}, [editingDraftId, isMobile, selection]);
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]);
// Helper to generate consistent annotation IDs
const getAnnotationId = useCallback((meta: AnnotationData): string => {
if (meta.type === 'saved' || meta.type === 'edit') {
return `draft-${meta.draft.id}`;
} else if (meta.type === 'new') {
return 'new-comment-input';
}
return '';
}, []);
const handleSaveComment = useCallback(() => {
if (!selection || !commentText.trim()) return;
// Robust target resolver that checks shadow root, light DOM, and container
const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
if (!id || !diffContainerRef.current) return null;
const diffsContainer = diffContainerRef.current.querySelector('diffs-container');
if (!diffsContainer) return null;
// Try shadow root first
const shadowTarget = diffsContainer.shadowRoot?.querySelector(`[data-annotation-id="${id}"]`);
if (shadowTarget) return shadowTarget as HTMLElement;
// Try light DOM (slotted content)
const lightTarget = diffsContainer.querySelector(`[data-annotation-id="${id}"]`);
if (lightTarget) return lightTarget as HTMLElement;
// Try container directly
const containerTarget = diffContainerRef.current.querySelector(`[data-annotation-id="${id}"]`);
if (containerTarget) return containerTarget as HTMLElement;
return null;
}, []);
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
const div = document.createElement('div');
// Ensure full width and proper spacing
div.className = 'w-full my-2';
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta);
div.dataset.annotationId = id;
return div;
}, [getAnnotationId]);
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
// Use provided range override or fall back to current selection
const targetRange = rangeOverride ?? selection;
if (!targetRange || !textToSave.trim()) return;
const sessionKey = getSessionKey();
if (!sessionKey) {
@@ -362,40 +264,46 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return;
}
const code = extractSelectedCode(original, modified, selection);
const side = selection.side === 'deletions' ? 'original' : 'modified';
const fileLabel = fileName ? fileName.split('/').pop() || 'unknown' : 'unknown';
// Pierre selection range: { start, end, side }
// Store needs { startLine, endLine, side: 'original'|'modified' }
// Pierre side: 'additions' (right) | 'deletions' (left)
const storeSide = targetRange.side === 'deletions' ? 'original' : 'modified';
// Use deterministic code extraction instead of instance.getSelectedText()
const selectedText = extractSelectedCode(original, modified, targetRange);
if (editingDraftId) {
updateDraft(sessionKey, editingDraftId, {
fileLabel: fileName,
startLine: selection.start,
endLine: selection.end,
side,
code,
language,
text: commentText.trim(),
fileLabel,
startLine: targetRange.start,
endLine: targetRange.end,
side: storeSide,
code: selectedText,
language: language,
text: textToSave.trim(),
});
} else {
addDraft({
sessionKey,
source: 'diff',
fileLabel: fileName,
startLine: selection.start,
endLine: selection.end,
side,
code,
language,
text: commentText.trim(),
fileLabel,
startLine: targetRange.start,
endLine: targetRange.end,
side: storeSide,
code: selectedText,
language: language,
text: textToSave.trim(),
});
}
// Clear selection and comment text
setCommentText('');
setSelection(null);
setEditingDraftId(null);
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
}, [selection, commentText, original, modified, fileName, language, addDraft, updateDraft, getSessionKey, editingDraftId]);
}, [selection, fileName, language, original, modified, addDraft, updateDraft, getSessionKey, editingDraftId]);
const applySelection = useCallback((range: SelectedLineRange) => {
setSelection(range);
@@ -420,6 +328,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const diffRootRef = useRef<HTMLDivElement | null>(null);
const diffContainerRef = useRef<HTMLDivElement | null>(null);
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const workerPool = useWorkerPool();
const lightResolvedTheme = useMemo(() => getResolvedShikiTheme(lightTheme), [lightTheme]);
@@ -499,6 +408,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
}
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
const options = useMemo(() => ({
theme: {
dark: darkTheme.metadata.id,
@@ -516,7 +426,51 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
enableHoverUtility: false,
onLineSelected: handleSelectionChange,
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange]);
renderAnnotation,
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
const lineAnnotations = useMemo(() => {
const sessionKey = getSessionKey();
if (!sessionKey) return [];
const sessionDrafts = allDrafts[sessionKey] ?? [];
// Match file label logic - use basename
const fileLabel = fileName ? fileName.split('/').pop() || 'unknown' : 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
const anns: DiffLineAnnotation<AnnotationData>[] = [];
fileDrafts.forEach((d) => {
// Force cast to AnnotationSide to satisfy compiler
const side = (d.side === 'original' ? 'deletions' : 'additions') as AnnotationSide;
if (d.id === editingDraftId) {
// Always show edit input (even on mobile)
anns.push({
lineNumber: d.endLine,
side: side,
metadata: { type: 'edit', draft: d },
});
} else {
// Show saved cards on all devices
anns.push({
lineNumber: d.endLine,
side: side,
metadata: { type: 'saved', draft: d },
});
}
});
if (selection && !editingDraftId) {
anns.push({
lineNumber: selection.end,
side: (selection.side ?? 'additions') as AnnotationSide,
metadata: { type: 'new', selection },
});
}
return anns;
}, [allDrafts, getSessionKey, fileName, editingDraftId, selection]);
// Imperative render (like upstream OpenCode): avoids `parseDiffFromFile` on main thread.
useEffect(() => {
@@ -536,13 +490,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
lastAppliedSelectionRef.current = null;
const oldFile: FileContents = {
name: fileName,
name: fileName || '',
contents: original,
lang: language as FileContents['lang'],
cacheKey: `old:${diffThemeKey}:${fileName}:${makeContentCacheKey(original)}`,
};
const newFile: FileContents = {
name: fileName,
name: fileName || '',
contents: modified,
lang: language as FileContents['lang'],
cacheKey: `new:${diffThemeKey}:${fileName}:${makeContentCacheKey(modified)}`,
@@ -551,10 +505,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
instance.render({
oldFile,
newFile,
lineAnnotations: [],
lineAnnotations,
containerWrapper: container,
});
// Force update to render portals into new DOM elements created by Pierre
requestAnimationFrame(() => forceUpdate());
return () => {
instance.cleanUp();
if (diffInstanceRef.current === instance) {
@@ -562,7 +519,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
}
container.innerHTML = '';
};
}, [diffThemeKey, fileName, language, modified, options, original, workerPool]);
}, [diffThemeKey, fileName, language, modified, options, original, workerPool, lineAnnotations]);
useEffect(() => {
const instance = diffInstanceRef.current;
@@ -591,190 +548,131 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
}
}, [selection]);
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
useEffect(() => {
const container = diffContainerRef.current;
if (!container) return;
let observer: MutationObserver | null = null;
let rafId: number | null = null;
const setupObserver = () => {
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')
)
);
if (hasAnnotationChanges) {
// Debounce with RAF to batch multiple mutations
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 });
};
// Delay setup to allow Pierre to initialize
const timeoutId = setTimeout(setupObserver, 100);
return () => {
clearTimeout(timeoutId);
if (rafId) cancelAnimationFrame(rafId);
observer?.disconnect();
};
}, [diffThemeKey, fileName]); // Re-setup when diff changes
if (typeof window === 'undefined') {
return null;
}
// Extracted Comment Interface Content for reuse in Portal or In-Flow
const renderCommentContent = () => {
if (!selection) return null;
return (
<div
className="flex flex-col items-center gap-2 px-4"
style={{ width: `min(calc(${mainContentWidth} - 2rem), 42rem)` }}
>
<div
className="w-full rounded-xl flex flex-col relative shadow-lg border border-border/80 focus-within:border-primary/70 focus-within:ring-1 focus-within:ring-primary/50"
style={{
backgroundColor: themeSystem?.currentTheme?.colors?.surface?.subtle,
// Render portals for inline comments with robust target resolution
const portals = lineAnnotations.map((ann) => {
const meta = (ann as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta);
// Use robust resolver that checks shadow, light DOM, and container
const target = resolveAnnotationTarget(id);
// If target not found, skip rendering (will retry on next update cycle)
if (!target) {
return null;
}
if (meta.type === 'saved') {
return createPortal(
<InlineCommentCard
key={id}
draft={meta.draft}
onEdit={() => {
const side = meta.draft.side === 'original' ? 'deletions' : 'additions';
applySelection({
start: meta.draft.startLine,
end: meta.draft.endLine,
side,
});
setCommentText(meta.draft.text);
setEditingDraftId(meta.draft.id);
}}
>
{/* Textarea - auto-grows from 1 line to max 5 lines */}
<Textarea
ref={commentInputRef}
value={commentText}
onChange={(e) => {
setCommentText(e.target.value);
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
const lineHeight = 20; // approx line height
const maxHeight = lineHeight * 5 + 8; // 5 lines + padding
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
}}
placeholder="Type your comment..."
outerClassName="focus-within:ring-0"
className="min-h-[28px] max-h-[108px] resize-none border-0 px-3 pt-2 pb-1 rounded-none appearance-none hover:border-transparent bg-transparent dark:bg-transparent overflow-y-auto focus:ring-0 focus:shadow-none"
autoFocus={!isMobile}
rows={1}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSaveComment();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelComment();
}
}}
/>
{/* Footer with Cancel and Comment buttons */}
<div className="px-2.5 py-1 flex items-center justify-between gap-x-2">
<span className="text-xs text-muted-foreground">
{fileName.split('/').pop()}:{selection.start}-{selection.end}
</span>
<div className="flex items-center gap-x-2">
{!isMobile && (
<span className="text-xs text-muted-foreground">
{getModifierLabel()}+
</span>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelComment}
className="h-7 px-2 text-xs"
>
Cancel
</Button>
<Button
type="button"
variant="default"
size="sm"
onClick={handleSaveComment}
disabled={!commentText.trim()}
className="h-7 px-2 text-xs"
style={{
backgroundColor: currentTheme?.colors?.status?.success,
color: currentTheme?.colors?.status?.successForeground,
}}
>
{editingDraftId ? 'Save' : 'Comment'}
</Button>
</div>
</div>
</div>
</div>
);
};
onDelete={() => removeDraft(meta.draft.sessionKey, meta.draft.id)}
/>,
target,
id
);
} else if (meta.type === 'edit') {
return createPortal(
<InlineCommentInput
key={id}
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={{
start: meta.draft.startLine,
end: meta.draft.endLine,
side: meta.draft.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>,
target,
id
);
} else {
return createPortal(
<InlineCommentInput
key={id}
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined}
isEditing={false}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>,
target,
id
);
}
});
// Render saved comment cards
const renderSavedComments = () => {
if (drafts.length === 0) return null;
return (
<div className="absolute inset-0 pointer-events-none z-40">
{drafts.map(draft => {
// Approximate line placement; shadow DOM prevents direct line querying.
const lineHeight = 24;
const top = (draft.startLine - 1) * lineHeight;
return (
<div
key={draft.id}
className="absolute pointer-events-auto"
style={{
top: `${top}px`,
right: '8px',
maxWidth: '300px',
}}
>
<div
className="rounded-lg border p-2 shadow-md"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-1">
{draft.fileLabel}:{draft.startLine}-{draft.endLine}
{draft.side && ` (${draft.side})`}
</div>
<div className="text-sm line-clamp-3">{draft.text}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex-shrink-0 p-1 rounded hover:bg-[var(--interactive-hover)] text-muted-foreground"
>
<RiMoreLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<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"
>
<RiDeleteBinLine className="h-4 w-4 mr-2" />
Delete comment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
})}
</div>
);
};
const commentContent = renderCommentContent();
// If we're in the main diff view ('fill' layout), render In-Flow (like ChatInput).
// If we're in an inline diff ('inline' layout), render via Portal (fixed over content).
if (layout === 'fill') {
return (
<div
className={cn("flex flex-col relative", "size-full")}
style={{
// Apply keyboard padding to the main container, just like ChatContainer
paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : undefined
}}
>
<div className={cn("flex flex-col relative", "size-full")}>
<div className="flex-1 relative min-h-0">
<ScrollableOverlay
outerClassName="pierre-diff-wrapper size-full"
@@ -783,72 +681,23 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
>
<div ref={diffRootRef} className="size-full relative">
<div ref={diffContainerRef} className="size-full" />
{renderSavedComments()}
{portals}
</div>
</ScrollableOverlay>
</div>
{/* Render Input overlay at the bottom */}
{selection && (
<div
className={cn(
"pointer-events-auto absolute bottom-0 left-0 right-0 pb-2 transition-none z-50 flex justify-center w-full",
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
)}
style={{
marginBottom: isMobile
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
: '16px'
}}
data-keyboard-avoid="true"
data-comment-ui="true"
ref={commentContainerRef}
>
{commentContent}
</div>
)}
</div>
);
}
// Fallback for 'inline' layout: use Portal behavior
// Use simple div with overflow-x-auto to avoid nested ScrollableOverlay issues in Chrome
// Fallback for 'inline' layout
return (
<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" />
{renderSavedComments()}
{portals}
</div>
{selection && createPortal(
<div
className="fixed inset-0 z-50 flex flex-col justify-end items-start pointer-events-none transition-none transform-gpu"
style={{
paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : '0px',
isolation: 'isolate'
}}
>
<div
className={cn(
"pointer-events-auto relative pb-2 transition-none",
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
)}
style={{
marginLeft: mainContentCenter,
transform: 'translateX(-50%)',
marginBottom: isMobile
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
: '16px'
}}
data-keyboard-avoid="true"
data-comment-ui="true"
ref={commentContainerRef}
>
{commentContent}
</div>
</div>,
document.body
)}
</div>
);
};
+110 -231
View File
@@ -1,33 +1,26 @@
import React from 'react';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/stores/useUIStore';
import { cn, getModifierLabel } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useDeviceInfo } from '@/lib/device';
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, RiEditLine } from '@remixicon/react';
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { toast } from '@/components/ui';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { createPortal } from 'react-dom';
const normalize = (value: string): string => {
if (!value) return '';
@@ -93,11 +86,10 @@ export const PlanView: React.FC = () => {
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const runtimeApis = useRuntimeAPIs();
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
const { inputBarOffset, isKeyboardOpen } = useUIStore();
useUIStore();
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);
@@ -138,8 +130,6 @@ 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';
@@ -180,37 +170,35 @@ export const PlanView: React.FC = () => {
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;
// Auto-scroll input into view on mobile
if (isMobile && !editingDraftId) {
// We rely on InlineCommentInput doing this now via useEffect
}
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const commentUI = document.querySelector('[data-comment-ui]');
if (commentUI?.contains(target)) return;
// Check if click is inside any comment component
if (
target.closest('[data-comment-card="true"]') ||
target.closest('[data-comment-input="true"]') ||
target.closest('.oc-block-widget')
) {
return;
}
if (target.closest('.cm-gutterElement')) return;
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
// If clicking outside while editing, maybe we should save or ask confirmation?
// For now, cancel edit
setLineSelection(null);
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
};
const timeoutId = window.setTimeout(() => {
@@ -221,7 +209,7 @@ export const PlanView: React.FC = () => {
window.clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [lineSelection]);
}, [lineSelection, editingDraftId, isMobile]);
const extractSelectedCode = React.useCallback((text: string, range: SelectedLineRange): string => {
const lines = text.split('\n');
@@ -235,11 +223,12 @@ export const PlanView: React.FC = () => {
setCommentText('');
setLineSelection(null);
setEditingDraftId(null);
setPendingFocus(false);
}, []);
const handleSaveComment = React.useCallback(() => {
if (!lineSelection || !commentText.trim()) return;
const handleSaveComment = React.useCallback((textToSave: string, rangeOverride?: { start: number; end: number }) => {
// Use provided range override or fall back to current selection
const targetRange = rangeOverride ?? lineSelection;
if (!targetRange || !textToSave.trim()) return;
const sessionKey = getSessionKey();
if (!sessionKey) {
@@ -247,29 +236,29 @@ export const PlanView: React.FC = () => {
return;
}
const code = extractSelectedCode(content, lineSelection);
const code = extractSelectedCode(content, targetRange);
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
if (editingDraftId) {
updateDraft(sessionKey, editingDraftId, {
fileLabel,
startLine: lineSelection.start,
endLine: lineSelection.end,
startLine: targetRange.start,
endLine: targetRange.end,
code,
language,
text: commentText.trim(),
text: textToSave.trim(),
});
} else {
addDraft({
sessionKey,
source: 'plan',
fileLabel,
startLine: lineSelection.start,
endLine: lineSelection.end,
startLine: targetRange.start,
endLine: targetRange.end,
code,
language,
text: commentText.trim(),
text: textToSave.trim(),
});
}
@@ -278,7 +267,8 @@ export const PlanView: React.FC = () => {
setEditingDraftId(null);
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
}, [lineSelection, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
const editorExtensions = React.useMemo(() => {
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
@@ -387,170 +377,87 @@ export const PlanView: React.FC = () => {
};
}, []);
const renderCommentUI = () => {
if (!lineSelection) return null;
return (
<div
data-comment-ui
className="flex flex-col items-center gap-2 px-4"
style={{ width: 'min(100vw - 1rem, 42rem)' }}
>
<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);
const textarea = e.target;
textarea.style.height = 'auto';
const lineHeight = 20;
const maxHeight = lineHeight * 5 + 8;
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
}}
placeholder="Type your comment..."
className="min-h-[28px] max-h-[108px] resize-none border-0 px-3 pt-2 pb-1 shadow-none rounded-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent dark:bg-transparent focus-visible:outline-none overflow-y-auto"
autoFocus={!isMobile}
rows={1}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSaveComment();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelComment();
}
}}
/>
{/* Footer with Cancel and Comment buttons */}
<div className="px-2.5 py-1 flex items-center justify-between gap-x-2">
<span className="text-xs text-muted-foreground">
Plan:{lineSelection.start}-{lineSelection.end}
</span>
<div className="flex items-center gap-x-2">
{!isMobile && (
<span className="text-xs text-muted-foreground">
{getModifierLabel()}+
</span>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelComment}
className="h-7 px-2 text-xs"
>
Cancel
</Button>
<Button
type="button"
variant="default"
size="sm"
onClick={handleSaveComment}
disabled={!commentText.trim()}
className="h-7 px-2 text-xs"
style={{
backgroundColor: currentTheme?.colors?.status?.success,
color: currentTheme?.colors?.status?.successForeground,
}}
>
{editingDraftId ? 'Save' : 'Comment'}
</Button>
</div>
</div>
</div>
</div>
);
};
// Render saved comment cards
const renderSavedComments = () => {
if (mdViewMode === 'preview') return null;
if (!editorView) return null;
const blockWidgets = React.useMemo(() => {
if (mdViewMode === 'preview') return [];
const sessionKey = getSessionKey();
if (!sessionKey) return null;
if (!sessionKey) return [];
const sessionDrafts = allDrafts[sessionKey] ?? [];
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === fileLabel);
if (fileDrafts.length === 0) return null;
const widgets: BlockWidgetDef[] = [];
return createPortal(
<div className="absolute inset-0 pointer-events-none z-40">
{fileDrafts.map((draft) => {
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
key={draft.id}
className="absolute pointer-events-auto"
style={{
top: `${top}px`,
right: '8px',
maxWidth: '300px',
// Add saved drafts
fileDrafts.forEach((draft) => {
if (draft.id === editingDraftId) {
// Always show edit input (even on mobile)
widgets.push({
afterLine: draft.endLine,
id: `edit-${draft.id}`,
content: (
<InlineCommentInput
fileLabel={fileLabel}
lineRange={{ start: draft.startLine, end: draft.endLine }}
initialText={commentText}
onSave={handleSaveComment}
onCancel={handleCancelComment}
isEditing={true}
/>
),
});
} else {
// Show saved cards on all devices
widgets.push({
afterLine: draft.endLine,
id: `draft-${draft.id}`,
content: (
<InlineCommentCard
draft={draft}
onEdit={() => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
}}
>
<div
className="rounded-lg border p-2 shadow-md"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-1">
{draft.fileLabel}:{draft.startLine}-{draft.endLine}
</div>
<div className="text-sm line-clamp-3">{draft.text}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex-shrink-0 p-1 rounded hover:bg-[var(--interactive-hover)] text-muted-foreground"
>
<RiMoreLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<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"
>
<RiDeleteBinLine className="h-4 w-4 mr-2" />
Delete comment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
})}
</div>,
editorView.scrollDOM
);
};
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
/>
),
});
}
});
// Add new comment input if selecting AND not editing an existing draft
if (lineSelection && !editingDraftId) {
widgets.push({
afterLine: lineSelection.end,
id: 'new-comment-input',
content: (
<InlineCommentInput
fileLabel={fileLabel}
lineRange={lineSelection}
initialText={commentText} // Usually empty for new, unless restored?
onSave={handleSaveComment}
onCancel={handleCancelComment}
isEditing={false}
/>
),
});
}
return widgets;
}, [
mdViewMode,
getSessionKey,
allDrafts,
displayPath,
editingDraftId,
lineSelection,
commentText,
handleSaveComment,
handleCancelComment,
removeDraft,
]);
return (
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
@@ -636,11 +543,7 @@ export const PlanView: React.FC = () => {
<div
className="h-full"
style={{
['--oc-plan-comment-pad' as string]: lineSelection
? (isMobile
? 'calc(var(--oc-keyboard-inset, 0px) + 140px)'
: '140px')
: '0px',
['--oc-plan-comment-pad' as string]: '0px',
}}
>
<div className="h-full oc-plan-editor">
@@ -669,14 +572,13 @@ export const PlanView: React.FC = () => {
readOnly={true}
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),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
blockWidgets={blockWidgets}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view, line, event) => {
@@ -725,7 +627,6 @@ export const PlanView: React.FC = () => {
},
}}
/>
{renderSavedComments()}
</div>
)}
</div>
@@ -734,28 +635,6 @@ export const PlanView: React.FC = () => {
)}
</ScrollableOverlay>
</div>
{lineSelection && mdViewMode !== 'preview' && (
<div
className="pointer-events-none absolute inset-0 z-50 flex flex-col justify-end"
style={{ paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : '0px' }}
>
<div
className={cn(
"pointer-events-auto pb-2 transition-none w-full flex justify-center",
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
)}
style={{
marginBottom: isMobile
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
: '16px'
}}
data-keyboard-avoid="true"
>
{renderCommentUI()}
</div>
</div>
)}
</div>
);
};