Add review note chord guards (#9412)

* Guard add-review-note chord to prevent remount (product B)

- Scoped guards consume chord at composer level to prevent remount and leakage
- Handle OS key-repeat: ignore when no draft, consume when one is mounted
- Clear stale block keys in markdown preview when content renumbers
- Flush pending selection before reading targets to fix timing races

* fix(editor): repair add-review-note guard tests and close remaining chord gaps

- Update the product-B guard tests to the Mod+Shift+A default binding
  (#9257 retired Mod+Alt+N as AltGr-unsafe); they asserted the old chord,
  so seven landed red and the rich-editor repeat test passed vacuously.
- Monaco: recompute the annotation target from the live selection at
  keydown instead of the render-lagged ref, so a chord right after a drag
  cannot open on the previous selection or miss a fresh one.
- Preview: key the stale-block-key cleanup on renderedContent (the DOM
  the block keys live in), which can lag content during external-edit
  section preservation.

Co-authored-by: Orca <help@stably.ai>

* fix(editor): mirror shortcut-guard refs in effects instead of render body

CodeRabbit on #9412: render-body ref writes can leak from a render pass
React replays and discards. Move the state->ref mirrors for
commentPopoverRef / shouldShowMarkdownAnnotationsRef (MonacoEditor) and
activeAnnotationBlockKeyRef (MarkdownPreview) into effects; same-tick
keydown paths keep their eager event-handler writes.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-07-18 22:10:22 -07:00 committed by GitHub
parent 02ff7c7465
commit f7c2c1276c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 588 additions and 30 deletions

View File

@ -8,6 +8,7 @@ import {
hasBoundedCommentBodyText
} from '@/lib/comment-body-submit-state'
import { translate } from '@/i18n/i18n'
import { installOpenDraftAddReviewNoteGuard } from '../editor/editor-shortcuts'
import { resolveDiffCommentPopoverTop } from './diff-comment-popover-position'
// Why: rendered as a DOM sibling overlay inside the editor container rather
@ -141,6 +142,17 @@ export function DiffCommentPopover({
textarea?.focus()
}, [])
// Why: product B — the composer autofocuses its textarea, so a second
// add-review-note chord must not remount the draft; consume it on the
// popover subtree (not window) so other panes/surfaces keep their chord.
useEffect(() => {
const popover = popoverRef.current
if (!popover) {
return
}
return installOpenDraftAddReviewNoteGuard(popover)
}, [])
// Why: Monaco's editor area does not bubble a synthetic React click up to
// the popover's onClick. Without a document-level mousedown listener, the
// popover has no way to detect clicks outside its own bounds. We keep the

View File

@ -69,9 +69,10 @@ import {
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
import {
getMarkdownAnnotationBlockKeyForSelection,
isMarkdownPreviewAddReviewNoteShortcut
previewHasAnnotationBlockKey,
resolveMarkdownPreviewAddReviewNoteKey
} from './markdown-preview-annotation-shortcut'
import { installOpenDraftAddReviewNoteGuard } from './editor-shortcuts'
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
@ -636,6 +637,28 @@ export default function MarkdownPreview({
? (frontmatterVisibleByFile[toggleableSourceFileId] ?? true)
: true
const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState<string | null>(null)
const activeAnnotationBlockKeyRef = useRef(activeAnnotationBlockKey)
// Why: mirrored in an effect (not the render body) so a discarded render
// pass cannot leak into the ref; keydown paths still write it eagerly.
useEffect(() => {
activeAnnotationBlockKeyRef.current = activeAnnotationBlockKey
}, [activeAnnotationBlockKey])
// Why: line-derived block keys can go stale after content renumbers; drop them
// when the block no longer mounts so the shortcut cannot lock out forever.
useEffect(() => {
if (!activeAnnotationBlockKey) {
return
}
const root = rootRef.current
if (!root || previewHasAnnotationBlockKey(root, activeAnnotationBlockKey)) {
return
}
// Why: the mirror effect above re-syncs the ref once this setState commits;
// only the same-tick keydown paths need an eager manual write.
setActiveAnnotationBlockKey(null)
// Why: keyed on renderedContent (not content) — the DOM the block keys live
// in derives from it and can lag content during external-edit preservation.
}, [activeAnnotationBlockKey, renderedContent])
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
const [copiedReviewNoteId, setCopiedReviewNoteId] = useState<string | null>(null)
const reviewNotesCopiedResetTimerRef = useRef<number | null>(null)
@ -920,17 +943,33 @@ export default function MarkdownPreview({
return
}
if (
isMarkdownPreviewAddReviewNoteShortcut(event, getShortcutPlatform(), keybindings) &&
targetInsidePreview &&
markdownAnnotationsEnabled
) {
const blockKey = getMarkdownAnnotationBlockKeyForSelection(root, window.getSelection())
if (blockKey) {
event.preventDefault()
event.stopPropagation()
setActiveAnnotationBlockKey(blockKey)
}
const reviewNoteKey = resolveMarkdownPreviewAddReviewNoteKey({
event,
platform: getShortcutPlatform(),
keybindings,
targetInsidePreview,
markdownAnnotationsEnabled,
activeAnnotationBlockKey: activeAnnotationBlockKeyRef.current,
root,
selection: window.getSelection()
})
if (reviewNoteKey.action === 'consume') {
event.preventDefault()
event.stopPropagation()
return
}
if (reviewNoteKey.action === 'clear-stale-and-ignore') {
// Why: drop a line-derived key that no longer mounts a composer so the
// shortcut cannot stay permanently consumed after content renumbers.
activeAnnotationBlockKeyRef.current = null
setActiveAnnotationBlockKey(null)
return
}
if (reviewNoteKey.action === 'open') {
event.preventDefault()
event.stopPropagation()
activeAnnotationBlockKeyRef.current = reviewNoteKey.blockKey
setActiveAnnotationBlockKey(reviewNoteKey.blockKey)
return
}
@ -2052,6 +2091,18 @@ function MarkdownAnnotationComposer({
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const mountedRef = useMountedRef()
const composerRef = useRef<HTMLDivElement | null>(null)
// Why: product B — composer focus is in the textarea, so consume the bindable
// add-review-note chord on the composer subtree (same guard as
// DiffCommentPopover) rather than window, so other surfaces keep their chord.
useEffect(() => {
const composer = composerRef.current
if (!composer) {
return
}
return installOpenDraftAddReviewNoteGuard(composer)
}, [])
const focusTextareaRef = useCallback((textarea: HTMLTextAreaElement | null): void => {
// Why: opening an annotation composer should focus the draft field on the
@ -2082,7 +2133,11 @@ function MarkdownAnnotationComposer({
}
return (
<div className="markdown-annotation-composer" onClick={(event) => event.stopPropagation()}>
<div
ref={composerRef}
className="markdown-annotation-composer"
onClick={(event) => event.stopPropagation()}
>
<div className="orca-diff-comment-popover-label">
{translate('auto.components.editor.MarkdownPreview.b1bfc04034', 'Selected text')}
</div>

View File

@ -202,10 +202,13 @@ export default function MonacoEditor({
const [commentPopover, setCommentPopover] = useState<MarkdownCommentPopoverState | null>(null)
const [selectionAnnotationTarget, setSelectionAnnotationTarget] =
useState<MonacoMarkdownSelectionAnnotationTarget | null>(null)
// Why: the Monaco mount closure installs its keydown listeners once, so the
// add-review-note shortcut reads the live selection target through a ref.
const selectionAnnotationTargetRef = useRef<MonacoMarkdownSelectionAnnotationTarget | null>(null)
selectionAnnotationTargetRef.current = selectionAnnotationTarget
// Why: claim open drafts synchronously so a same-tick second chord cannot
// remount the composer before React commits commentPopover state. Mirrored
// in an effect so a discarded render pass cannot leak into the ref.
const commentPopoverRef = useRef<MarkdownCommentPopoverState | null>(null)
useEffect(() => {
commentPopoverRef.current = commentPopover
}, [commentPopover])
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@ -228,6 +231,12 @@ export default function MonacoEditor({
const shouldShowMarkdownAnnotations =
markdownAnnotationsEnabled && language === 'markdown' && Boolean(worktreeId)
// Why: the Monaco mount closure installs its keydown listeners once, so the
// add-review-note shortcut reads the current enablement through a ref.
const shouldShowMarkdownAnnotationsRef = useRef(shouldShowMarkdownAnnotations)
useEffect(() => {
shouldShowMarkdownAnnotationsRef.current = shouldShowMarkdownAnnotations
}, [shouldShowMarkdownAnnotations])
const pendingScrollForThisEditor = useMemo(() => {
if (!shouldShowMarkdownAnnotations || !scrollToDiffCommentId) {
@ -421,14 +430,28 @@ export default function MonacoEditor({
propsRef.current.onSave(value)
})
const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance)
// Opens the same composer as the selection "+" button; the target ref
// mirrors the last-rendered selection target and is null unless markdown
// annotations are enabled and text is selected.
// Opens the same composer as the selection "+" button.
const cleanupAddReviewNoteShortcut = installEditorAddReviewNoteShortcut(editorDomNode, () => {
const target = selectionAnnotationTargetRef.current
// Why: product B — keep an open draft instead of remounting (same-tick
// races and editor-focused second chords before the composer guard runs).
if (commentPopoverRef.current) {
return true
}
if (!shouldShowMarkdownAnnotationsRef.current) {
return false
}
// Why: the rendered target ref lags onDidChangeCursorSelection by a
// render, so a chord right after a drag could open on the previous
// selection (or miss a fresh one); read Monaco's live selection instead.
const target = getMonacoMarkdownSelectionAnnotationTarget(
editorInstance,
editorInstance.getSelection(),
getDiffCommentPopoverLeft(editorInstance, editorContainerRef.current) ?? undefined
)
if (!target) {
return false
}
commentPopoverRef.current = target
setCommentPopover(target)
setSelectionAnnotationTarget(null)
return true

View File

@ -21,7 +21,8 @@ import {
installEditorAddReviewNoteShortcut,
installEditorFindShortcut,
installMonacoDiffChangeNavigationShortcut,
installMonacoEditorFindShortcut
installMonacoEditorFindShortcut,
installOpenDraftAddReviewNoteGuard
} from './editor-shortcuts'
type ShortcutFixture = {
@ -270,6 +271,50 @@ describe('installEditorAddReviewNoteShortcut', () => {
})
})
describe('installOpenDraftAddReviewNoteGuard', () => {
it('consumes the add-review-note chord including OS key-repeat (product B)', () => {
// Why: the guard is scoped to the composer subtree, so mirror that with a
// container wrapping the focused textarea rather than attaching to window.
const container = document.createElement('div')
const input = document.createElement('textarea')
const onDownstreamKeyDown = vi.fn()
container.appendChild(input)
document.body.appendChild(container)
input.addEventListener('keydown', onDownstreamKeyDown)
const dispose = installOpenDraftAddReviewNoteGuard(container)
const first = dispatchKeyDown(input, {
key: 'a',
code: 'KeyA',
metaKey: true,
shiftKey: true
})
const repeat = dispatchKeyDown(input, {
key: 'a',
code: 'KeyA',
metaKey: true,
shiftKey: true,
repeat: true
})
const unrelated = dispatchKeyDown(input, { key: 'a', code: 'KeyA', metaKey: true })
expect(first.defaultPrevented).toBe(true)
expect(repeat.defaultPrevented).toBe(true)
expect(unrelated.defaultPrevented).toBe(false)
// Capture-phase guard stops propagation before the target listener.
expect(onDownstreamKeyDown).toHaveBeenCalledTimes(1)
dispose()
const afterDispose = dispatchKeyDown(input, {
key: 'a',
code: 'KeyA',
metaKey: true,
shiftKey: true
})
expect(afterDispose.defaultPrevented).toBe(false)
})
})
describe('installMonacoDiffChangeNavigationShortcut', () => {
function createDiffNavigationFixture(): {
container: HTMLDivElement

View File

@ -84,7 +84,12 @@ export function installEditorAddReviewNoteShortcut(
onAddReviewNote: () => boolean
): () => void {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.repeat || !editorShortcutMatches('editor.addReviewNote', event)) {
if (!editorShortcutMatches('editor.addReviewNote', event)) {
return
}
// Why: ignore OS key-repeat so a held chord cannot thrash open/remount.
// Open drafts are consumed by installOpenDraftAddReviewNoteGuard instead.
if (event.repeat) {
return
}
// Why: only consume the chord when a composer actually opens; on files
@ -100,6 +105,28 @@ export function installEditorAddReviewNoteShortcut(
return () => target.removeEventListener('keydown', handleKeyDown, true)
}
/**
* While a review-note/diff-comment draft composer is mounted, consume the
* bindable add-review-note chord (including OS key-repeat) so a second press
* cannot remount the composer or leak into other handlers (product B).
*
* Scoped to the composer's own subtree (which contains the focused textarea)
* rather than `window` so a draft open in one editor pane never swallows the
* chord typed into a different pane or surface.
*/
export function installOpenDraftAddReviewNoteGuard(target: HTMLElement): () => void {
const handleKeyDown = (event: KeyboardEvent): void => {
if (!editorShortcutMatches('editor.addReviewNote', event)) {
return
}
event.preventDefault()
event.stopPropagation()
}
target.addEventListener('keydown', handleKeyDown, true)
return () => target.removeEventListener('keydown', handleKeyDown, true)
}
type MonacoFindShortcutEditor = {
getAction: (id: string) => { run: () => void | Promise<void> } | null
getContainerDomNode: () => HTMLElement

View File

@ -3,7 +3,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
getMarkdownAnnotationBlockKeyForSelection,
isMarkdownPreviewAddReviewNoteShortcut
isMarkdownPreviewAddReviewNoteShortcut,
previewHasAnnotationBlockKey,
resolveMarkdownPreviewAddReviewNoteKey
} from './markdown-preview-annotation-shortcut'
function createPreviewFixture(): {
@ -105,3 +107,115 @@ describe('isMarkdownPreviewAddReviewNoteShortcut', () => {
).toBe(false)
})
})
describe('resolveMarkdownPreviewAddReviewNoteKey', () => {
const chord = {
key: 'a',
code: 'KeyA',
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: true,
repeat: false
}
it('consumes the chord while a mounted draft block is open (product B)', () => {
const { root } = createPreviewFixture()
expect(
resolveMarkdownPreviewAddReviewNoteKey({
event: chord,
platform: 'darwin',
targetInsidePreview: true,
markdownAnnotationsEnabled: true,
activeAnnotationBlockKey: 'p:3-5',
root,
selection: null
})
).toEqual({ action: 'consume' })
})
it('consumes OS key-repeat while a mounted draft is open', () => {
const { root } = createPreviewFixture()
expect(
resolveMarkdownPreviewAddReviewNoteKey({
event: { ...chord, repeat: true },
platform: 'darwin',
targetInsidePreview: true,
markdownAnnotationsEnabled: true,
activeAnnotationBlockKey: 'p:3-5',
root,
selection: null
})
).toEqual({ action: 'consume' })
})
it('ignores OS key-repeat when no draft is open', () => {
const { root, paragraph } = createPreviewFixture()
const selection = selectTextIn(paragraph)
expect(
resolveMarkdownPreviewAddReviewNoteKey({
event: { ...chord, repeat: true },
platform: 'darwin',
targetInsidePreview: true,
markdownAnnotationsEnabled: true,
activeAnnotationBlockKey: null,
root,
selection
})
).toEqual({ action: 'ignore' })
})
it('opens the composer for a live selection when no draft is open', () => {
const { root, paragraph } = createPreviewFixture()
const selection = selectTextIn(paragraph)
expect(
resolveMarkdownPreviewAddReviewNoteKey({
event: chord,
platform: 'darwin',
targetInsidePreview: true,
markdownAnnotationsEnabled: true,
activeAnnotationBlockKey: null,
root,
selection
})
).toEqual({ action: 'open', blockKey: 'p:3-5' })
})
it('clears a stale block key that no longer mounts a composer', () => {
const { root, paragraph } = createPreviewFixture()
const selection = selectTextIn(paragraph)
expect(previewHasAnnotationBlockKey(root, 'p:9-9')).toBe(false)
expect(
resolveMarkdownPreviewAddReviewNoteKey({
event: chord,
platform: 'darwin',
targetInsidePreview: true,
markdownAnnotationsEnabled: true,
activeAnnotationBlockKey: 'p:9-9',
root,
selection
})
).toEqual({ action: 'open', blockKey: 'p:3-5' })
})
it('clears a stale key on repeat without opening', () => {
const { root } = createPreviewFixture()
expect(
resolveMarkdownPreviewAddReviewNoteKey({
event: { ...chord, repeat: true },
platform: 'darwin',
targetInsidePreview: true,
markdownAnnotationsEnabled: true,
activeAnnotationBlockKey: 'p:9-9',
root,
selection: null
})
).toEqual({ action: 'clear-stale-and-ignore' })
})
})

View File

@ -36,3 +36,86 @@ export function getMarkdownAnnotationBlockKeyForSelection(
closestAnnotationBlockKey(selection.focusNode, root)
)
}
export function previewHasAnnotationBlockKey(root: HTMLElement, blockKey: string): boolean {
// Why: walk attributes instead of building a CSS selector so keys never need
// CSS.escape (unavailable in some test environments).
for (const block of root.querySelectorAll('[data-annotation-block-key]')) {
if (block.getAttribute('data-annotation-block-key') === blockKey) {
return true
}
}
return false
}
export type MarkdownPreviewAddReviewNoteKeyResult =
| { action: 'ignore' }
| { action: 'consume' }
| { action: 'open'; blockKey: string }
| { action: 'clear-stale-and-ignore' }
/**
* Pure decision for the preview add-review-note chord. Keeps product B, OS
* key-repeat, and stale-block-key handling out of the React component body.
*/
export function resolveMarkdownPreviewAddReviewNoteKey(options: {
event: Pick<
KeyboardEvent,
'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'repeat'
>
platform: NodeJS.Platform
keybindings?: KeybindingOverrides
targetInsidePreview: boolean
markdownAnnotationsEnabled: boolean
activeAnnotationBlockKey: string | null
root: HTMLElement
selection: Selection | null
}): MarkdownPreviewAddReviewNoteKeyResult {
const {
event,
platform,
keybindings,
targetInsidePreview,
markdownAnnotationsEnabled,
activeAnnotationBlockKey,
root,
selection
} = options
if (
!isMarkdownPreviewAddReviewNoteShortcut(event, platform, keybindings) ||
!targetInsidePreview ||
!markdownAnnotationsEnabled
) {
return { action: 'ignore' }
}
if (activeAnnotationBlockKey) {
// Why: only treat the key as an open draft when the block still mounts a
// composer; a stale key after content renumber must not lock the shortcut.
if (previewHasAnnotationBlockKey(root, activeAnnotationBlockKey)) {
return { action: 'consume' }
}
// Fall through after clearing so a held/stale key does not permanently
// suppress open. Repeat still must not open (below).
if (event.repeat) {
return { action: 'clear-stale-and-ignore' }
}
const blockKey = getMarkdownAnnotationBlockKeyForSelection(root, selection)
if (blockKey) {
return { action: 'open', blockKey }
}
return { action: 'clear-stale-and-ignore' }
}
// Why: ignore OS key-repeat so a held chord cannot thrash open without a draft.
if (event.repeat) {
return { action: 'ignore' }
}
const blockKey = getMarkdownAnnotationBlockKeyForSelection(root, selection)
if (blockKey) {
return { action: 'open', blockKey }
}
return { action: 'ignore' }
}

View File

@ -11,8 +11,17 @@ export function handleRichMarkdownAddReviewNoteShortcut(
if (!editorShortcutMatches('editor.addReviewNote', event)) {
return false
}
// Why: ignore OS key-repeat so a held chord cannot thrash open/remount.
// Open drafts are consumed by installOpenDraftAddReviewNoteGuard on the
// mounted composer (product B), including when focus is in the textarea.
if (event.repeat) {
return false
}
// Why: require the live selection so a collapsed selection cannot reopen a
// stale target; consume the chord only when a composer actually opens.
// stale target; consume only when a composer opens or an open draft is kept
// (openAnnotationPopover returns true for both). openAnnotationPopover flushes
// the pending ProseMirror selection before reading the target, so an immediate
// chord after a mouse-drag sees the live selection rather than stale state.
if (!ctx.openAnnotationPopoverRef.current(true)) {
return false
}

View File

@ -143,6 +143,43 @@ describe('rich markdown key handler', () => {
}
})
it('ignores OS key-repeat for the add-review-note shortcut', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
const event = keyEvent('a', { metaKey: true, shiftKey: true, code: 'KeyA', repeat: true })
// Why: leave the repeat unconsumed here; open drafts are consumed by the
// mounted composer guard (product B) instead of this editor key path.
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).not.toHaveBeenCalled()
} finally {
editor.destroy()
}
})
it('delegates a fresh add-review-note chord to openAnnotationPopover and consumes it', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
// Why: the ProseMirror-selection flush moved into openAnnotationPopover
// (which reads the selection), so the handler now only delegates the open
// with requireLiveSelection and consumes the chord when it succeeds.
ctx.openAnnotationPopoverRef.current = vi.fn(() => true)
const event = keyEvent('a', { metaKey: true, shiftKey: true, code: 'KeyA' })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledTimes(1)
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledWith(true)
} finally {
editor.destroy()
}
})
it('preserves a typed empty ordered-list shortcut on Enter', () => {
const editor = createEditor(emptyTopLevelOrderedList())

View File

@ -30,8 +30,8 @@ export function flushPendingProseMirrorSelection(editor: Editor): void {
return
}
// Why: immediate Tab after a mouse click can run before ProseMirror has
// copied the native selection into editor state, so list commands hit stale item state.
// Why: immediate Tab or add-review-note after a mouse click/drag can run
// before ProseMirror has copied the native selection into editor state.
observer.currentSelection?.set?.({
anchorNode: null,
anchorOffset: 0,

View File

@ -0,0 +1,138 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { Editor } from '@tiptap/react'
import { useRichMarkdownReviewController } from './useRichMarkdownReviewController'
import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations'
vi.mock('./useRichMarkdownReviewData', () => ({
useRichMarkdownReviewData: () => ({
canAnnotateRichMarkdown: true,
markdownComments: [],
markdownReviewNotes: [],
sourceRelativePath: 'notes.md',
unsentMarkdownReviewScope: null
})
}))
vi.mock('./useRichMarkdownReviewCopyFeedback', () => ({
useRichMarkdownReviewCopyFeedback: () => ({
clearReviewCopyTimers: vi.fn(),
reviewCopyFeedback: null
})
}))
vi.mock('./useRichMarkdownReviewRailController', () => ({
useRichMarkdownReviewRailController: () => ({
cancelNotePositionFrame: vi.fn(),
clearAttentionTimers: vi.fn(),
setReviewRailOpen: vi.fn(),
reviewRailOpen: false
})
}))
const sampleTarget: RichMarkdownAnnotationTarget = {
from: 1,
to: 4,
lineNumber: 1,
startLine: 1,
selectedText: 'abc',
top: 12,
buttonTop: 12,
buttonLeft: 8
}
describe('useRichMarkdownReviewController openAnnotationPopover draft guard', () => {
it('returns true without replacing an open draft (product B)', () => {
const dispatch = vi.fn()
const editorRef = {
current: {
view: { dispatch },
state: {
tr: {
setMeta: vi.fn(function setMeta(this: unknown) {
return this
})
}
}
} as unknown as Editor
}
const rootRef = { current: document.createElement('div') }
const scrollContainerRef = { current: document.createElement('div') }
const { result } = renderHook(() =>
useRichMarkdownReviewController({
addDiffComment: vi.fn(),
allDiffComments: [],
content: 'abc',
editorRef,
filePath: '/repo/notes.md',
markdownAnnotationsEnabled: true,
markdownReviewContent: 'abc',
markdownSourceLineOffset: 0,
rootRef,
scrollContainerRef,
worktreeId: 'wt-1',
worktreeRoot: '/repo'
})
)
act(() => {
result.current.setAnnotationPopover(sampleTarget)
})
expect(result.current.annotationPopover).toEqual(sampleTarget)
let kept = false
act(() => {
kept = result.current.openAnnotationPopover(true)
})
expect(kept).toBe(true)
expect(result.current.annotationPopover).toEqual(sampleTarget)
expect(dispatch).not.toHaveBeenCalled()
})
it('flushes the pending ProseMirror selection before reading the target', () => {
const flush = vi.fn()
const setSelection = vi.fn()
// Why: an empty selection makes getRichMarkdownAnnotationTarget return null,
// so the open no-ops; we only assert the flush ran first (drag-race fix).
const editorRef = {
current: {
view: {
dispatch: vi.fn(),
domObserver: { flush, currentSelection: { set: setSelection } }
},
state: { selection: { empty: true } }
} as unknown as Editor
}
const rootRef = { current: document.createElement('div') }
const scrollContainerRef = { current: document.createElement('div') }
const { result } = renderHook(() =>
useRichMarkdownReviewController({
addDiffComment: vi.fn(),
allDiffComments: [],
content: 'abc',
editorRef,
filePath: '/repo/notes.md',
markdownAnnotationsEnabled: true,
markdownReviewContent: 'abc',
markdownSourceLineOffset: 0,
rootRef,
scrollContainerRef,
worktreeId: 'wt-1',
worktreeRoot: '/repo'
})
)
let opened = true
act(() => {
opened = result.current.openAnnotationPopover(true)
})
expect(flush).toHaveBeenCalledTimes(1)
expect(opened).toBe(false)
expect(result.current.annotationPopover).toBeNull()
})
})

View File

@ -11,6 +11,7 @@ import {
type RichMarkdownAnnotationTarget
} from './rich-markdown-review-annotations'
import { shouldExpandRichMarkdownReviewRail } from './rich-markdown-review-note-layout'
import { flushPendingProseMirrorSelection } from './rich-markdown-selection-flush'
import { useRichMarkdownReviewData } from './useRichMarkdownReviewData'
import { useRichMarkdownReviewCopyFeedback } from './useRichMarkdownReviewCopyFeedback'
import { useRichMarkdownReviewRailController } from './useRichMarkdownReviewRailController'
@ -201,15 +202,26 @@ export function useRichMarkdownReviewController({
]
)
// Why: reports whether a composer actually opened so keyboard callers can
// leave the chord unconsumed when this no-ops (mouse callers ignore it).
// Why: reports whether a composer actually opened (or an open draft was kept)
// so keyboard callers can leave the chord unconsumed only on true no-ops.
const openAnnotationPopover = useCallback(
(requireLiveSelection = false): boolean => {
if (!canAnnotateRichMarkdown) {
return false
}
// Why: product B — second chord while drafting must consume the key without
// remounting the composer and silently discarding the in-progress note.
if (annotationPopoverRef.current) {
return true
}
const editor = editorRef.current
const root = rootRef.current
// Why: an immediate chord after a mouse-drag can run before ProseMirror
// copies the native selection into editor.state; flush it before reading
// the target so the composer opens on the live selection, not stale state.
if (editor) {
flushPendingProseMirrorSelection(editor)
}
// Why: keyboard callers require the live selection to avoid stale-target
// races; the mouse button may use the target from the render that exposed it.
const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null
@ -230,6 +242,9 @@ export function useRichMarkdownReviewController({
activeRange: { from: target.from, to: target.to }
})
)
// Why: claim the draft ref before setState so a same-tick second chord
// (or OS key-repeat racing the open) cannot remount and discard text.
annotationPopoverRef.current = target
// Why: opening a draft should reserve the notes rail immediately; saved notes stay visible.
setReviewRailOpen(true)
setAnnotationPopover(target)