diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 5426de9a4d0..948207dc586 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -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); diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index b870ed9fc1c..3b47beab0ef 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -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(null) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) + const [copiedReviewNoteId, setCopiedReviewNoteId] = useState(null) const reviewNotesCopiedResetTimerRef = useRef(null) + const copiedReviewNoteResetTimerRef = useRef(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 => { + 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={ - - void clearDeliveredDiffComments(sourceWorktree.id, notes) - } - /> + <> + + + void clearDeliveredDiffComments(sourceWorktree.id, notes) + } + /> + } /> @@ -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} /> diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index b1d7197d08c..5af69e073ad 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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(null) const [activeReviewCommentId, setActiveReviewCommentId] = useState(null) const [attentionReviewCommentId, setAttentionReviewCommentId] = useState(null) const [notePositions, setNotePositions] = useState([]) @@ -660,6 +663,8 @@ export default function RichMarkdownEditor({ const markdownSourceLineOffsetRef = useRef(markdownSourceLineOffset) const attentionReviewCommentTimeoutRef = useRef(null) const sourceAttentionTimeoutRef = useRef(null) + const reviewNotesCopiedResetTimerRef = useRef(null) + const copiedReviewNoteResetTimerRef = useRef(null) const annotationTargetFrameRef = useRef(null) const notePositionsFrameRef = useRef(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 => { + 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 => { + 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={ - + + + void clearDeliveredDiffComments(worktreeId, notes) + } + /> + } /> @@ -1928,12 +2033,21 @@ export default function RichMarkdownEditor({ {markdownComments.length} + void clearDeliveredDiffComments(worktreeId, notes)} /> diff --git a/src/renderer/src/lib/markdown-review-note-copy.test.ts b/src/renderer/src/lib/markdown-review-note-copy.test.ts new file mode 100644 index 00000000000..73c8f515304 --- /dev/null +++ b/src/renderer/src/lib/markdown-review-note-copy.test.ts @@ -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> = {}): 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().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().mockResolvedValue() + + const copied = await copyMarkdownReviewNotesForAgent({ + notes: [], + content: 'one', + writeClipboardText + }) + + expect(copied).toBe(false) + expect(writeClipboardText).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/markdown-review-note-copy.ts b/src/renderer/src/lib/markdown-review-note-copy.ts new file mode 100644 index 00000000000..0d7eae8f7aa --- /dev/null +++ b/src/renderer/src/lib/markdown-review-note-copy.ts @@ -0,0 +1,20 @@ +import { formatMarkdownReviewNotes, type MarkdownReviewNote } from './markdown-review-notes' + +export type MarkdownReviewNoteClipboardWriter = (text: string) => Promise + +export async function copyMarkdownReviewNotesForAgent({ + notes, + content, + writeClipboardText +}: { + notes: readonly MarkdownReviewNote[] + content: string + writeClipboardText: MarkdownReviewNoteClipboardWriter +}): Promise { + if (notes.length === 0) { + return false + } + + await writeClipboardText(formatMarkdownReviewNotes(notes, content)) + return true +}