fix(app): make composer input IME-safe and paste-aware (#3343)

* fix(app): preserve native composer input semantics

Native text was round-tripped through application state on every keystroke, which could overwrite an input method's in-flight edits. Keep native text ownership inside the input and reserve application-driven replacement for explicit draft mutations.\n\nRoute native clipboard images through the existing persisted attachment pipeline so keyboard and system paste actions behave like other image sources.

* fix(nix): refresh dependency hash

The native composer dependency changes the fixed npm dependency closure used by desktop builds.

* fix(app): keep composer locked across image pastes

Count concurrent native image persistence operations so submit remains disabled until every pasted image is attached.

* fix(app): replace composer text through the DOM on web

Web TextInput refs are DOM elements, so application-driven draft replacement must update their value and selection without native setNativeProps.

* fix(app): preserve web IME composition

Carry the Chinese IME fix from #2990 into the web composer input boundary.

Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>

---------

Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
This commit is contained in:
Mohamed Boudra
2026-08-14 11:52:24 +02:00
committed by GitHub
co-authored by Jimmy Lee
parent 770b87a15c
commit 30195d3bd1
23 changed files with 757 additions and 71 deletions
+63 -34
View File
@@ -56,6 +56,9 @@ import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import { ComposerTextInput } from "./text-input";
import type { ComposerTextInputHandle } from "./text-input-types";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
@@ -105,6 +108,7 @@ export interface MessageInputProps {
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef?: (node: View | null) => void;
onAddImages?: (images: ImageAttachment[]) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
client: DaemonClient | null;
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean;
@@ -147,6 +151,8 @@ export interface MessageInputProps {
inputMode?: ComposerInputMode;
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
readOnly?: boolean;
/** Changes only when application state must replace native-owned text. */
textReplacementKey: string;
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
submitLabel?: string;
}
@@ -154,6 +160,8 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
getText: () => string;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
@@ -397,12 +405,9 @@ function handleDesktopKeyPressImpl(
ctx.handleDefaultSendAction();
}
function getTextInputNativeElement(
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): HTMLElement | null {
function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTMLElement | null {
if (!current) return null;
const handle = current as TextInput & { getNativeRef?: () => unknown };
const native = typeof handle.getNativeRef === "function" ? handle.getNativeRef() : current;
const native = typeof current.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null;
}
@@ -478,9 +483,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
}
function useAutoFocusOnWebEffect(
textInputRef: React.MutableRefObject<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>,
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>,
autoFocus: boolean,
autoFocusKey: string | undefined,
): void {
@@ -588,8 +591,8 @@ function FocusHint({
interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textInputRef: React.Ref<TextInput>;
textInputStyle: React.ComponentProps<typeof ThemedTextInput>["style"];
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: React.ComponentProps<typeof TextInput>["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
placeholder: string;
accessibilityLabel: string;
@@ -602,6 +605,8 @@ interface ComposerTextSurfaceProps {
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
onPasteError: (message: string) => void;
focusHintVisible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
focusHintLabel: string;
@@ -624,13 +629,12 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
}
return (
<View style={styles.textInputScrollWrapper}>
<ThemedTextInput
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
value={props.value}
text={props.value}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
uniProps={textInputPlaceholderColorMapping}
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
@@ -641,6 +645,8 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
editable={props.editable}
onKeyPress={props.onKeyPress}
onSelectionChange={props.onSelectionChange}
onPasteImages={props.onPasteImages}
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
<FocusHint
@@ -904,7 +910,7 @@ interface QueueMessageContext {
attachments: ComposerAttachment[];
cwd: string;
onQueue: ((payload: MessagePayload) => void) | undefined;
onChangeText: (text: string) => void;
replaceText: (text: string) => void;
onMinimizeHeight: () => void;
}
@@ -913,7 +919,7 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return;
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.onChangeText("");
ctx.replaceText("");
ctx.onMinimizeHeight();
}
@@ -965,9 +971,7 @@ function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
}
function getWebTextAreaImpl(
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): TextAreaHandle | null {
function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHandle | null {
if (!current) return null;
const candidate = current as { getNativeRef?: () => unknown };
if (typeof candidate.getNativeRef === "function") {
@@ -1019,6 +1023,7 @@ interface ResolvedMessageInputProps {
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef: ((node: View | null) => void) | undefined;
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
placeholder: string | undefined;
@@ -1044,6 +1049,7 @@ interface ResolvedMessageInputProps {
attachmentSlot: React.ReactNode;
inputMode: ComposerInputMode;
readOnly: boolean;
textReplacementKey: string;
submitLabel: string | undefined;
}
@@ -1065,6 +1071,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentMenuItems: props.attachmentMenuItems,
onAttachButtonRef: props.onAttachButtonRef,
onAddImages: props.onAddImages,
onPasteImages: props.onPasteImages,
client: props.client,
isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder,
@@ -1090,6 +1097,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentSlot: props.attachmentSlot,
inputMode: props.inputMode ?? "chat",
readOnly: props.readOnly ?? false,
textReplacementKey: props.textReplacementKey,
submitLabel: props.submitLabel,
};
}
@@ -1119,6 +1127,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentMenuItems,
onAttachButtonRef,
onAddImages,
onPasteImages,
client,
isReadyForDictation,
placeholder,
@@ -1144,6 +1153,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentSlot,
inputMode,
readOnly,
textReplacementKey,
submitLabel,
} = resolveMessageInputProps(props);
const mode = resolveComposerInputMode(inputMode);
@@ -1161,18 +1171,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
null,
);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const valueRef = useRef(value);
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
valueRef.current = nextText;
textInputRef.current?.replaceText(nextText, selection);
onChangeText(nextText);
},
[onChangeText],
);
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus();
},
blur: () => {
textInputRef.current?.blur?.();
textInputRef.current?.blur();
},
getText: () => textInputRef.current?.getText() ?? valueRef.current,
replaceText,
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
@@ -1191,7 +1212,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore(
useCallback(
(state) => {
@@ -1205,8 +1225,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
useEffect(() => {
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey;
valueRef.current = value;
}, [value]);
textInputRef.current?.replaceText(value);
}, [textReplacementKey, value]);
useEffect(() => {
return () => {
@@ -1226,13 +1249,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
onQueue,
onSubmit,
onChangeText,
replaceText,
attachments,
cwd,
autoSend,
});
},
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
[replaceText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
@@ -1413,7 +1436,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleSendMessage = useCallback(
() =>
sendMessageImpl({
value: valueRef.current,
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
hasExternalContent,
allowEmptySubmit,
@@ -1438,14 +1461,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleQueueMessage = useCallback(
() =>
queueMessageImpl({
value: valueRef.current,
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
cwd,
onQueue,
onChangeText,
replaceText,
onMinimizeHeight: minimizeInputHeight,
}),
[attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
);
const handleDefaultSendAction = useCallback(() => {
@@ -1618,6 +1641,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onFocusChange?.(false);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
console.error("[MessageInput] Native paste failed:", message);
toast.error(t("composer.errors.pasteImageFailed"));
},
[t, toast],
);
const attachButtonStyle = useCallback(
({ hovered }: { hovered?: boolean }) => [
styles.attachButton,
@@ -1732,6 +1763,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
@@ -1982,11 +2015,7 @@ const ThemedMicOff = withUnistyles(MicOff);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const textInputPlaceholderColorMapping = (theme: Theme) => ({
placeholderTextColor: theme.colors.surface4,
});