mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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
This commit is contained in:
@@ -60,7 +60,8 @@ vi.mock('./use-native-chat-draft', () => ({
|
||||
vi.mock('./NativeChatComposerField', () => ({
|
||||
NativeChatComposerField: (props: FieldProps) => {
|
||||
mocks.fieldProps = props
|
||||
return <textarea ref={props.textareaRef} />
|
||||
// Holds the draft as the real editor does; a picker completes the text the editor holds.
|
||||
return <textarea ref={props.textareaRef} value={mocks.draft} readOnly />
|
||||
}
|
||||
}))
|
||||
vi.mock('./use-native-chat-skills', () => ({
|
||||
|
||||
@@ -127,6 +127,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
caret,
|
||||
recalledFromHistory: recall.active,
|
||||
disabled,
|
||||
isComposing: imeEnterGesture.isComposing,
|
||||
structuredTransport,
|
||||
editor: { textareaRef, setDraft, setCaret, setActiveSuggestion },
|
||||
setNotice
|
||||
@@ -199,8 +200,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
const handlePasteEvent = useNativeChatComposerHandle(ref, {
|
||||
attachmentScopeKey: draftScopeKey,
|
||||
textareaRef,
|
||||
isComposing: imeEnterGesture.isComposing,
|
||||
caret,
|
||||
draft,
|
||||
setDraft,
|
||||
setCaret,
|
||||
setActiveSuggestion,
|
||||
|
||||
+1
-2
@@ -53,8 +53,7 @@ it.each([
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatTypedInsertion({
|
||||
textareaRef: inputRef,
|
||||
caret: draft.length,
|
||||
draft: input.value,
|
||||
isComposing: () => false,
|
||||
setDraft: (value) => {
|
||||
input.value = value
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+50
-27
@@ -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()
|
||||
|
||||
@@ -16,12 +16,11 @@ export function useNativeChatComposerHandle(
|
||||
ref: ForwardedRef<NativeChatComposerHandle>,
|
||||
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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<NativeChatComposerInput | null>
|
||||
caret: number
|
||||
draft: string
|
||||
isComposing: () => boolean
|
||||
setDraft: (value: string) => void
|
||||
setCaret: Dispatch<SetStateAction<number>>
|
||||
setActiveSuggestion: Dispatch<SetStateAction<number>>
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user