diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewController.submit-ownership.test.tsx b/src/renderer/src/components/editor/useRichMarkdownReviewController.submit-ownership.test.tsx new file mode 100644 index 00000000000..56108fec22f --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReviewController.submit-ownership.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import { + createRichMarkdownAnnotationHighlightExtension, + richMarkdownAnnotationHighlightPluginKey +} from './rich-markdown-annotation-highlight' +import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations' +import { useRichMarkdownReviewController } from './useRichMarkdownReviewController' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const editors: Editor[] = [] +const target: RichMarkdownAnnotationTarget = { + from: 1, + to: 4, + lineNumber: 1, + selectedText: 'abc', + top: 0, + buttonTop: 0, + buttonLeft: 0 +} +const saved: DiffComment = { + id: 'saved-note', + createdAt: 1, + worktreeId: 'folder-owner', + filePath: 'notes.md', + source: 'markdown', + lineNumber: 1, + selectedText: 'abc', + body: 'Review abc', + side: 'modified' +} + +function createEditor(): Editor { + const editor = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, createRichMarkdownAnnotationHighlightExtension()], + content: '

abc xyz

' + }) + editors.push(editor) + return editor +} + +function highlightXyz(editor: Editor): void { + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: { from: 5, to: 8 }, + noteRanges: [{ from: 5, to: 8 }] + }) + ) +} + +function highlightedText(editor: Editor): string[] { + return [...editor.view.dom.querySelectorAll('.rich-markdown-annotation-selection')].map( + (element) => element.textContent ?? '' + ) +} + +function mountReview() { + const editor = createEditor() + const editorRef: { current: Editor | null } = { current: editor } + const rootRef: { current: HTMLDivElement | null } = { current: document.createElement('div') } + const pending = Promise.withResolvers() + const addDiffComment = vi.fn((_input: Omit) => pending.promise) + let comments: DiffComment[] = [] + const hook = renderHook(() => + useRichMarkdownReviewController({ + addDiffComment, + allDiffComments: comments, + content: 'abc xyz', + editorRef, + filePath: '/workspace/notes.md', + markdownAnnotationsEnabled: true, + markdownReviewContent: 'abc xyz', + markdownSourceLineOffset: 0, + rootRef, + scrollContainerRef: { current: document.createElement('div') }, + worktreeId: 'folder-owner', + worktreeRoot: '/workspace' + }) + ) + act(() => hook.result.current.setAnnotationPopover(target)) + return { + ...hook, + editor, + pending, + addDiffComment, + updateComments(next: DiffComment[]) { + comments = next + hook.rerender() + } + } +} + +afterEach(() => { + cleanup() + for (const editor of editors.splice(0)) { + editor.destroy() + } +}) + +describe('review submission owner', () => { + it('leaves a newer draft open when an older submission completes', async () => { + const review = mountReview() + const submission = review.result.current.submitAnnotation('Review abc') + const nextTarget = { ...target, from: 5, to: 8, selectedText: 'xyz' } + act(() => review.result.current.setAnnotationPopover(nextTarget)) + await act(async () => { + review.pending.resolve(saved) + await submission + }) + expect(review.result.current.annotationPopover).toEqual(nextTarget) + }) + + it('keeps highlights for comments that arrive while persistence is pending', async () => { + const review = mountReview() + const submission = review.result.current.submitAnnotation('Review abc') + act(() => { + review.updateComments([{ ...saved, id: 'other-note', selectedText: 'xyz' }]) + highlightXyz(review.editor) + }) + await act(async () => { + review.pending.resolve(saved) + await submission + }) + expect(highlightedText(review.editor)).toEqual(['abc', 'xyz']) + }) + + it('closes the current draft and highlights the saved note on success', async () => { + const review = mountReview() + const submission = review.result.current.submitAnnotation('Review abc') + await act(async () => { + review.pending.resolve(saved) + await submission + }) + expect(review.result.current.annotationPopover).toBeNull() + expect(highlightedText(review.editor)).toEqual(['abc']) + }) + + it('preserves a failed draft for retry', async () => { + const failure = mountReview() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const failedSubmission = failure.result.current.submitAnnotation('Review abc') + await act(async () => { + failure.pending.resolve(null) + await failedSubmission + }) + expect(failure.result.current.annotationPopover).toEqual(target) + expect(highlightedText(failure.editor)).toEqual([]) + } finally { + error.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts index 29d53249d0e..fcf17cce3e2 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts @@ -163,6 +163,7 @@ export function useRichMarkdownReviewController({ if (!annotationPopover || sourceRelativePath === null) { return } + const submittedEditor = editorRef.current const result = await addDiffComment({ worktreeId, filePath: sourceRelativePath, @@ -180,10 +181,20 @@ export function useRichMarkdownReviewController({ console.error('Failed to add markdown comment — draft preserved') return } + if ( + !submittedEditor || + submittedEditor.isDestroyed || + annotationPopoverRef.current !== annotationPopover + ) { + return + } + const currentComments = markdownCommentsRef.current updateRichMarkdownAnnotationHighlightsAfterSubmit({ annotationPopover, - comments: [...markdownComments, result], - editor: editorRef.current, + comments: currentComments.some((comment) => comment.id === result.id) + ? currentComments + : [...currentComments, result], + editor: submittedEditor, markdownSourceLineOffset }) setAnnotationPopover(null) @@ -195,7 +206,6 @@ export function useRichMarkdownReviewController({ annotationPopover, clearAnnotationHighlight, editorRef, - markdownComments, markdownSourceLineOffset, sourceRelativePath, worktreeId