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.