From c7eb0cd22c2d69f3e79625188f227b93e360c3aa Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:40:44 +0000 Subject: [PATCH] fix(desktop): preserve dirty inline edits on blur --- .../thread/user-edit-composer.tsx | 38 +++++++++++++-- .../thread/user-message-edit.test.tsx | 48 ++++++++++++++++++- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 70a5472e585c..dfcc05717dc1 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -80,6 +80,10 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const draft = useAuiState(s => s.composer.text) const rootRef = useRef(null) const editorRef = useRef(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(null) const draftRef = useRef(draft) const dragDepthRef = useRef(0) const [dragActive, setDragActive] = useState(false) @@ -120,6 +124,12 @@ export const UserEditComposer: FC = ({ 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 = ({ 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 = ({ cwd, gateway, sess setFocusRequestId(id => id + 1) }, - [aui] + [aui, rememberInitialDraft] ) useEffect(() => { @@ -263,6 +274,7 @@ export const UserEditComposer: FC = ({ 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 = ({ 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 = ({ 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 = ({ cwd, gateway, sess editor.replaceChildren() } + rememberInitialDraft() syncDraftFromEditor(editor) window.setTimeout(refreshTrigger, 0) } @@ -487,6 +501,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess } event.preventDefault() + rememberInitialDraft() document.execCommand('insertText', false, pastedText) syncDraftFromEditor(event.currentTarget) } @@ -518,11 +533,26 @@ export const UserEditComposer: FC = ({ 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) => { diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx index f81bd08ec57f..e912856800a1 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx @@ -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( {}} />) + + 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( {}} />) + + 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( {}} />)