mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(desktop): preserve dirty inline edits on blur
This commit is contained in:
parent
e52c33cc9b
commit
c7eb0cd22c
2 changed files with 80 additions and 6 deletions
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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 () => {}} />)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue