mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
3e8017627f
commit
c726878a27
6 changed files with 196 additions and 10 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
132
src/renderer/src/components/sidebar/CommentMarkdown.tsx
Normal file
132
src/renderer/src/components/sidebar/CommentMarkdown.tsx
Normal 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
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue