feat: render markdown in worktree comment cards (#452)

Add CommentMarkdown component using react-markdown + remark-gfm +
remark-breaks to render rich formatting (bold, lists, code, links) in
sidebar worktree comment cards. Update height estimation to account for
markdown block elements and capped code block height.
This commit is contained in:
Jinjing 2026-04-10 14:11:55 -07:00 committed by GitHub
parent 3e8017627f
commit c726878a27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 196 additions and 10 deletions

View file

@ -80,6 +80,7 @@
"radix-ui": "^1.4.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.0",

View file

@ -136,6 +136,9 @@ importers:
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
remark-frontmatter:
specifier: ^5.0.0
version: 5.0.0
@ -4370,6 +4373,9 @@ packages:
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-newline-to-break@2.0.0:
resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@ -5058,6 +5064,9 @@ packages:
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
remark-frontmatter@5.0.0:
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
@ -10141,6 +10150,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-newline-to-break@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-find-and-replace: 3.0.2
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@ -11124,6 +11138,12 @@ snapshots:
unist-util-visit: 5.1.0
vfile: 6.0.3
remark-breaks@4.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-newline-to-break: 2.0.0
unified: 11.0.5
remark-frontmatter@5.0.0:
dependencies:
'@types/mdast': 4.0.4

View file

@ -0,0 +1,132 @@
import React from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Components } from 'react-markdown'
import { cn } from '@/lib/utils'
// Why: sidebar comments are rendered at 11px in a narrow card, so we strip
// block-level wrappers that add unwanted margins and only keep inline
// formatting (bold, italic, code, links) plus compact lists and line breaks.
// Using react-markdown (already a project dependency) lets AI agents write
// markdown via `orca worktree set --comment` and have it render nicely.
const components: Components = {
// Strip <p> wrappers to avoid double margins in the tight card layout.
p: ({ children }) => <span className="comment-md-p">{children}</span>,
// Open links externally — sidebar is not a navigation context.
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
),
// Why: react-markdown calls the `code` component for both inline `code`
// and the <code> inside fenced blocks (<pre><code>…</code></pre>). We
// always apply inline-code styling here; the wrapper div uses a CSS
// descendant selector ([&_pre_code]) at higher specificity to strip
// the pill background/padding when code is inside a <pre>. This is
// more reliable than checking `className` — which is only set when
// the fenced block specifies a language (```js), not for bare ```.
code: ({ children }) => (
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono">{children}</code>
),
// Compact pre blocks — no syntax highlighting needed for short comments
pre: ({ children }) => (
<pre className="my-1 rounded bg-accent p-1.5 text-[10px] font-mono overflow-x-auto max-h-32">
{children}
</pre>
),
// Compact lists
ul: ({ children }) => <ul className="my-0.5 ml-3 list-disc space-y-0">{children}</ul>,
ol: ({ children }) => <ol className="my-0.5 ml-3 list-decimal space-y-0">{children}</ol>,
// Why: GFM task list checkboxes are non-functional in a read-only comment
// card (clicking them would just open the edit modal via the parent's
// onClick). Rendering them disabled avoids a misleading interactive
// affordance.
li: ({ children }) => (
<li className="leading-normal [&>input]:pointer-events-none">{children}</li>
),
// Headings render as bold text at the same size — no visual hierarchy needed
// in a tiny sidebar card.
h1: ({ children }) => <span className="font-bold">{children}</span>,
h2: ({ children }) => <span className="font-bold">{children}</span>,
h3: ({ children }) => <span className="font-semibold">{children}</span>,
h4: ({ children }) => <span className="font-semibold">{children}</span>,
h5: ({ children }) => <span className="font-semibold">{children}</span>,
h6: ({ children }) => <span className="font-semibold">{children}</span>,
// Horizontal rules as a subtle divider
hr: () => <hr className="my-1 border-border/50" />,
// Compact blockquotes
blockquote: ({ children }) => (
<blockquote className="my-0.5 border-l-2 border-border/60 pl-2 text-muted-foreground/80">
{children}
</blockquote>
),
// Why: images in a ~200px sidebar card would blow out the layout or look
// broken at any reasonable size. Render as a text link instead so the URL is
// still accessible without disrupting the card.
img: ({ alt, src }) => (
<a
href={src}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{alt || 'image'}
</a>
),
// Why: GFM tables in a ~200px sidebar would overflow badly. Wrapping in an
// overflow container keeps the card layout stable while still letting the
// user scroll to see the full table.
table: ({ children }) => (
<div className="my-1 overflow-x-auto">
<table className="text-[10px] border-collapse [&_td]:border [&_td]:border-border/40 [&_td]:px-1 [&_td]:py-0.5 [&_th]:border [&_th]:border-border/40 [&_th]:px-1 [&_th]:py-0.5 [&_th]:font-semibold [&_th]:text-left">
{children}
</table>
</div>
)
}
// Why: standard CommonMark collapses single newlines into spaces. The old
// plain-text renderer used whitespace-pre-wrap which preserved them. Adding
// remark-breaks converts single newlines to <br>, keeping backward compat
// with existing plain-text comments that rely on newline formatting.
const remarkPlugins = [remarkGfm, remarkBreaks]
type CommentMarkdownProps = {
content: string
className?: string
onClick?: (e: React.MouseEvent) => void
}
const CommentMarkdown = React.memo(function CommentMarkdown({
content,
className,
onClick
}: CommentMarkdownProps) {
return (
<div
className={cn(
// Reset inline-code pill styles when <code> is inside a <pre> block.
// The descendant selector (pre code) has higher specificity than the
// direct utility classes on <code>, so these overrides win reliably.
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none',
className
)}
onClick={onClick}
>
<Markdown remarkPlugins={remarkPlugins} components={components}>
{content}
</Markdown>
</div>
)
})
export default CommentMarkdown

View file

@ -7,6 +7,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
import { Bell, GitMerge, LoaderCircle, CircleDot, CircleCheck, CircleX } from 'lucide-react'
import StatusIndicator from './StatusIndicator'
import CacheTimer from './CacheTimer'
import CommentMarkdown from './CommentMarkdown'
import WorktreeContextMenu from './WorktreeContextMenu'
import { cn } from '@/lib/utils'
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
@ -558,12 +559,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{cardProps.includes('comment') && worktree.comment && (
<div
className="text-[11px] text-muted-foreground whitespace-pre-wrap break-words cursor-pointer -mx-1.5 px-1.5 py-0.5 hover:bg-background/40 hover:text-foreground rounded transition-colors leading-normal"
<CommentMarkdown
content={worktree.comment}
className="text-[11px] text-muted-foreground break-words cursor-pointer -mx-1.5 px-1.5 py-0.5 hover:bg-background/40 hover:text-foreground rounded transition-colors leading-normal [&_.comment-md-p]:block [&_.comment-md-p+.comment-md-p]:mt-1"
onClick={handleEditComment}
>
{worktree.comment}
</div>
/>
)}
</div>
)}

View file

@ -203,7 +203,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto"
/>
<p className="text-[10px] text-muted-foreground">
Press Enter or {isMac ? 'Cmd' : 'Ctrl'}+Enter to save, Shift+Enter for a new line.
Supports **markdown** bold, lists, `code`, links. Press Enter or{' '}
{isMac ? 'Cmd' : 'Ctrl'}+Enter to save, Shift+Enter for a new line.
</p>
</div>
</div>

View file

@ -48,16 +48,48 @@ export function estimateRowHeight(
}
}
if (cardProps.includes('comment') && wt.comment) {
// Comment renders with whitespace-pre-wrap + break-words, so its height
// depends on content. Estimate visual lines from explicit newlines and
// character wrapping (~35 chars per line at typical sidebar width).
// 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) {
totalLines += Math.max(1, Math.ceil(line.length / 35))
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
}
metaCount++
}