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++
}