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';