fix(desktop): preserve dirty inline edits on blur

This commit is contained in:
Tranquil-Flow 2026-07-17 13:40:44 +00:00 committed by Teknium
parent e52c33cc9b
commit c7eb0cd22c
2 changed files with 80 additions and 6 deletions

View file

@ -80,6 +80,10 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
const draft = useAuiState(s => s.composer.text)
const rootRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
// Capture the original draft immediately before the first edit. The runtime
// may hydrate composer.text after this component's first render, so taking a
// mount-time snapshot can incorrectly classify every later blur as dirty.
const initialDraftRef = useRef<string | null>(null)
const draftRef = useRef(draft)
const dragDepthRef = useRef(0)
const [dragActive, setDragActive] = useState(false)
@ -120,6 +124,12 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
setFocusRequestId(id => id + 1)
}, [])
const rememberInitialDraft = useCallback(() => {
if (initialDraftRef.current === null) {
initialDraftRef.current = draftRef.current
}
}, [])
const appendExternalText = useCallback(
(text: string, mode: ComposerInsertMode) => {
const value = text.trim()
@ -128,6 +138,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
return
}
rememberInitialDraft()
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
const next = `${base}${sep}${value}`
@ -144,7 +155,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
setFocusRequestId(id => id + 1)
},
[aui]
[aui, rememberInitialDraft]
)
useEffect(() => {
@ -263,6 +274,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
return
}
rememberInitialDraft()
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
@ -312,7 +324,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
document.execCommand('insertText', false, text)
finish()
},
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
[aui, closeTrigger, refreshTrigger, rememberInitialDraft, requestEditFocus, trigger]
)
const insertRefStrings = useCallback(
@ -329,13 +341,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
return false
}
rememberInitialDraft()
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
requestEditFocus()
return true
},
[aui, requestEditFocus]
[aui, rememberInitialDraft, requestEditFocus]
)
const insertDroppedRefs = useCallback(
@ -473,6 +486,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
editor.replaceChildren()
}
rememberInitialDraft()
syncDraftFromEditor(editor)
window.setTimeout(refreshTrigger, 0)
}
@ -487,6 +501,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}
event.preventDefault()
rememberInitialDraft()
document.execCommand('insertText', false, pastedText)
syncDraftFromEditor(event.currentTarget)
}
@ -518,11 +533,26 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
return
}
const editor = editorRef.current
// Dirty edit guard: when the user actually typed something, blur must
// not cancel the composer — that would discard their in-flight
// edits. Compare against the draft captured immediately before the
// first edit; when no edit event occurred, the current hydrated draft
// is the clean baseline.
const initialDraft = initialDraftRef.current ?? draftRef.current
if (editor && syncDraftFromEditor(editor) !== initialDraft) {
closeTrigger()
return
}
closeTrigger()
aui.composer().cancel()
}, 80)
},
[aui, closeTrigger, submitting]
[aui, closeTrigger, submitting, syncDraftFromEditor]
)
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {

View file

@ -8,8 +8,8 @@ import { ExportedMessageRepository } from '@assistant-ui/react'
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
@ -32,6 +32,10 @@ vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
afterEach(() => {
cleanup()
})
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
@ -50,6 +54,19 @@ function stubOffsetDimension(
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
async function moveFocusOutside(editor: HTMLElement) {
const outside = window.document.createElement('button')
window.document.body.append(outside)
editor.focus()
await act(async () => {
outside.focus()
await new Promise(resolve => window.setTimeout(resolve, 120))
})
outside.remove()
}
function userMessage(): ThreadMessage {
return {
id: 'user-1',
@ -128,6 +145,33 @@ describe('click-to-edit user message', () => {
})
})
it('keeps a dirty inline edit open when focus leaves the composer', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
const editedText = 'edited draft that must not be discarded'
editor.textContent = editedText
fireEvent.input(editor)
await moveFocusOutside(editor)
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
expect((await screen.findByRole('textbox', { name: 'Edit message' })).textContent).toBe(editedText)
})
it('still cancels an untouched inline edit when focus leaves the composer', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
await moveFocusOutside(editor)
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeFalsy()
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)