diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index aa0515672..4c90a6c8d 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -100,33 +100,36 @@ const components: Components = { // with existing plain-text comments that rely on newline formatting. const remarkPlugins = [remarkGfm, remarkBreaks] -type CommentMarkdownProps = { +type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { content: string - className?: string - onClick?: (e: React.MouseEvent) => void } -const CommentMarkdown = React.memo(function CommentMarkdown({ - content, - className, - onClick -}: CommentMarkdownProps) { - return ( -
is inside a
 block.
-        // The descendant selector (pre code) has higher specificity than the
-        // direct utility classes on , so these overrides win reliably.
-        '[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none',
-        className
-      )}
-      onClick={onClick}
-    >
-      
-        {content}
-      
-    
- ) -}) +// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref +// and event handlers (onPointerEnter, onPointerLeave, data-state, etc.) onto +// the child. Without forwarding both, the hover card cannot open or position. +const CommentMarkdown = React.memo( + React.forwardRef(function CommentMarkdown( + { content, className, ...rest }, + ref + ) { + return ( +
is inside a
 block.
+          // The descendant selector (pre code) has higher specificity than the
+          // direct utility classes on , so these overrides win reliably.
+          '[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none',
+          className
+        )}
+        {...rest}
+      >
+        
+          {content}
+        
+      
+ ) + }) +) export default CommentMarkdown diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 6b7f42bb9..cc1da3e81 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -559,11 +559,25 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} {cardProps.includes('comment') && worktree.comment && ( - + + + + + + + + )} )} diff --git a/src/renderer/src/components/sidebar/worktree-list-estimate.test.ts b/src/renderer/src/components/sidebar/worktree-list-estimate.test.ts index 25bbad2ad..2ddf2aa71 100644 --- a/src/renderer/src/components/sidebar/worktree-list-estimate.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-estimate.test.ts @@ -84,27 +84,21 @@ describe('estimateRowHeight', () => { expect(estimateRowHeight(itemRow(worktree), ['pr'], repoMap, prCache)).toBe(55) }) - it('estimates comment height based on content length', () => { + it('estimates comment height as fixed 2-line clamp', () => { const wt = { ...worktree, comment: 'todo: fix bug' } const base = estimateRowHeight(itemRow(worktree), ['comment'], repoMap, null) const withComment = estimateRowHeight(itemRow(wt), ['comment'], repoMap, null) - // 1 line × ceil(16.5)=17 + 4px py-0.5 + 8px meta spacing = 29 - expect(withComment - base).toBe(29) + // 2 lines × ceil(16.5)=33 + 4px py-0.5 + 8px meta spacing = 45 + expect(withComment - base).toBe(45) }) - it('estimates multi-line comment height from newlines', () => { - const wt = { ...worktree, comment: 'first line\nsecond line\nthird line' } - const h = estimateRowHeight(itemRow(wt), ['comment'], repoMap, null) - // base 55 + ceil(3 × 16.5)=50 + 4px py-0.5 + 8px meta spacing = 117 - expect(h).toBe(117) - }) - - it('estimates wrapped long lines in comment', () => { - // 70 chars wraps to 2 lines at ~35 chars/line - const wt = { ...worktree, comment: 'a'.repeat(70) } - const h = estimateRowHeight(itemRow(wt), ['comment'], repoMap, null) - // base 55 + ceil(2 × 16.5)=33 + 4px py-0.5 + 8px meta spacing = 100 - expect(h).toBe(100) + it('uses same fixed height for long comments (clamped to 2 lines)', () => { + const short = { ...worktree, comment: 'short' } + const long = { ...worktree, comment: 'first line\nsecond line\nthird line\nfourth line' } + const hShort = estimateRowHeight(itemRow(short), ['comment'], repoMap, null) + const hLong = estimateRowHeight(itemRow(long), ['comment'], repoMap, null) + // Both clamped to 2 lines, so same height + expect(hShort).toBe(hLong) }) it('stacks all metadata lines correctly', () => { @@ -113,9 +107,8 @@ describe('estimateRowHeight', () => { '/tmp/orca::feature/cool': { data: { number: 1 } } } const h = estimateRowHeight(itemRow(wt), ['issue', 'pr', 'comment'], repoMap, prCache) - // 55 base + 16 issue + 16 pr + (17+4) comment + 8 meta spacing + 2×3 gaps = 122 - // (inter-card gap is handled by the virtualizer's `gap` option, not here) - expect(h).toBe(122) + // 55 base + 16 issue + 16 pr + (33+4) comment + 8 meta spacing + 2×3 gaps = 138 + expect(h).toBe(138) }) it('strips refs/heads/ prefix when building PR cache key', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-estimate.ts b/src/renderer/src/components/sidebar/worktree-list-estimate.ts index 13338b1bb..8a7639086 100644 --- a/src/renderer/src/components/sidebar/worktree-list-estimate.ts +++ b/src/renderer/src/components/sidebar/worktree-list-estimate.ts @@ -48,48 +48,9 @@ export function estimateRowHeight( } } if (cardProps.includes('comment') && wt.comment) { - // Comment renders as markdown via react-markdown. Markdown block elements - // (lists, code blocks, blockquotes) add some vertical overhead compared to - // raw text, but the dominant factor is still line count. We estimate visual - // lines from explicit newlines and character wrapping (~35 chars per line at - // typical sidebar width), then add a small buffer for markdown block spacing. - // Line-height is leading-normal (1.5 × 11px = 16.5px) + py-0.5(4px). - const lines = wt.comment.split('\n') - let totalLines = 0 - let hasBlocks = false - let inCodeFence = false - let codeFenceLines = 0 - for (const line of lines) { - if (line.startsWith('```')) { - hasBlocks = true - if (inCodeFence) { - // Closing fence: cap at max-h-32 (128px) ÷ 16.5 ≈ 8 visible lines - totalLines += Math.min(codeFenceLines, 8) - codeFenceLines = 0 - } - inCodeFence = !inCodeFence - continue - } - if (inCodeFence) { - codeFenceLines++ - } else { - totalLines += Math.max(1, Math.ceil(line.length / 35)) - // Detect markdown block elements that add extra vertical spacing. - // Only check outside code fences — fenced content renders as plain code. - if (/^(\s*[-*+]\s|#{1,6}\s|>\s|---|\d+\.\s)/.test(line)) { - hasBlocks = true - } - } - } - // Handle unclosed code fence (treat remaining lines normally) - if (inCodeFence) { - totalLines += Math.min(codeFenceLines, 8) - } - h += Math.ceil(totalLines * 16.5) + 4 - // Markdown blocks (lists, headings, code fences) add ~4-8px of extra margin - if (hasBlocks) { - h += 8 - } + // Comment is clamped to 2 lines (line-clamp-2) in the card. Full content + // is shown in a hover card. Height: 2 lines × 16.5px + py-0.5(4px). + h += Math.ceil(2 * 16.5) + 4 metaCount++ }