diff --git a/bun.lock b/bun.lock index 169ad5a3..a25ae81b 100644 --- a/bun.lock +++ b/bun.lock @@ -95,6 +95,7 @@ "dependencies": { "@assistant-ui/react": "^0.14.29", "@assistant-ui/react-markdown": "^0.14.7", + "@assistant-ui/tap": "^0.9.8", "@elevenlabs/react": "^0.13.0", "@hapi/protocol": "workspace:*", "@lobehub/icons": "^5.4.0", diff --git a/web/package.json b/web/package.json index b30f8822..4bffcd68 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "dependencies": { "@assistant-ui/react": "^0.14.29", "@assistant-ui/react-markdown": "^0.14.7", + "@assistant-ui/tap": "^0.9.8", "@elevenlabs/react": "^0.13.0", "@hapi/protocol": "workspace:*", "@lobehub/icons": "^5.4.0", diff --git a/web/src/components/AssistantChat/HappyComposer.richBridge.test.tsx b/web/src/components/AssistantChat/HappyComposer.richBridge.test.tsx new file mode 100644 index 00000000..34bcab4d --- /dev/null +++ b/web/src/components/AssistantChat/HappyComposer.richBridge.test.tsx @@ -0,0 +1,80 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { useEffect, useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { type TextInputState, useRichComposerBridge } from './HappyComposer' + +const events = vi.hoisted(() => [] as string[]) + +vi.mock('@assistant-ui/tap', () => ({ + flushTapSync: (callback: () => void) => { + events.push('flush:start') + callback() + events.push('flush:end') + }, +})) + +type Bridge = ReturnType + +function BridgeHarness(props: { + callbacksRef: { current: Bridge | null } + api: { + composer: () => { + setText: (text: string) => void + } + } +}) { + const [inputState, setInputState] = useState({ + text: '', + selection: { start: 0, end: 0 }, + }) + const [, setUnrelatedVersion] = useState(0) + const bridge = useRichComposerBridge(props.api, setInputState, null) + props.callbacksRef.current = bridge + + useEffect(() => { + if (inputState.text) events.push(`mirror-render:${inputState.text}`) + }, [inputState]) + + return ( + + ) +} + +describe('useRichComposerBridge', () => { + it('flushes the composer write before mirror render and keeps callbacks stable across unrelated renders', () => { + const callbacksRef: { current: Bridge | null } = { current: null } + const api = { + composer: () => ({ + setText: (text: string) => events.push(`setText:${text}`), + }), + } + + render() + const initial = callbacksRef.current + expect(initial).not.toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Unrelated rerender' })) + + expect(callbacksRef.current?.onValueChange).toBe(initial?.onValueChange) + expect(callbacksRef.current?.onMirrorChange).toBe(initial?.onMirrorChange) + expect(callbacksRef.current?.onEdit).toBe(initial?.onEdit) + + events.length = 0 + act(() => { + callbacksRef.current!.onValueChange('new composer text') + callbacksRef.current!.onMirrorChange({ + text: 'mirror text', + selection: { start: 11, end: 11 }, + }) + }) + + expect(events).toEqual([ + 'flush:start', + 'setText:new composer text', + 'flush:end', + 'mirror-render:mirror text', + ]) + }) +}) diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index e0d5f64e..4ae6b84c 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,5 +1,6 @@ import { getCodexCollaborationModeOptions, getPermissionModeOptionsForFlavor } from '@hapi/protocol' import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' +import { flushTapSync } from '@assistant-ui/tap' import { type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, @@ -86,6 +87,40 @@ export type ComposerSendError = { } | null } +type RichComposerBridgeApi = { + composer: () => { + setText: (text: string) => void + } +} + +/** + * The custom rich contenteditable must follow ComposerPrimitive.Input's + * synchronous composer-write contract. Kept separate so its callback identity + * is stable across unrelated HappyComposer renders and directly testable. + */ +export function useRichComposerBridge( + api: RichComposerBridgeApi, + setInputState: (state: TextInputState) => void, + sendError: ComposerSendError | null, + onClearSendError?: () => void, +) { + const onValueChange = useCallback((text: string) => { + flushTapSync(() => { + api.composer().setText(text) + }) + }, [api]) + + const onMirrorChange = useCallback((state: TextInputState) => { + setInputState(state) + }, [setInputState]) + + const onEdit = useCallback(() => { + if (sendError && onClearSendError) onClearSendError() + }, [sendError, onClearSendError]) + + return { onValueChange, onMirrorChange, onEdit } +} + const defaultSuggestionHandler = async (): Promise => [] export function ModelEffortSettingsSection(props: { @@ -322,6 +357,12 @@ export function HappyComposer(props: { const [richMentionsEnabled] = useState(() => isRichComposerMentionsEnabled()) const prevControlledByUser = useRef(controlledByUser) + const { + onValueChange: handleRichValueChange, + onMirrorChange: handleRichMirrorChange, + onEdit: handleRichEdit, + } = useRichComposerBridge(api, setInputState, sendError, onClearSendError) + const attachmentDrafts = attachments.flatMap((attachment) => { if (!attachment.file) return [] const upload = attachment as typeof attachment & { path?: string; previewUrl?: string } @@ -1384,14 +1425,12 @@ export function HappyComposer(props: { autoFocus={!controlsDisabled && !isTouch} placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')} disabled={controlsDisabled} - onValueChange={(text) => api.composer().setText(text)} - onMirrorChange={(state) => setInputState(state)} + onValueChange={handleRichValueChange} + onMirrorChange={handleRichMirrorChange} onKeyDown={handleKeyDown} onPaste={handlePaste} resolveSessionMentionTooltip={resolveSessionMentionTooltip} - onEdit={() => { - if (sendError && onClearSendError) onClearSendError() - }} + onEdit={handleRichEdit} className="max-h-[7.5rem] min-h-[1.5rem] flex-1 overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-base leading-snug text-[var(--app-fg)] focus:outline-none" /> ) : ( diff --git a/web/src/components/AssistantChat/RichComposerInput.test.tsx b/web/src/components/AssistantChat/RichComposerInput.test.tsx new file mode 100644 index 00000000..3c7908a1 --- /dev/null +++ b/web/src/components/AssistantChat/RichComposerInput.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { flushSync } from 'react-dom' +import { useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { + mirrorOffsetFromPoint, + RichComposerInput, + segmentsFromEditor, +} from './RichComposerInput' +import { serializeComposerSegments } from '@/lib/composerSegments' + +function selectionOffset(root: HTMLElement): number { + const selection = window.getSelection() + expect(selection?.rangeCount).toBe(1) + const range = selection!.getRangeAt(0) + return mirrorOffsetFromPoint(root, range.startContainer, range.startOffset) +} + +function placeCaret(textNode: Text, offset: number): void { + const range = document.createRange() + range.setStart(textNode, offset) + range.collapse(true) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) +} + +function SynchronousControlledHarness() { + const [value, setValue] = useState('alpha') + const [, setMirrorVersion] = useState(0) + + return ( + <> + + {value} + { + flushSync(() => setValue(next)) + }} + onMirrorChange={() => { + setMirrorVersion((version) => version + 1) + }} + /> + + ) +} + +describe('RichComposerInput controlled synchronization', () => { + afterEach(() => { + window.getSelection()?.removeAllRanges() + }) + + it('preserves a middle-caret DOM input through its synchronous controlled echo and accepts later external replacement', () => { + render() + + const editor = screen.getByTestId('rich-composer-input') + const originalText = editor.firstChild + expect(originalText).toBeInstanceOf(Text) + + const textNode = originalText as Text + placeCaret(textNode, 2) + textNode.textContent = 'alXpha' + placeCaret(textNode, 3) + fireEvent.input(editor) + + // The same-tick controlled acknowledgement and mirror-triggered parent + // render must retain the browser-mutated DOM and logical caret. + expect(screen.getByTestId('controlled-value')).toHaveTextContent('alXpha') + expect(editor.firstChild).toBe(originalText) + expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('alXpha') + expect(selectionOffset(editor)).toBe(3) + + editor.focus() + expect(document.activeElement).toBe(editor) + fireEvent.click(screen.getByRole('button', { name: 'Replace draft' })) + + expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('external draft') + expect(selectionOffset(editor)).toBe('external draft'.length) + }) +})