fix(app): preserve IME composition across text fields (#3517)

* fix(app): preserve IME composition in form inputs

Form model publication was replaying changing default text into the native or DOM editing surface. Keep the editing surface authoritative, defer web publication until composition commits, and reserve resetKey changes for explicit text replacement.

* refactor(app): centralize text editing authority

Route every app text field, including the composer and plugin runtime, through one platform-owned editing primitive. Its type and runtime reject controlled value replay, its command handle owns programmatic replacement, and lint prevents raw React Native TextInput imports outside the boundary.

* fix(app): restore composer placeholder styling
This commit is contained in:
Mohamed Boudra
2026-08-18 11:32:49 +00:00
committed by GitHub
parent cd2b6bca9d
commit e2e97e4854
39 changed files with 575 additions and 390 deletions
@@ -10,7 +10,11 @@ import {
createElement,
} from "react";
import { createPortal } from "react-dom";
import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from "react-native";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import {
ArrowLeft,
ArrowRight,
@@ -91,10 +95,6 @@ type ElectronWebview = HTMLElement & {
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
};
type WebTextInput = TextInput & {
getNativeRef?: () => unknown;
};
interface BrowserElementAnnotation {
comment: string;
}
@@ -403,8 +403,10 @@ function clearAnnotationMarkers(webview: ElectronWebview): void {
).catch(ignoreWebviewJavaScriptError);
}
function getTextInputNativeElement(current: WebTextInput | null): HTMLInputElement | null {
const native = current?.getNativeRef?.() ?? current;
function getTextInputNativeElement(
current: EditingTextInputHandle | null,
): HTMLInputElement | null {
const native = current?.getNativeRef();
return native instanceof HTMLInputElement ? native : null;
}
@@ -606,7 +608,7 @@ export function BrowserPane({
const webviewRef = useRef<ElectronWebview | null>(null);
const webviewHostRef = useRef<HTMLDivElement | null>(null);
const webviewClipRef = useRef<HTMLElement | null>(null);
const urlInputRef = useRef<WebTextInput | null>(null);
const urlInputRef = useRef<EditingTextInputHandle | null>(null);
const initialUrlRef = useRef(browser?.url ?? "https://example.com");
const browserIdRef = useRef(browserId);
browserIdRef.current = browserId;
@@ -674,6 +676,7 @@ export function BrowserPane({
useEffect(() => {
const nextUrl = browser?.url ?? "https://example.com";
urlInputRef.current?.replaceText(nextUrl);
setDraftUrl((current) => (current === nextUrl ? current : nextUrl));
}, [browser?.url]);
@@ -1473,7 +1476,7 @@ export function BrowserPane({
placeholderTextColor={theme.colors.foregroundMuted}
ref={urlInputRef}
style={urlInputStyle}
value={draftUrl}
initialValue={draftUrl}
/>
</View>
<View style={styles.chromeRight}>
@@ -1629,7 +1632,7 @@ function BrowserElementAnnotationCard({
placeholder={t("workspace.browser.annotate.placeholder")}
style={styles.annotationInput}
uniProps={annotationInputMapping}
value={comment}
initialValue={comment}
/>
<View style={styles.annotationActions}>
<Button variant="ghost" size="sm" onPress={onCancel}>
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { Text, TextInput, View } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { SvgXml } from "react-native-svg";
@@ -16,6 +16,10 @@ import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -230,6 +234,8 @@ function RelayHeroBadge() {
function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
const { t } = useTranslation();
const inputRef = useRef<EditingTextInputHandle>(null);
useEffect(() => inputRef.current?.replaceText(props.offer.url), [props.offer.url]);
return (
<View style={styles.offer}>
<Text style={styles.offerHint}>{t("pairing.device.hint")}</Text>
@@ -239,8 +245,9 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
<View style={styles.linkRow}>
<View style={styles.inputWrapper}>
<TextInput
ref={inputRef}
style={styles.linkInput}
value={props.offer.url}
initialValue={props.offer.url}
readOnly
selectTextOnFocus
accessibilityLabel={t("pairing.link.label")}