From e47c027ea023192a548c6bbce38549479acbb555 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 01:02:33 +0300 Subject: [PATCH] feat(diff): unified comment UI with gutter plus and content-drag selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff comment editor is restyled as the shared comment pill (context line inside the box, auto-growing textarea, round attach button; Cmd/Ctrl+Enter attaches, Enter breaks the line) and the saved-comment card matches the composer preview entries. Pierre's gutter utility is enabled: hovering a line shows a small primary-colored plus that opens a comment for the line, dragging from it selects a range, and it rides the bottom of an active selection. Clicking a diff line toggles a single-line comment, and dragging over content maps to the same line selection the number column produces — the native text selection is suppressed once a drag crosses a line boundary — opening the editor for the range on release. Placeholders and meta lines are dimmed relative to typed text. --- .../chat/message/TextSelectionMenu.tsx | 2 +- .../components/comments/InlineCommentCard.tsx | 199 ++++++++--------- .../comments/InlineCommentInput.tsx | 120 +++++----- .../src/components/views/PierreDiffViewer.tsx | 208 +++++++++++++++++- 4 files changed, 356 insertions(+), 173 deletions(-) diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index 0f9b16b2..6acb3dd0 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -616,7 +616,7 @@ export const TextSelectionMenu: React.FC = ({ containerR }} placeholder={t('chat.textSelection.comment.placeholder')} className={cn( - 'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]', + 'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60', // The width cap sizes the floating desktop pill; on mobile the pill // spans the bottom bar and the cap would strand slack space to the // right of the attach button. diff --git a/packages/ui/src/components/comments/InlineCommentCard.tsx b/packages/ui/src/components/comments/InlineCommentCard.tsx index 326b1695..2218ca3f 100644 --- a/packages/ui/src/components/comments/InlineCommentCard.tsx +++ b/packages/ui/src/components/comments/InlineCommentCard.tsx @@ -1,17 +1,9 @@ import React, { useState } from 'react'; import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; -import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { Icon } from "@/components/icon/Icon"; +import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; interface InlineCommentCardProps { @@ -22,6 +14,14 @@ interface InlineCommentCardProps { maxWidth?: number; } +const HEADER_ACTION_CLASS = 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'; + +/** + * A saved inline comment shown under its lines in the diff/editor. Styled to + * match the composer's context preview entries: a muted header band naming + * the file and range with direct edit/remove actions, and the comment text + * below. + */ export function InlineCommentCard({ draft, onEdit, @@ -30,115 +30,90 @@ export function InlineCommentCard({ maxWidth, }: InlineCommentCardProps) { const { t } = useI18n(); - const themeContext = useOptionalThemeSystem(); - const currentTheme = themeContext?.currentTheme; const [isOpen, setIsOpen] = useState(false); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const draftText = typeof draft.text === 'string' ? draft.text : ''; - + const draftText = draft.text; + // 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 = draftText.length > 150 || draftText.split('\n').length > 3; return ( - - { - event.preventDefault(); - setIsContextMenuOpen(true); - }} - /> - } +
+
-
-
-
- - {draft.fileLabel} - - - {t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })} - {draft.side && ({draft.side})} -
- - -
- {draftText} -
- - {isLongContent && ( - - - - )} - - - {/* Used for animation purposes if we want to animate height */} - -
-
- - - - - - - - - {t('inlineComment.actions.editComment')} - - - - {t('inlineComment.actions.deleteComment')} - - - + + {draft.fileLabel} + + + + {t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })} + {draft.side ? ` (${draft.side})` : ''} + + +
- - - - - {t('inlineComment.actions.editComment')} - - - - {t('inlineComment.actions.deleteComment')} - - - + +
+ +
+ {draftText} +
+ + {isLongContent && ( + + + + )} + + + {/* Used for animation purposes if we want to animate height */} + +
+
+
); } diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx index 7b76b5aa..bcdf248c 100644 --- a/packages/ui/src/components/comments/InlineCommentInput.tsx +++ b/packages/ui/src/components/comments/InlineCommentInput.tsx @@ -1,8 +1,6 @@ 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 { Icon } from '@/components/icon/Icon'; import { useDeviceInfo } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; @@ -18,6 +16,12 @@ export interface InlineCommentInputProps { maxWidth?: number; } +/** + * The comment editor shown under selected diff/editor lines. Styled as the + * same pill used by chat quote comments and browser annotations: a rounded + * auto-growing textarea with a round attach button, and a muted context line + * above naming the file and range. + */ export function InlineCommentInput({ initialText = '', onTextChange, @@ -30,17 +34,24 @@ export function InlineCommentInput({ maxWidth, }: InlineCommentInputProps) { const { t } = useI18n(); - const themeContext = useOptionalThemeSystem(); - const currentTheme = themeContext?.currentTheme; const { isMobile } = useDeviceInfo(); const [text, setText] = React.useState(initialText); const textareaRef = useRef(null); + void isEditing; const handleTextChange = (value: string) => { setText(value); onTextChange?.(value); + resizeTextarea(); }; - + + const resizeTextarea = () => { + const element = textareaRef.current; + if (!element) return; + element.style.height = 'auto'; + element.style.height = `${Math.min(element.scrollHeight, 120)}px`; + }; + // Stable range snapshot to prevent race with selection clearing const stableRangeRef = useRef(lineRange); useEffect(() => { @@ -62,8 +73,9 @@ export function InlineCommentInput({ useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; + resizeTextarea(); - const scrollContainer = textarea.closest('.overlay-scrollbar-container') as HTMLElement | null; + const scrollContainer = textarea.closest('.overlay-scrollbar-container'); const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY; const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX; @@ -100,12 +112,18 @@ export function InlineCommentInput({ }); }, [isMobile]); + const save = () => { + if (text.trim()) { + onSave(text, normalizeRange(stableRangeRef.current)); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + // As the placeholder promises: Cmd/Ctrl+Enter attaches, plain Enter + // breaks the line, Escape cancels. if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); - if (text.trim()) { - onSave(text, normalizeRange(stableRangeRef.current)); - } + save(); } else if (e.key === 'Escape') { e.preventDefault(); onCancel(); @@ -115,75 +133,61 @@ export function InlineCommentInput({ 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, normalizeRange(stableRangeRef.current)); - } + save(); }; return (
e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} > -
- {(fileLabel || lineRange) && ( -
- {fileLabel && {fileLabel}} - {fileLabel && lineRange && } - {displayRange && ( - - {t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })} - - )} +
+ {(fileLabel || displayRange) ? ( +
+ {fileLabel ? {fileLabel} : null} + {fileLabel && displayRange ? : null} + {displayRange ? ( + {t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })} + ) : null}
- )} - -