From 920a0339287ea5f45a46b21cb2e0981a7ca4feb3 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:42:44 -0700
Subject: [PATCH] fix(native-chat): typing fast after focus moves keeps keys in
order (#31203)
* fix(native-chat): typing fast after focus moves keeps keys in order
Typing a burst right after focus left the composer (Move Tab to Split, a
paired chat, a click on the transcript) could scramble the first letters:
"Reply exactly" came out as "Rply exactly...e".
The first key was routed into the composer programmatically: focus moved
at once, but the caret was set a frame later from a position computed
before the key. The next keys landed natively, then that late caret write
pulled the caret back. Quote "Add to chat" and picker completion had the
same deferred caret write.
Each programmatic write now puts the text and caret into the editor in one
synchronous step, read from the editor's live value, then focuses it. No
write happens while an IME composition owns the field.
* fix(native-chat): retain saved chips after synchronous composer edits
---
...NativeChatComposer.resume-command.test.tsx | 3 +-
.../native-chat/NativeChatComposer.tsx | 3 +-
...tPromptEditor.paragraph-selection.test.tsx | 3 +-
.../native-chat/native-chat-composer-input.ts | 11 +++
...ve-chat-prompt-editor-store-value.test.tsx | 77 ++++++++++++-------
.../use-native-chat-composer-handle.ts | 5 +-
.../use-native-chat-picker-state.test.tsx | 1 +
.../use-native-chat-picker-state.ts | 35 ++++++---
.../use-native-chat-typed-insertion.ts | 28 +++----
9 files changed, 109 insertions(+), 57 deletions(-)
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx
index e7672514e25..da02a707470 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx
@@ -60,7 +60,8 @@ vi.mock('./use-native-chat-draft', () => ({
vi.mock('./NativeChatComposerField', () => ({
NativeChatComposerField: (props: FieldProps) => {
mocks.fieldProps = props
- return
+ // Holds the draft as the real editor does; a picker completes the text the editor holds.
+ return
}
}))
vi.mock('./use-native-chat-skills', () => ({
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
index 9d8a32a4bbc..86c7c6efc31 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
@@ -127,6 +127,7 @@ const NativeChatComposerPane = forwardRef
useNativeChatTypedInsertion({
textareaRef: inputRef,
- caret: draft.length,
- draft: input.value,
+ isComposing: () => false,
setDraft: (value) => {
input.value = value
},
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-input.ts b/src/renderer/src/components/native-chat/native-chat-composer-input.ts
index ee467c89391..f904fe75595 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-input.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-input.ts
@@ -33,3 +33,14 @@ export function insertNativeChatPastedText(
input.insertText(text)
return true
}
+
+/** Text and caret in one synchronous write, then focus, so the next native key lands after them. */
+export function writeNativeChatComposerText(
+ input: NativeChatComposerInput,
+ text: string,
+ caret: number
+): void {
+ input.value = text
+ input.setSelectionRange(caret, caret)
+ input.focus()
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx
index 08f5b311cc6..03b1eca7aca 100644
--- a/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx
@@ -2,12 +2,13 @@
// A value the field sets on the editor comes from the draft store, so the editor never saves it
// back: an echo would make another window's draft, or a late-loaded one, a change of this window.
import { createElement, createRef } from 'react'
-import { act, cleanup, render } from '@testing-library/react'
+import { act, cleanup, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DraftStore from './native-chat-composer-draft-store'
import type * as DraftCache from './native-chat-draft-cache'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import { createMemoryNativeChatComposerDraftStorage } from './native-chat-composer-draft-storage'
+import { promptEditor } from './native-chat-prompt-editor.test-support'
type Renderer = { drafts: typeof DraftCache; store: typeof DraftStore }
@@ -102,32 +103,54 @@ describe('the editor and the draft store', () => {
expect(write).not.toHaveBeenCalled()
})
- it('keeps the skill chip when the composer itself changes the text, across a remount', async () => {
- const renderer = await open()
- renderer.drafts.writeNativeChatDraftDocument(SCOPE, '$review this', SKILL_DOCUMENT)
- renderer.store.flushNativeChatComposerDrafts()
- const first = await mountEditor(renderer)
- await vi.waitFor(() =>
- expect(window.document.querySelector('[data-native-chat-skill]')).not.toBeNull()
- )
- // A mention accepted: the composer writes the text, then the field sets it on the editor.
- const next = '$review this @src/a.ts '
- vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
- act(() => {
- renderer.drafts.writeNativeChatDraftCache(SCOPE, next)
- first.input().value = next
- })
- await act(async () => vi.advanceTimersByTimeAsync(400))
- await renderer.store.nativeChatComposerDraftWritesSettled()
- expect(JSON.stringify(storage.drafts.get(SCOPE)?.document ?? null)).toContain('nativeChatSkill')
-
- first.stopSync()
- first.unmount()
- const second = await mountEditor(renderer)
- await act(async () => vi.advanceTimersByTimeAsync(50))
- second.stopSync()
- expect(window.document.querySelector('[data-native-chat-skill]')).not.toBeNull()
- })
+ it.each([
+ ['insertTypedText', '$review thisReply'],
+ ['appendText', '$review this\n\nReply']
+ ] as const)(
+ 'keeps %s key order and saved chips through a quote and remount',
+ async (write, expected) => {
+ const renderer = await open()
+ renderer.drafts.writeNativeChatDraftDocument(SCOPE, '$review this', SKILL_DOCUMENT)
+ renderer.store.flushNativeChatComposerDrafts()
+ const first = await mountEditor(renderer)
+ const input = first.input()
+ input.setSelectionRange(input.value.length, input.value.length)
+ const { useNativeChatTypedInsertion } = await import('./use-native-chat-typed-insertion')
+ const { result } = renderHook(() =>
+ useNativeChatTypedInsertion({
+ textareaRef: { current: first.input() },
+ isComposing: () => false,
+ setDraft: (text) => renderer.drafts.writeNativeChatDraftCache(SCOPE, text),
+ setCaret: () => {},
+ setActiveSuggestion: () => {}
+ })
+ )
+ const editor = promptEditor(screen.getByRole('textbox'))
+ act(() => {
+ result.current[write]('R')
+ editor.view.dispatch(editor.state.tr.insertText('e'))
+ })
+ await act(() => new Promise((resolve) => requestAnimationFrame(resolve)))
+ act(() => editor.view.dispatch(editor.state.tr.insertText('ply')))
+ expect([input.value, input.selectionStart]).toEqual([expected, expected.length])
+ renderer.store.flushNativeChatComposerDrafts()
+ await renderer.store.nativeChatComposerDraftWritesSettled()
+ expect(renderer.store.hasUnsavedNativeChatComposerDraftChange(SCOPE)).toBe(false)
+ await act(async () => {
+ result.current.appendText('> quote\n\n')
+ renderer.store.flushNativeChatComposerDrafts()
+ await renderer.store.nativeChatComposerDraftWritesSettled()
+ })
+ first.stopSync()
+ first.unmount()
+ const second = await mountEditor(renderer)
+ await vi.waitFor(() =>
+ expect(window.document.querySelector('[data-native-chat-skill]')).not.toBeNull()
+ )
+ expect(second.input().value).toBe(`${expected}\n\n> quote\n\n`)
+ second.stopSync()
+ }
+ )
it('lets another window’s send stand, instead of echoing back the draft it showed', async () => {
const receiving = await open()
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts
index ca6d66cef9b..83fd9d83940 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts
@@ -16,12 +16,11 @@ export function useNativeChatComposerHandle(
ref: ForwardedRef,
args: UseNativeChatComposerHandleArgs
): (event: ClipboardEventLike) => void {
- const { textareaRef, draft, setDraft, setActiveSuggestion, ...pasteArgs } = args
+ const { textareaRef, isComposing, setDraft, setActiveSuggestion, ...pasteArgs } = args
const { insertTypedText, appendText, acceptsText, insertPastedText, focus, contains } =
useNativeChatTypedInsertion({
textareaRef,
- caret: args.caret,
- draft,
+ isComposing,
setDraft,
setCaret: args.setCaret,
setActiveSuggestion
diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx
index 2bcff3527f2..960f83ef07a 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx
@@ -20,6 +20,7 @@ function pickerMode(draft: string, recalledFromHistory: boolean): string {
agentCommands: [{ name: 'clear' }],
recalledFromHistory,
disabled,
+ isComposing: () => false,
textareaRef: { current: null },
setDraft,
setCaret: vi.fn(),
diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts
index bf490aec2cc..4555a7573f3 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts
@@ -1,4 +1,7 @@
-import type { NativeChatComposerInput } from './native-chat-composer-input'
+import {
+ writeNativeChatComposerText,
+ type NativeChatComposerInput
+} from './native-chat-composer-input'
import {
useCallback,
useEffect,
@@ -61,6 +64,7 @@ export function useNativeChatPickerState(args: {
/** A message recalled from history is resent or walked past, not completed, until it is edited. */
recalledFromHistory: boolean
disabled: boolean
+ isComposing: () => boolean
/** Skill names the running session reports; undefined keeps the host disk scan. */
sessionSkills?: readonly NativeChatSessionSkill[]
/** Each opening of the `/` or `$` menu, so a resting chat's list can be brought up to date. */
@@ -79,6 +83,7 @@ export function useNativeChatPickerState(args: {
agentCommands,
recalledFromHistory,
disabled,
+ isComposing,
sessionSkills,
onPickerOpened,
textareaRef,
@@ -156,17 +161,26 @@ export function useNativeChatPickerState(args: {
}
}, [agent, autocomplete, dismissalContext, onPickerOpened])
+ // The editor may be ahead of this render when a key lands just before the pick.
+ const liveDraft = useCallback((): [string, number] => {
+ const input = textareaRef.current
+ return input ? [input.value, input.selectionStart ?? input.value.length] : [draft, caret]
+ }, [caret, draft, textareaRef])
+
const commitCompletion = useCallback(
(result: { draft: string; caret: number }) => {
+ const textarea = textareaRef.current
+ // Mark the draft dirty before the suppressed transaction saves its chip document.
setDraft(result.draft)
+ // An IME composition owns the field; the draft reaches it once the composition settles.
+ if (textarea && !isComposing()) {
+ writeNativeChatComposerText(textarea, result.draft, result.caret)
+ }
setCaret(result.caret)
setActiveSuggestion(0)
setDismissed(null)
- const textarea = textareaRef.current
- textarea?.focus()
- requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret))
},
- [setActiveSuggestion, setCaret, setDraft, textareaRef]
+ [isComposing, setActiveSuggestion, setCaret, setDraft, textareaRef]
)
const completeItem = useCallback(
@@ -174,16 +188,17 @@ export function useNativeChatPickerState(args: {
if (disabledRef.current || autocomplete.mode !== 'slash') {
return
}
- const result = applyPickerSuggestion(draft, caret, item)
+ const [text, at] = liveDraft()
+ const result = applyPickerSuggestion(text, at, item)
if (item.kind === 'skill' && textareaRef.current?.insertSkill) {
const from = result.caret - result.insertedToken.length - 1
- textareaRef.current.insertSkill(from, caret, result.insertedToken, item.path)
+ textareaRef.current.insertSkill(from, at, result.insertedToken, item.path)
}
commitCompletion(result)
skillOriginRef.current = item.kind === 'skill' ? result.insertedToken : null
emitNativeChatPickerItemAccepted({ agent, itemKind: item.kind })
},
- [agent, autocomplete, caret, commitCompletion, draft, textareaRef]
+ [agent, autocomplete, commitCompletion, liveDraft, textareaRef]
)
const completeMention = useCallback(
@@ -191,9 +206,9 @@ export function useNativeChatPickerState(args: {
if (disabledRef.current || autocomplete.mode !== 'mention') {
return
}
- commitCompletion(applyMentionSuggestion(draft, caret, path))
+ commitCompletion(applyMentionSuggestion(...liveDraft(), path))
},
- [autocomplete, caret, commitCompletion, draft]
+ [autocomplete, commitCompletion, liveDraft]
)
const handleDraftOrCaretChange = useCallback(
diff --git a/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts b/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts
index b7996e0d2aa..72a254c7176 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts
@@ -1,5 +1,6 @@
import {
insertNativeChatPastedText,
+ writeNativeChatComposerText,
type NativeChatComposerInput
} from './native-chat-composer-input'
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'
@@ -9,8 +10,7 @@ import { appendReturnedDraftText } from '../../../../shared/returned-draft-text'
* paste pipeline and the composer's imperative handle. */
export function useNativeChatTypedInsertion(args: {
textareaRef: RefObject
- caret: number
- draft: string
+ isComposing: () => boolean
setDraft: (value: string) => void
setCaret: Dispatch>
setActiveSuggestion: Dispatch>
@@ -22,7 +22,7 @@ export function useNativeChatTypedInsertion(args: {
focus: () => boolean
contains: (node: Node | null) => boolean
} {
- const { textareaRef, caret, draft, setDraft, setCaret, setActiveSuggestion } = args
+ const { textareaRef, isComposing, setDraft, setCaret, setActiveSuggestion } = args
const usableInput = useCallback((): NativeChatComposerInput | null => {
const textarea = textareaRef.current
@@ -31,15 +31,16 @@ export function useNativeChatTypedInsertion(args: {
const showDraft = useCallback(
(textarea: NativeChatComposerInput, next: string, nextCaret: number): void => {
- textarea.focus()
+ // Mark the draft dirty before the suppressed transaction saves its chip document.
setDraft(next)
+ // An IME composition owns the field; the draft reaches it once the composition settles.
+ if (!isComposing()) {
+ writeNativeChatComposerText(textarea, next, nextCaret)
+ }
setCaret(nextCaret)
setActiveSuggestion(0)
- requestAnimationFrame(() => {
- textarea.setSelectionRange(nextCaret, nextCaret)
- })
},
- [setActiveSuggestion, setCaret, setDraft]
+ [isComposing, setActiveSuggestion, setCaret, setDraft]
)
const insertTypedText = useCallback(
@@ -48,27 +49,28 @@ export function useNativeChatTypedInsertion(args: {
if (!textarea) {
return false
}
- const selectionStart = textarea.selectionStart ?? caret
+ const { value } = textarea
+ const selectionStart = textarea.selectionStart ?? value.length
const selectionEnd = textarea.selectionEnd ?? selectionStart
showDraft(
textarea,
- `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}`,
+ `${value.slice(0, selectionStart)}${text}${value.slice(selectionEnd)}`,
selectionStart + text.length
)
return true
},
- [caret, draft, showDraft, usableInput]
+ [showDraft, usableInput]
)
const appendText = useCallback(
(text: string): void => {
const textarea = usableInput()
if (textarea) {
- const next = appendReturnedDraftText(draft, text)
+ const next = appendReturnedDraftText(textarea.value, text)
showDraft(textarea, next, next.length)
}
},
- [draft, showDraft, usableInput]
+ [showDraft, usableInput]
)
// Reads the live input when a delayed clipboard read settles.