diff --git a/package.json b/package.json index 0efc775e..f17cfdab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01cf0521..5efda2ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx new file mode 100644 index 00000000..aa051567 --- /dev/null +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -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

wrappers to avoid double margins in the tight card layout. + p: ({ children }) => {children}, + // Open links externally — sidebar is not a navigation context. + a: ({ href, children }) => ( + e.stopPropagation()} + > + {children} + + ), + // Why: react-markdown calls the `code` component for both inline `code` + // and the inside fenced blocks (

). 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
. This is
+  // more reliable than checking `className` — which is only set when
+  // the fenced block specifies a language (```js), not for bare ```.
+  code: ({ children }) => (
+    {children}
+  ),
+  // Compact pre blocks — no syntax highlighting needed for short comments
+  pre: ({ children }) => (
+    
+      {children}
+    
+ ), + // Compact lists + ul: ({ children }) => , + ol: ({ children }) =>
    {children}
, + // 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 }) => ( +
  • {children}
  • + ), + // Headings render as bold text at the same size — no visual hierarchy needed + // in a tiny sidebar card. + h1: ({ children }) => {children}, + h2: ({ children }) => {children}, + h3: ({ children }) => {children}, + h4: ({ children }) => {children}, + h5: ({ children }) => {children}, + h6: ({ children }) => {children}, + // Horizontal rules as a subtle divider + hr: () =>
    , + // Compact blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // 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 }) => ( + e.stopPropagation()} + > + {alt || 'image'} + + ), + // 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 }) => ( +
    + + {children} +
    +
    + ) +} + +// 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
    , 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 ( +
    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}
    +      
    +    
    + ) +}) + +export default CommentMarkdown diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 3223d142..6b7f42bb 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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 && ( -
    - {worktree.comment} -
    + /> )} )} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 52dcbdc6..43f673a9 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -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" />

    - 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.

    diff --git a/src/renderer/src/components/sidebar/worktree-list-estimate.ts b/src/renderer/src/components/sidebar/worktree-list-estimate.ts index b4fb7574..13338b1b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-estimate.ts +++ b/src/renderer/src/components/sidebar/worktree-list-estimate.ts @@ -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++ }