diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 565558895fc..a3b5d5d46b7 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -1,7 +1,9 @@ +import { memo } from 'react' import { ArrowUp, CircleAlert, Mic, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' import type { SessionOptionDescriptor, SessionOptionsSurface @@ -45,7 +47,8 @@ export type NativeChatComposerActionsProps = { agent?: string } -export function NativeChatComposerActions({ +// Memoized: typing re-renders the composer per keystroke, and these controls rarely change with it. +export const NativeChatComposerActions = memo(function NativeChatComposerActions({ editSession, attachDisabled, dictationDisabled, @@ -70,6 +73,7 @@ export function NativeChatComposerActions({ focusComposer, agent }: NativeChatComposerActionsProps): React.JSX.Element { + useUiLanguageRevision() const stops = isWorking const handleCriticalAction = (event: React.MouseEvent): void => { // Ignore a double-click’s second press after Send becomes Stop so it cannot cancel the newly started turn. @@ -223,7 +227,7 @@ export function NativeChatComposerActions({ ) -} +}) function DraftNotSavedIcon({ shown }: { shown: boolean }): React.JSX.Element | null { if (!shown) { diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index a37b46416a0..055df61afa9 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -28,6 +28,9 @@ import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPre import type { NativeChatComposerGoalMode } from './use-native-chat-composer-submit' import { translate } from '@/i18n/i18n' import { useNativeChatComposerDraftUnsaved } from './use-native-chat-draft-unsaved' +import { useLatestHandler } from '@/lib/use-latest-handler' + +function noop(): void {} export type NativeChatComposerFieldProps = { editSession?: { cancel: () => void } @@ -180,6 +183,59 @@ export function NativeChatComposerField({ // Remember the editor’s last draft because chip node views can flushSync a render before its onChange publishes. const editorDraftRef = useRef(null) + const settleImeValue = (element: NativeChatComposerInput): void => { + if (droppedDraftClearRef.current) { + droppedDraftClearRef.current = false + element.value = imeComposedSegment(compositionBaseRef.current, element.value) + } + onImeSettled(element) + } + + // Why: stable identities let the memoized editor and actions skip the per-keystroke render (#35836). + const editorChange = useLatestHandler((input: NativeChatComposerInput) => { + const value = input.value + editorDraftRef.current = value + onDraftChange(value, input) + }) + const editorSelect = useLatestHandler(onTextareaSelect) + const editorKeyDownCapture = useLatestHandler((event: React.KeyboardEvent) => { + if (!imeEnterGesture.ownsKeyDown(event)) { + onKeyDown(event) + } + }) + const editorKeyUp = useLatestHandler(imeEnterGesture.onKeyUp) + const editorBlur = useLatestHandler(() => { + const compositionWasActive = imeEnterGesture.isComposing() + imeEnterGesture.reset() + if (compositionWasActive) { + settleImeValue(textareaRef.current!) + } + }) + const editorCompositionStart = useLatestHandler(() => { + if (imeEnterGesture.isComposing()) { + imeEnterGesture.setComposing(false) + // Settle the interrupted composition before the new one takes browser ownership. + flushSync(() => settleImeValue(textareaRef.current!)) + } + compositionBaseRef.current = textareaRef.current!.value + imeEnterGesture.setComposing(true) + }) + const editorCompositionEnd = useLatestHandler((event: React.CompositionEvent) => { + const compositionWasActive = imeEnterGesture.isComposing() + imeEnterGesture.setComposing(false, event.data) + if (compositionWasActive) { + settleImeValue(textareaRef.current!) + } + }) + const editorPaste = useLatestHandler(onPaste) + const attach = useLatestHandler(onAttach) + const dictationToggle = useLatestHandler(onDictationToggle) + const dictationHoldStart = useLatestHandler(onDictationHoldStart) + const dictationHoldEnd = useLatestHandler(onDictationHoldEnd) + const send = useLatestHandler(onSend) + const stop = useLatestHandler(onStop ?? noop) + const exitGoalMode = useLatestHandler(goalMode?.exit ?? noop) + // Browser owns the provisional value; React synchronizes drafts only between IME sessions. useLayoutEffect(() => { const textarea = textareaRef.current @@ -198,14 +254,6 @@ export function NativeChatComposerField({ editorDraftRef.current = draft }, [draft, imeEnterGesture, textareaRef]) - const settleImeValue = (element: NativeChatComposerInput): void => { - if (droppedDraftClearRef.current) { - droppedDraftClearRef.current = false - element.value = imeComposedSegment(compositionBaseRef.current, element.value) - } - onImeSettled(element) - } - // Stable so the memoized option pickers keep their identity. const focusComposer = useCallback(() => textareaRef.current?.focus(), [textareaRef]) @@ -274,42 +322,14 @@ export function NativeChatComposerField({ inputRef={textareaRef} initialValue={draft} disabled={disabled} - onChange={(input) => { - const value = input.value - editorDraftRef.current = value - onDraftChange(value, input) - }} - onKeyDownCapture={(event) => { - if (!imeEnterGesture.ownsKeyDown(event)) { - onKeyDown(event) - } - }} - onKeyUp={imeEnterGesture.onKeyUp} - onBlur={() => { - const compositionWasActive = imeEnterGesture.isComposing() - imeEnterGesture.reset() - if (compositionWasActive) { - settleImeValue(textareaRef.current!) - } - }} - onCompositionStart={() => { - if (imeEnterGesture.isComposing()) { - imeEnterGesture.setComposing(false) - // Settle the interrupted composition before the new one takes browser ownership. - flushSync(() => settleImeValue(textareaRef.current!)) - } - compositionBaseRef.current = textareaRef.current!.value - imeEnterGesture.setComposing(true) - }} - onCompositionEnd={(event) => { - const compositionWasActive = imeEnterGesture.isComposing() - imeEnterGesture.setComposing(false, event.data) - if (compositionWasActive) { - settleImeValue(textareaRef.current!) - } - }} - onPasteCapture={onPaste} - onSelect={onTextareaSelect} + onChange={editorChange} + onKeyDownCapture={editorKeyDownCapture} + onKeyUp={editorKeyUp} + onBlur={editorBlur} + onCompositionStart={editorCompositionStart} + onCompositionEnd={editorCompositionEnd} + onPasteCapture={editorPaste} + onSelect={editorSelect} aria-expanded={autocomplete.mode !== 'none'} aria-controls={autocomplete.mode !== 'none' ? pickerListboxId : undefined} aria-activedescendant={ @@ -347,18 +367,18 @@ export function NativeChatComposerField({ isStopping={isStopping} isDictating={isDictating} isDictationHoldMode={isDictationHoldMode} - onAttach={onAttach} - onDictationToggle={onDictationToggle} - onDictationHoldStart={onDictationHoldStart} - onDictationHoldEnd={onDictationHoldEnd} - onSend={onSend} - onStop={onStop} + onAttach={attach} + onDictationToggle={dictationToggle} + onDictationHoldStart={dictationHoldStart} + onDictationHoldEnd={dictationHoldEnd} + onSend={send} + onStop={onStop ? stop : undefined} sessionOptionsSurface={sessionOptionsSurface} sessionOptionsSnapshot={sessionOptionsSnapshot} contextUsage={contextUsage} sessionOptionsPickerRequest={sessionOptionsPickerRequest} focusComposer={focusComposer} - onExitGoalMode={goalMode?.active ? goalMode.exit : undefined} + onExitGoalMode={goalMode?.active ? exitGoalMode : undefined} {...(agent ? { agent } : {})} /> diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index b9284604289..7e7a81b3e5b 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -12,6 +12,7 @@ import { import { closeHistory } from '@tiptap/pm/history' import { Slice } from '@tiptap/pm/model' import { + memo, useImperativeHandle, useLayoutEffect, useMemo, @@ -19,6 +20,7 @@ import { type HTMLAttributes, type RefObject } from 'react' +import { shallow } from 'zustand/shallow' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Placeholder from '@tiptap/extension-placeholder' @@ -51,7 +53,14 @@ type Props = Omit, 'onChange' | 'onSelect'> & { const NO_IMAGES: readonly string[] = [] -export function NativeChatPromptEditor({ +// `initialValue` only seeds the editor at mount; keep the other props referentially stable or this memo stops helping. +export const NativeChatPromptEditor = memo( + NativePromptEditor, + ({ initialValue: _previous, ...previous }, { initialValue: _next, ...next }) => + shallow(previous, next) +) + +function NativePromptEditor({ scopeKey, imageRow = NO_IMAGES, inputRef, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx index 1753e71071a..f0aaf381f0a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx @@ -4,7 +4,7 @@ import { changePrompt, promptValue } from './native-chat-prompt-editor.test-supp import { createRef } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NativeI18n from '@/i18n/i18n' @@ -37,8 +37,15 @@ import { readNativeChatComposerDraft, updateNativeChatComposerDraft, clearNativeChatComposerDraftsForTests, - hydrateNativeChatComposerDrafts + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled } from './native-chat-composer-draft-store' +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { settleNativeChatPastedText } from './native-chat-composer-pasted-text-references' +import { + nativeChatPastedTextOperations, + nativeChatPastedTextToken +} from './native-chat-pasted-text-reference' import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests @@ -517,3 +524,112 @@ it('keeps an actual composing return through command acceptance, composition set view.unmount() clearNativeChatComposerDraftsForTests() }) + +describe('an open IME composition stays in the draft every consumer reads (#35836)', () => { + const scope = 'agent-session:open-composition' + let storage: ReturnType + + beforeEach(async () => { + clearNativeChatComposerDraftsForTests() + storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() + }) + afterEach(() => { + cleanup() + clearNativeChatComposerDraftsForTests() + nativeChatPastedTextOperations.clear() + }) + + // The pane's Send reads its React draft, as this field's Send does. + function LiveField({ onSend = vi.fn() }: { onSend?: (text: string) => void }) { + const ime = useImeEnterGestureOwnership() + const draft = useNativeChatDraft(scope, ime.isComposing) + const events = useNativeChatDraftEvents({ + ...draft, + setCaret: vi.fn(), + setActiveSuggestion: vi.fn(), + handleDraftOrCaretChange: vi.fn(), + flushPendingAttachments: vi.fn() + }) + return ( + + onSend(draft.draft)} + {...events} + /> + + ) + } + + it('sends the composing text from a Send press that keeps focus', () => { + const onSend = vi.fn() + render() + const field = textarea() + fireEvent.compositionStart(field) + changePrompt(field, 'nihao') + + const sendButton = screen.getByRole('button', { name: 'Send' }) + expect(sendButton.hasAttribute('disabled')).toBe(false) + fireEvent.mouseDown(sendButton) + fireEvent.click(sendButton) + expect(onSend).toHaveBeenCalledExactlyOnceWith('nihao') + }) + + it('keeps a message returned mid-composition when the composer unmounts', () => { + const view = render() + const field = textarea() + fireEvent.compositionStart(field) + changePrompt(field, '가') + act(() => { + appendNativeChatDraftCache(scope, 'returned message') + }) + + view.unmount() + expect(readNativeChatComposerDraft(scope).text).toBe('가\n\nreturned message') + }) + + it('saves edits made after a composition that blur ended without compositionend', () => { + render() + const field = textarea() + fireEvent.compositionStart(field) + changePrompt(field, '각') + fireEvent.blur(field) + changePrompt(field, '각a') + + expect(readNativeChatComposerDraft(scope).text).toBe('각a') + }) + + it('keeps a pasted-text path that finished saving mid-composition', () => { + const id = '00000000-0000-4000-8000-000000000035' + const token = nativeChatPastedTextToken(id) + nativeChatPastedTextOperations.set(id, { scopeKey: scope, text: 'pasted' }) + updateNativeChatComposerDraft(scope, { text: token }, 'immediate') + render() + const field = textarea() + fireEvent.compositionStart(field) + changePrompt(field, `${token} ni`) + act(() => { + settleNativeChatPastedText(id, '/tmp/pasted.txt') + }) + endComposition(field, 'ni') + + expect(readNativeChatComposerDraft(scope).pastedTextFiles).toEqual({ [id]: '/tmp/pasted.txt' }) + }) + + it('saves the composing text when the window hides mid-composition', async () => { + render() + const field = textarea() + fireEvent.compositionStart(field) + changePrompt(field, 'nihao') + + window.dispatchEvent(new Event('pagehide')) + await nativeChatComposerDraftWritesSettled() + expect(storage.drafts.get(scope)?.text).toBe('nihao') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index ff62ad9b42b..870e735bf49 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -174,7 +174,10 @@ export function updateNativeChatComposerDraft( text, ...(Object.keys(pastedTextFiles).length ? { pastedTextFiles } : {}), ...(document ? { document } : {}), - images: [...images], + // Why: an unchanged row keeps its identity, so typing does not re-render every image consumer. + images: sameNativeChatComposerDraftImages(images, current.images) + ? current.images + : [...images], savedAt: nextSavedAt(), ...(owner ? { owner } : {}), ...(unsavedText === undefined ? {} : { unsavedText }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx index bb8b5043f29..6416b2be73b 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx @@ -245,9 +245,10 @@ describe('native chat workspace file drops', () => { cleanup() }) - it('consumes a nested editor drop once and inserts top-level paths at the caret', () => { + it('consumes a nested editor drop once and inserts top-level paths at the caret', async () => { render() - act(() => { + // Tiptap mounts node-view portals from a microtask. + await act(async () => { latestInput!.insertSkill!(0, 4, '$review') }) expect(editor().querySelectorAll('[data-native-chat-skill]')).toHaveLength(1) @@ -260,6 +261,7 @@ describe('native chat workspace file drops', () => { ]) transfer.setData('text/plain', 'must not be inserted by ProseMirror') const accepted = dispatchDragEvent('drop', editor(), transfer) + await act(async () => {}) expect(accepted).toBe(false) expect(screen.getByTestId('draft').textContent).toBe( diff --git a/src/renderer/src/lib/use-latest-handler.ts b/src/renderer/src/lib/use-latest-handler.ts new file mode 100644 index 00000000000..6cf038ba5f8 --- /dev/null +++ b/src/renderer/src/lib/use-latest-handler.ts @@ -0,0 +1,12 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' + +/** A stable function that calls the latest committed `handler`, so memoized children skip re-rendering when only a closure changed. */ +export function useLatestHandler( + handler: (...args: A) => R +): (...args: A) => R { + const latest = useRef(handler) + useLayoutEffect(() => { + latest.current = handler + }) + return useCallback((...args: A) => latest.current(...args), []) +}