Add copy action for markdown review notes

This commit is contained in:
Neil
2026-05-31 06:19:24 -07:00
parent ed1e4ca892
commit 76eac3fa7a
5 changed files with 305 additions and 52 deletions
@@ -137,7 +137,7 @@
}
.rich-markdown-review-rail-toggle,
.rich-markdown-review-rail-send {
.rich-markdown-review-rail-action {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -156,20 +156,20 @@
font-weight: 600;
}
.rich-markdown-review-rail-send {
.rich-markdown-review-rail-action {
width: 26px;
padding: 0;
}
.rich-markdown-review-rail-toggle:hover,
.rich-markdown-review-rail-toggle[aria-expanded='true'],
.rich-markdown-review-rail-send:hover:not(:disabled) {
.rich-markdown-review-rail-action:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.rich-markdown-review-rail-send:disabled,
.rich-markdown-review-note-send:disabled {
.rich-markdown-review-rail-action:disabled,
.rich-markdown-review-note-action:disabled {
cursor: default;
opacity: 0.45;
}
@@ -220,7 +220,7 @@
}
}
.rich-markdown-review-note-send {
.rich-markdown-review-note-action {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -235,7 +235,7 @@
z-index: 1;
}
.rich-markdown-review-note-send:hover:not(:disabled) {
.rich-markdown-review-note-action:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
background: color-mix(in srgb, var(--foreground) 6%, transparent);
color: var(--foreground);
@@ -83,6 +83,7 @@ import {
sortMarkdownReviewNotes,
type MarkdownReviewNote
} from '@/lib/markdown-review-notes'
import { copyMarkdownReviewNotesForAgent } from '@/lib/markdown-review-note-copy'
import { NotesSendMenu, type NotesSendMenuScope } from './NotesSendMenu'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { dirname } from '@/lib/path'
@@ -557,7 +558,9 @@ export default function MarkdownPreview({
}, [frontMatter])
const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState<string | null>(null)
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
const [copiedReviewNoteId, setCopiedReviewNoteId] = useState<string | null>(null)
const reviewNotesCopiedResetTimerRef = useRef<number | null>(null)
const copiedReviewNoteResetTimerRef = useRef<number | null>(null)
// Why: clipboard IPC can resolve after the preview unmounts; skip copied
// feedback instead of starting a reset timer on a stale preview.
const reviewNotesCopyMountedRef = useRef(false)
@@ -568,10 +571,6 @@ export default function MarkdownPreview({
() => sortMarkdownReviewNotes(markdownComments as MarkdownReviewNote[]),
[markdownComments]
)
const markdownReviewPrompt = useMemo(
() => formatMarkdownReviewNotes(markdownReviewNotes, renderedContent),
[markdownReviewNotes, renderedContent]
)
const unsentMarkdownReviewNotes = useMemo(
() => markdownReviewNotes.filter((note) => !note.sentAt),
[markdownReviewNotes]
@@ -703,13 +702,21 @@ export default function MarkdownPreview({
}
}, [])
const clearCopiedReviewNoteResetTimer = useCallback((): void => {
if (copiedReviewNoteResetTimerRef.current !== null) {
window.clearTimeout(copiedReviewNoteResetTimerRef.current)
copiedReviewNoteResetTimerRef.current = null
}
}, [])
const cleanupPreviewSurfaceTimers = useCallback((): void => {
// Why: reveal/copy timers are event-owned, but the final cancellation
// belongs to the preview surface unmount.
cancelMarkdownPreviewEditorRevealFrames(pendingEditorRevealFrameIdsRef)
clearMarkdownPreviewTimeout(attentionReviewCommentTimeoutRef)
clearReviewNotesCopiedResetTimer()
}, [clearReviewNotesCopiedResetTimer])
clearCopiedReviewNoteResetTimer()
}, [clearCopiedReviewNoteResetTimer, clearReviewNotesCopiedResetTimer])
const setRootRef = useCallback(
(node: HTMLDivElement | null) => {
@@ -848,8 +855,12 @@ export default function MarkdownPreview({
return
}
try {
await window.api.ui.writeClipboardText(markdownReviewPrompt)
if (!reviewNotesCopyMountedRef.current) {
const copied = await copyMarkdownReviewNotesForAgent({
notes: markdownReviewNotes,
content: renderedContent,
writeClipboardText: window.api.ui.writeClipboardText
})
if (!copied || !reviewNotesCopyMountedRef.current) {
return
}
clearReviewNotesCopiedResetTimer()
@@ -861,7 +872,31 @@ export default function MarkdownPreview({
} catch {
// Best-effort clipboard action; failures usually mean the window is not focused.
}
}, [clearReviewNotesCopiedResetTimer, markdownReviewNotes.length, markdownReviewPrompt])
}, [clearReviewNotesCopiedResetTimer, markdownReviewNotes, renderedContent])
const handleCopyMarkdownReviewNote = useCallback(
async (note: MarkdownReviewNote): Promise<void> => {
try {
const copied = await copyMarkdownReviewNotesForAgent({
notes: [note],
content: renderedContent,
writeClipboardText: window.api.ui.writeClipboardText
})
if (!copied || !reviewNotesCopyMountedRef.current) {
return
}
clearCopiedReviewNoteResetTimer()
setCopiedReviewNoteId(note.id)
copiedReviewNoteResetTimerRef.current = window.setTimeout(() => {
copiedReviewNoteResetTimerRef.current = null
setCopiedReviewNoteId(null)
}, 1600)
} catch {
// Best-effort clipboard action; failures usually mean the window is not focused.
}
},
[clearCopiedReviewNoteResetTimer, renderedContent]
)
const pulseRenderedMarkdownReviewNote = useCallback((commentId: string): void => {
if (attentionReviewCommentTimeoutRef.current !== null) {
@@ -1029,16 +1064,39 @@ export default function MarkdownPreview({
onDelete={() => void deleteDiffComment(sourceWorktree.id, comment.id)}
onSubmitEdit={(body) => updateDiffComment(sourceWorktree.id, comment.id, body)}
headerActions={
<MarkdownSingleNoteSendMenu
worktreeId={sourceWorktree.id}
filePath={filePath}
content={renderedContent}
note={comment as MarkdownReviewNote}
modeSlot="preview-inline"
onDelivered={(notes) =>
void clearDeliveredDiffComments(sourceWorktree.id, notes)
}
/>
<>
<button
type="button"
className="orca-diff-comment-pill-btn"
title={
copiedReviewNoteId === comment.id ? 'Copied note' : 'Copy note for agent'
}
aria-label={
copiedReviewNoteId === comment.id ? 'Copied note' : 'Copy note for agent'
}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void handleCopyMarkdownReviewNote(comment as MarkdownReviewNote)
}}
>
{copiedReviewNoteId === comment.id ? (
<Check className="size-3" />
) : (
<Copy className="size-3" />
)}
</button>
<MarkdownSingleNoteSendMenu
worktreeId={sourceWorktree.id}
filePath={filePath}
content={renderedContent}
note={comment as MarkdownReviewNote}
modeSlot="preview-inline"
onDelivered={(notes) =>
void clearDeliveredDiffComments(sourceWorktree.id, notes)
}
/>
</>
}
/>
</div>
@@ -1053,9 +1111,11 @@ export default function MarkdownPreview({
attentionReviewCommentId,
addDiffComment,
clearDeliveredDiffComments,
copiedReviewNoteId,
deleteDiffComment,
filePath,
getMarkdownCommentsForRange,
handleCopyMarkdownReviewNote,
markdownAnnotationsEnabled,
content,
renderedContent,
@@ -1755,7 +1815,7 @@ function MarkdownSingleNoteSendMenu({
}
]}
targetModeLabel="This note"
triggerClassName="markdown-review-icon-button"
triggerClassName="orca-diff-comment-pill-btn"
disabledTooltip="Note already sent"
onDelivered={onDelivered}
/>
@@ -57,13 +57,14 @@ import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
import { DiffCommentCard } from '../diff-comments/DiffCommentCard'
import { NotesSendMenu, type NotesSendMenuScope } from './NotesSendMenu'
import { isMarkdownComment } from '@/lib/diff-comment-compat'
import { MessageSquare, Plus } from 'lucide-react'
import { Check, Copy, MessageSquare, Plus } from 'lucide-react'
import {
formatMarkdownReviewNotes,
getMarkdownReviewCardQuote,
sortMarkdownReviewNotes,
type MarkdownReviewNote
} from '@/lib/markdown-review-notes'
import { copyMarkdownReviewNotesForAgent } from '@/lib/markdown-review-note-copy'
import {
richMarkdownAnnotationHighlightPluginKey,
type RichMarkdownAnnotationHighlightRange
@@ -650,6 +651,8 @@ export default function RichMarkdownEditor({
null
)
const [reviewRailOpen, setReviewRailOpen] = useState(false)
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
const [copiedReviewNoteId, setCopiedReviewNoteId] = useState<string | null>(null)
const [activeReviewCommentId, setActiveReviewCommentId] = useState<string | null>(null)
const [attentionReviewCommentId, setAttentionReviewCommentId] = useState<string | null>(null)
const [notePositions, setNotePositions] = useState<RichMarkdownReviewNotePosition[]>([])
@@ -660,6 +663,8 @@ export default function RichMarkdownEditor({
const markdownSourceLineOffsetRef = useRef(markdownSourceLineOffset)
const attentionReviewCommentTimeoutRef = useRef<number | null>(null)
const sourceAttentionTimeoutRef = useRef<number | null>(null)
const reviewNotesCopiedResetTimerRef = useRef<number | null>(null)
const copiedReviewNoteResetTimerRef = useRef<number | null>(null)
const annotationTargetFrameRef = useRef<number | null>(null)
const notePositionsFrameRef = useRef<number | null>(null)
const isEditingLinkRef = useRef(false)
@@ -803,16 +808,28 @@ export default function RichMarkdownEditor({
}
}, [])
const clearReviewCopyTimers = useCallback(() => {
if (reviewNotesCopiedResetTimerRef.current !== null) {
window.clearTimeout(reviewNotesCopiedResetTimerRef.current)
reviewNotesCopiedResetTimerRef.current = null
}
if (copiedReviewNoteResetTimerRef.current !== null) {
window.clearTimeout(copiedReviewNoteResetTimerRef.current)
copiedReviewNoteResetTimerRef.current = null
}
}, [])
const setRootElement = useCallback(
(node: HTMLDivElement | null) => {
// Why: review-note pulses are tied to this editor root; ref cleanup
// keeps the existing unmount boundary without a passive Effect.
// Why: review-note pulses and copy feedback are tied to this editor root;
// ref cleanup keeps the unmount boundary out of passive Effects.
if (node === null) {
clearAttentionTimers()
clearReviewCopyTimers()
}
rootRef.current = node
},
[clearAttentionTimers]
[clearAttentionTimers, clearReviewCopyTimers]
)
const syncAnnotationTarget = useCallback((nextEditor: Editor): void => {
@@ -856,6 +873,60 @@ export default function RichMarkdownEditor({
})
}, [])
const markReviewNotesCopied = useCallback((): void => {
clearReviewCopyTimers()
setCopiedReviewNoteId(null)
setReviewNotesCopied(true)
reviewNotesCopiedResetTimerRef.current = window.setTimeout(() => {
reviewNotesCopiedResetTimerRef.current = null
setReviewNotesCopied(false)
}, 1600)
}, [clearReviewCopyTimers])
const markReviewNoteCopied = useCallback((noteId: string): void => {
if (copiedReviewNoteResetTimerRef.current !== null) {
window.clearTimeout(copiedReviewNoteResetTimerRef.current)
}
setCopiedReviewNoteId(noteId)
copiedReviewNoteResetTimerRef.current = window.setTimeout(() => {
copiedReviewNoteResetTimerRef.current = null
setCopiedReviewNoteId(null)
}, 1600)
}, [])
const handleCopyMarkdownReviewNotes = useCallback(async (): Promise<void> => {
try {
const copied = await copyMarkdownReviewNotesForAgent({
notes: markdownReviewNotes,
content: markdownReviewContent,
writeClipboardText: window.api.ui.writeClipboardText
})
if (copied && rootRef.current) {
markReviewNotesCopied()
}
} catch {
// Best-effort clipboard action; failures usually mean the window is not focused.
}
}, [markdownReviewContent, markdownReviewNotes, markReviewNotesCopied])
const handleCopyMarkdownReviewNote = useCallback(
async (note: MarkdownReviewNote): Promise<void> => {
try {
const copied = await copyMarkdownReviewNotesForAgent({
notes: [note],
content: markdownReviewContent,
writeClipboardText: window.api.ui.writeClipboardText
})
if (copied && rootRef.current) {
markReviewNoteCopied(note.id)
}
} catch {
// Best-effort clipboard action; failures usually mean the window is not focused.
}
},
[markdownReviewContent, markReviewNoteCopied]
)
const syncNotePositions = useCallback((): void => {
const ed = editorRef.current
const container = scrollContainerRef.current
@@ -1795,28 +1866,62 @@ export default function RichMarkdownEditor({
onSubmitEdit={(body) => updateDiffComment(worktreeId, comment.id, body)}
onContentResize={syncNotePositions}
headerActions={
<NotesSendMenu
worktreeId={worktreeId}
groupId={worktreeId}
modeIdParts={['markdown-notes', worktreeId, filePath, 'note', comment.id]}
scopes={[
{
id: 'note',
label: 'This note',
notes: comment.sentAt ? [] : [comment as MarkdownReviewNote],
prompt: formatMarkdownReviewNotes(
[comment as MarkdownReviewNote],
markdownReviewContent
)
<>
<button
type="button"
className="rich-markdown-review-note-action"
title={
copiedReviewNoteId === comment.id
? 'Copied note'
: 'Copy note for agent'
}
]}
targetModeLabel="This note"
triggerClassName="rich-markdown-review-note-send"
disabledTooltip="Note already sent"
onDelivered={(notes) =>
void clearDeliveredDiffComments(worktreeId, notes)
}
/>
aria-label={
copiedReviewNoteId === comment.id
? 'Copied note'
: 'Copy note for agent'
}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void handleCopyMarkdownReviewNote(comment as MarkdownReviewNote)
}}
>
{copiedReviewNoteId === comment.id ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<NotesSendMenu
worktreeId={worktreeId}
groupId={worktreeId}
modeIdParts={[
'markdown-notes',
worktreeId,
filePath,
'note',
comment.id
]}
scopes={[
{
id: 'note',
label: 'This note',
notes: comment.sentAt ? [] : [comment as MarkdownReviewNote],
prompt: formatMarkdownReviewNotes(
[comment as MarkdownReviewNote],
markdownReviewContent
)
}
]}
targetModeLabel="This note"
triggerClassName="rich-markdown-review-note-action"
disabledTooltip="Note already sent"
onDelivered={(notes) =>
void clearDeliveredDiffComments(worktreeId, notes)
}
/>
</>
}
/>
</div>
@@ -1928,12 +2033,21 @@ export default function RichMarkdownEditor({
<MessageSquare className="size-3.5" />
<span>{markdownComments.length}</span>
</button>
<button
type="button"
className="rich-markdown-review-rail-action"
title={reviewNotesCopied ? 'Copied notes' : 'Copy notes for agent'}
aria-label={reviewNotesCopied ? 'Copied notes' : 'Copy notes for agent'}
onClick={() => void handleCopyMarkdownReviewNotes()}
>
{reviewNotesCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
<NotesSendMenu
worktreeId={worktreeId}
groupId={worktreeId}
modeIdParts={['markdown-notes', worktreeId, filePath, 'rail']}
scopes={unsentMarkdownReviewScope}
triggerClassName="rich-markdown-review-rail-send"
triggerClassName="rich-markdown-review-rail-action"
onDelivered={(notes) => void clearDeliveredDiffComments(worktreeId, notes)}
/>
</div>
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import type { DiffComment } from '../../../shared/types'
import {
copyMarkdownReviewNotesForAgent,
type MarkdownReviewNoteClipboardWriter
} from './markdown-review-note-copy'
import type { MarkdownReviewNote } from './markdown-review-notes'
function note(overrides: Partial<Omit<DiffComment, 'source'>> = {}): MarkdownReviewNote {
return {
id: 'n1',
worktreeId: 'wt1',
filePath: 'README.md',
source: 'markdown',
lineNumber: 2,
body: 'needs detail',
createdAt: 0,
side: 'modified',
...overrides
}
}
describe('copyMarkdownReviewNotesForAgent', () => {
it('copies the same formatted markdown-review payload used by agent handoff', async () => {
const writeClipboardText = vi.fn<MarkdownReviewNoteClipboardWriter>().mockResolvedValue()
const copied = await copyMarkdownReviewNotesForAgent({
notes: [note({ selectedText: 'specific phrase', body: 'reword this' })],
content: 'one\nspecific phrase in a longer line',
writeClipboardText
})
expect(copied).toBe(true)
expect(writeClipboardText).toHaveBeenCalledOnce()
expect(writeClipboardText).toHaveBeenCalledWith(
[
'File: README.md',
'Source: markdown',
'Line 2',
'Excerpt:',
'> specific phrase',
'User comment: "reword this"'
].join('\n')
)
})
it('does not write the clipboard when there are no notes', async () => {
const writeClipboardText = vi.fn<MarkdownReviewNoteClipboardWriter>().mockResolvedValue()
const copied = await copyMarkdownReviewNotesForAgent({
notes: [],
content: 'one',
writeClipboardText
})
expect(copied).toBe(false)
expect(writeClipboardText).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,20 @@
import { formatMarkdownReviewNotes, type MarkdownReviewNote } from './markdown-review-notes'
export type MarkdownReviewNoteClipboardWriter = (text: string) => Promise<void>
export async function copyMarkdownReviewNotesForAgent({
notes,
content,
writeClipboardText
}: {
notes: readonly MarkdownReviewNote[]
content: string
writeClipboardText: MarkdownReviewNoteClipboardWriter
}): Promise<boolean> {
if (notes.length === 0) {
return false
}
await writeClipboardText(formatMarkdownReviewNotes(notes, content))
return true
}