Ensure draft comment textarea auto-focuses and preserve drafts through e

- Focus textarea on mount via requestAnimationFrame for reliable focusing
- Add onDomNodeTop callback to focus textarea when zone reaches viewport
- Preserve pending draft when editor model refreshes and re-anchor on reload
- Add editor.getModel() checks before opening and re-anchoring drafts
- Test that textarea is focused on creation and errors are surfaced
This commit is contained in:
Jinjing
2026-09-21 22:54:20 -07:00
parent f5d7a59bc9
commit 4c1c8a02d6
5 changed files with 94 additions and 5 deletions
@@ -28,7 +28,9 @@ describe('DiffCommentDraftCard', () => {
expect(view.getByText('Line 42')).toBeDefined()
expect(view.queryByText('You')).toBeNull()
expect(view.getByPlaceholderText('Add note for the AI')).toBeDefined()
const textarea = view.getByPlaceholderText('Add note for the AI')
expect(textarea).toBeDefined()
expect(document.activeElement).toBe(textarea)
expect(view.getByRole('button', { name: 'Add note' })).toBeDefined()
expect(view.getByRole('button', { name: 'Cancel' })).toBeDefined()
})
@@ -71,6 +71,16 @@ export function DiffCommentDraftCard({
textarea?.focus()
}, [])
useEffect(() => {
const focusTextarea = (): void => textareaRef.current?.focus()
focusTextarea()
if (typeof requestAnimationFrame !== 'function') {
return
}
const frame = requestAnimationFrame(focusTextarea)
return () => cancelAnimationFrame(frame)
}, [])
useLayoutEffect(() => {
const card = cardRef.current
if (!card || !onContentResizeRef.current) {
@@ -75,7 +75,7 @@ export function useDiffCommentDraftZone({
const openDraft = useCallback(
(draft: DiffCommentDraft, initialBody = ''): void => {
if (!editor || !onCreateCommentRef.current || !canOpenDraft) {
if (!editor || !editor.getModel() || !onCreateCommentRef.current || !canOpenDraft) {
return
}
@@ -88,12 +88,24 @@ export function useDiffCommentDraftZone({
const disposeDomMouseDownStopper = installDiffCommentZoneMouseDownStopper(dom)
const disposeMarginMouseDownStopper = installDiffCommentZoneMouseDownStopper(marginDom)
const root = createRoot(dom)
let didFocus = false
const delegate: monacoEditor.IViewZone = {
afterLineNumber: draft.lineNumber,
heightInPx: DRAFT_ZONE_DEFAULT_HEIGHT,
domNode: dom,
marginDomNode: marginDom,
suppressMouseDown: false
suppressMouseDown: false,
onDomNodeTop: () => {
if (didFocus) {
return
}
const textarea = dom.querySelector<HTMLTextAreaElement>('textarea')
if (!textarea) {
return
}
didFocus = true
textarea.focus()
}
}
const zoneId = accessor.addZone(delegate)
const entry: DraftZoneEntry = {
@@ -177,7 +189,7 @@ export function useDiffCommentDraftZone({
}
pendingDraftRef.current = { draft: current.draft, body: current.body }
disposeDraftZone()
if (!editor || !onCreateCommentRef.current || !canOpenDraft) {
if (!editor || !editor.getModel() || !onCreateCommentRef.current || !canOpenDraft) {
return
}
reanchorFrameRef.current = requestAnimationFrame(() => {
@@ -190,6 +202,29 @@ export function useDiffCommentDraftZone({
})
}, [canOpenDraft, disposeDraftZone, editor, monacoModelIdentity, openDraft])
// A combined-diff model refresh can briefly clear the editor ref before the replacement mounts.
// Re-anchor any carried draft when that replacement becomes available.
useEffect(() => {
if (!editor || !canOpenDraft || !pendingDraftRef.current) {
return
}
const pending = pendingDraftRef.current
pendingDraftRef.current = null
reanchorFrameRef.current = requestAnimationFrame(() => {
reanchorFrameRef.current = null
openDraft(pending.draft, pending.body)
})
return () => {
if (reanchorFrameRef.current !== null) {
cancelAnimationFrame(reanchorFrameRef.current)
reanchorFrameRef.current = null
}
if (!draftZoneRef.current) {
pendingDraftRef.current = pending
}
}
}, [canOpenDraft, editor, openDraft])
useEffect(() => {
if (!editor) {
return
@@ -199,7 +234,6 @@ export function useDiffCommentDraftZone({
cancelAnimationFrame(reanchorFrameRef.current)
reanchorFrameRef.current = null
}
pendingDraftRef.current = null
disposeDraftZone(false)
}
}, [disposeDraftZone, editor])
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { submitDiffSectionComment } from './diff-section-comment-submit'
import type { DiffSection } from './diff-section-types'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
const section: DiffSection = {
key: 'section-1',
path: 'src/example.ts',
status: 'modified',
originalContent: 'before',
modifiedContent: 'after',
collapsed: false,
loading: false,
dirty: false,
diffResult: null,
largeDiffRenderLimit: null
}
describe('submitDiffSectionComment', () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear()
})
it('surfaces persistence failures while keeping the draft open', async () => {
const addDiffComment = vi.fn().mockResolvedValue(null)
const result = await submitDiffSectionComment({
addDiffComment,
body: 'Needs revision',
target: { lineNumber: 4 },
section,
worktreeId: 'worktree-1'
})
expect(result).toBe(false)
expect(toast.error).toHaveBeenCalledWith('Failed to save comment')
})
})
+3
View File
@@ -73,6 +73,9 @@ test.describe('Diff note draft', () => {
await expect(draftCard).toBeVisible({ timeout: 15_000 })
await expect(draftCard).toContainText('Line 6')
await expect(draftCard).not.toContainText('You')
await expect
.poll(() => textarea.evaluate((element) => document.activeElement === element))
.toBe(true)
await expect(orcaPage.locator('.orca-diff-comment-draft-margin')).toBeVisible()
await expect
.poll(