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:
co-authored by
Jimmy Lee
parent
770b87a15c
commit
30195d3bd1
@@ -4,6 +4,7 @@ const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
@@ -139,6 +140,7 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@getpaseo/protocol": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
IOSConfig,
|
||||
withAppDelegate,
|
||||
withDangerousMod,
|
||||
withPodfile,
|
||||
} = require("expo/config-plugins");
|
||||
|
||||
const MODULE_SETUP = " PasteInputModule.setup(factory.rootViewFactory)";
|
||||
const REACT_NATIVE_START_END = " launchOptions: launchOptions)\n#endif";
|
||||
const BRIDGING_HEADER_IMPORT = "#import <react-native-paste-input/PasteInputModule.h>";
|
||||
const PODFILE_NEW_ARCH_DISABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const PODFILE_NEW_ARCH_ENABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'";
|
||||
|
||||
function configurePasteInputAppDelegate(contents) {
|
||||
const configured = contents.replace(`\n${MODULE_SETUP}`, "");
|
||||
if (!configured.includes(REACT_NATIVE_START_END)) {
|
||||
throw new Error("Could not register paste input after React Native starts");
|
||||
}
|
||||
return configured.replace(
|
||||
REACT_NATIVE_START_END,
|
||||
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
|
||||
);
|
||||
}
|
||||
|
||||
function configurePasteInputBridgingHeader(contents) {
|
||||
if (contents.includes(BRIDGING_HEADER_IMPORT)) {
|
||||
return contents;
|
||||
}
|
||||
return `${contents.trimEnd()}\n${BRIDGING_HEADER_IMPORT}\n`;
|
||||
}
|
||||
|
||||
function configurePasteInputPodfile(contents) {
|
||||
if (contents.includes(PODFILE_NEW_ARCH_ENABLED)) {
|
||||
return contents;
|
||||
}
|
||||
if (!contents.includes(PODFILE_NEW_ARCH_DISABLED)) {
|
||||
throw new Error("Could not configure the new architecture for paste input");
|
||||
}
|
||||
return contents.replace(
|
||||
PODFILE_NEW_ARCH_DISABLED,
|
||||
`${PODFILE_NEW_ARCH_DISABLED}\n${PODFILE_NEW_ARCH_ENABLED}`,
|
||||
);
|
||||
}
|
||||
|
||||
function withPasteInput(config) {
|
||||
const withDelegate = withAppDelegate(config, (modConfig) => {
|
||||
if (modConfig.modResults.language !== "swift") {
|
||||
throw new Error("Paste input setup requires a Swift AppDelegate");
|
||||
}
|
||||
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
const withPods = withPodfile(withDelegate, (modConfig) => {
|
||||
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
return withPasteInputBridgingHeader(withPods);
|
||||
}
|
||||
|
||||
function withPasteInputBridgingHeader(config) {
|
||||
return withDangerousMod(config, [
|
||||
"ios",
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.projectRoot;
|
||||
const sourceRoot = IOSConfig.Paths.getSourceRoot(projectRoot);
|
||||
const projectName = IOSConfig.XcodeUtils.getProjectName(projectRoot);
|
||||
const headerPath = path.join(sourceRoot, `${projectName}-Bridging-Header.h`);
|
||||
const contents = fs.readFileSync(headerPath, "utf8");
|
||||
fs.writeFileSync(headerPath, configurePasteInputBridgingHeader(contents));
|
||||
return modConfig;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = withPasteInput;
|
||||
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
|
||||
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
|
||||
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
configurePasteInputAppDelegate,
|
||||
configurePasteInputBridgingHeader,
|
||||
configurePasteInputPodfile,
|
||||
} = require("./with-paste-input");
|
||||
|
||||
describe("withPasteInput", () => {
|
||||
it("registers the module after React Native starts", () => {
|
||||
const source = [
|
||||
"import ReactAppDependencyProvider",
|
||||
"",
|
||||
" bindReactNativeFactory(factory)",
|
||||
" factory.startReactNative(",
|
||||
' withModuleName: "main",',
|
||||
" in: window,",
|
||||
" launchOptions: launchOptions)",
|
||||
"#endif",
|
||||
].join("\n");
|
||||
const configured = configurePasteInputAppDelegate(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
|
||||
);
|
||||
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("exposes the Objective-C module to Swift through the bridging header", () => {
|
||||
const source = "// Swift bridging header\n";
|
||||
const configured = configurePasteInputBridgingHeader(source);
|
||||
|
||||
expect(configured).toContain("#import <react-native-paste-input/PasteInputModule.h>");
|
||||
expect(configurePasteInputBridgingHeader(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("sets the new architecture environment before CocoaPods evaluates dependencies", () => {
|
||||
const source =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const configured = configurePasteInputPodfile(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
|
||||
);
|
||||
expect(configurePasteInputPodfile(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
@@ -438,7 +438,8 @@ export function WorkspaceSetupDialog() {
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacementKey={chatDraft.textReplacementKey}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
|
||||
@@ -224,11 +224,25 @@ describe("useAgentInputDraft live contract", () => {
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
const hydratedReplacementKey = getLatest().textReplacementKey;
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("hello world");
|
||||
getLatest().editText("hello world");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||
});
|
||||
|
||||
expect(getLatest().textReplacementKey).toBe(hydratedReplacementKey);
|
||||
|
||||
await act(async () => {
|
||||
getLatest().replaceText("replacement text");
|
||||
});
|
||||
|
||||
expect(getLatest().textReplacementKey).not.toBe(hydratedReplacementKey);
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("hello world");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.unmount();
|
||||
});
|
||||
@@ -418,7 +432,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("with attachment");
|
||||
getLatest().editText("with attachment");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||
});
|
||||
|
||||
@@ -500,7 +514,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("queued message");
|
||||
getLatest().editText("queued message");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||
});
|
||||
|
||||
@@ -554,7 +568,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("queued message");
|
||||
getLatest().editText("queued message");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
|
||||
});
|
||||
|
||||
@@ -570,7 +584,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("draft again");
|
||||
getLatest().editText("draft again");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -52,7 +52,9 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
|
||||
export interface AgentInputDraft {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
editText: (text: string) => void;
|
||||
replaceText: (text: string) => void;
|
||||
textReplacementKey: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
@@ -84,6 +86,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
|
||||
);
|
||||
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
||||
const [textReplacementRevision, setTextReplacementRevision] = useState(0);
|
||||
const text = draft?.text ?? "";
|
||||
const attachments = draft?.attachments ?? [];
|
||||
const isHydrated = hydratedDraftKey === draftKey;
|
||||
@@ -107,13 +110,21 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const setText = useCallback(
|
||||
const editText = useCallback(
|
||||
(nextText: string) => {
|
||||
saveDraft((current) => ({ ...current, text: nextText }));
|
||||
},
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const replaceText = useCallback(
|
||||
(nextText: string) => {
|
||||
saveDraft((current) => ({ ...current, text: nextText }));
|
||||
setTextReplacementRevision((revision) => revision + 1);
|
||||
},
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const setAttachments = useCallback(
|
||||
(updater: AttachmentUpdater) => {
|
||||
saveDraft((current) => ({
|
||||
@@ -136,6 +147,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
void (async () => {
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
setTextReplacementRevision((revision) => revision + 1);
|
||||
setHydratedDraftKey(draftKey);
|
||||
}
|
||||
})();
|
||||
@@ -264,7 +276,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey: `${draftKey}:${textReplacementRevision}`,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
|
||||
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const draftOnSetFeature = composerState.agentControls.onSetFeature;
|
||||
|
||||
const clearDraftInput = draftInput.clear;
|
||||
const setDraftText = draftInput.setText;
|
||||
const replaceDraftText = draftInput.replaceText;
|
||||
const setDraftAttachments = draftInput.setAttachments;
|
||||
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
|
||||
const pending = state.pendingByDraftId[draftId] ?? null;
|
||||
@@ -587,7 +587,7 @@ export function WorkspaceDraftAgentTab({
|
||||
return;
|
||||
}
|
||||
autoSubmitKeyRef.current = submitKey;
|
||||
setDraftText("");
|
||||
replaceDraftText("");
|
||||
setDraftAttachments([]);
|
||||
const preparedAttempt =
|
||||
initialCreateAttempt?.clientMessageId === submission.clientMessageId
|
||||
@@ -604,7 +604,7 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd: submission.cwd,
|
||||
});
|
||||
void createPromise.catch(() => {
|
||||
setDraftText(submission.text);
|
||||
replaceDraftText(submission.text);
|
||||
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
|
||||
autoSubmitKeyRef.current = null;
|
||||
});
|
||||
@@ -617,7 +617,7 @@ export function WorkspaceDraftAgentTab({
|
||||
isReadyForPendingAutoSubmit,
|
||||
serverId,
|
||||
setDraftAttachments,
|
||||
setDraftText,
|
||||
replaceDraftText,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
@@ -699,7 +699,8 @@ export function WorkspaceDraftAgentTab({
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.setText}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacementKey={draftInput.textReplacementKey}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
|
||||
@@ -125,6 +125,7 @@ import { getForgePresentation } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
import { readClipboardImage } from "./clipboard-image";
|
||||
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import {
|
||||
appendWorkspaceFileAttachment,
|
||||
@@ -846,6 +847,7 @@ interface ComposerProps {
|
||||
blurOnSubmit?: boolean;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
textReplacementKey: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
attachmentScopeKeys?: readonly string[];
|
||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||
@@ -1054,6 +1056,7 @@ export function Composer({
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
onChangeText,
|
||||
textReplacementKey,
|
||||
attachments,
|
||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||
onOpenWorkspaceAttachment,
|
||||
@@ -1151,6 +1154,7 @@ export function Composer({
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
||||
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
|
||||
@@ -1163,6 +1167,17 @@ export function Composer({
|
||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
|
||||
const replaceUserInput = useCallback(
|
||||
(text: string, selection?: { start: number; end: number }) => {
|
||||
if (messageInputRef.current) {
|
||||
messageInputRef.current.replaceText(text, selection);
|
||||
return;
|
||||
}
|
||||
onChangeText(text);
|
||||
},
|
||||
[onChangeText],
|
||||
);
|
||||
|
||||
const runClientSlashCommand = useCallback(
|
||||
(command: ClientSlashCommand): boolean => {
|
||||
if (command.execution !== "immediate" || !onClientSlashCommand) {
|
||||
@@ -1173,7 +1188,7 @@ export function Composer({
|
||||
messageInputRef.current?.blur();
|
||||
}
|
||||
clearDraft("sent");
|
||||
setUserInput("");
|
||||
replaceUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
resetSuppression();
|
||||
setSendError(null);
|
||||
@@ -1194,14 +1209,14 @@ export function Composer({
|
||||
onClientSlashCommand,
|
||||
resetSuppression,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
replaceUserInput,
|
||||
],
|
||||
);
|
||||
|
||||
const autocomplete = useAgentAutocomplete({
|
||||
userInput,
|
||||
cursorIndex,
|
||||
setUserInput,
|
||||
setUserInput: replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig: commandDraftConfig,
|
||||
@@ -1356,7 +1371,7 @@ export function Composer({
|
||||
});
|
||||
if (!result.queued) return;
|
||||
|
||||
setUserInput("");
|
||||
replaceUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
resetSuppression();
|
||||
clearSentAttachments(queuedAttachments);
|
||||
@@ -1367,7 +1382,7 @@ export function Composer({
|
||||
queueWriter,
|
||||
resetSuppression,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
replaceUserInput,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1398,7 +1413,7 @@ export function Composer({
|
||||
await submitMessage(submitText, submitAttachments);
|
||||
},
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setUserInput: replaceUserInput,
|
||||
setAttachments: (nextAttachments) => {
|
||||
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
|
||||
},
|
||||
@@ -1423,7 +1438,7 @@ export function Composer({
|
||||
isAgentRunning,
|
||||
queueMessage,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
replaceUserInput,
|
||||
submitBehavior,
|
||||
submitMessage,
|
||||
t,
|
||||
@@ -1484,6 +1499,30 @@ export function Composer({
|
||||
}
|
||||
}, [addImages, t]);
|
||||
|
||||
const handleNativePasteImages = useCallback(
|
||||
(files: readonly NativePastedFile[]) => {
|
||||
setPendingNativeImagePastes((pending) => pending + 1);
|
||||
void pickAndPersistImages({
|
||||
pickImages: async () => normalizeNativePastedImages(files),
|
||||
persister: composerImageAttachmentPersister,
|
||||
})
|
||||
.then((newImages) => {
|
||||
if (newImages.length > 0) {
|
||||
addImages(newImages);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Composer] Failed to persist pasted image:", error);
|
||||
toastErrorRef.current(t("composer.errors.pasteImageFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingNativeImagePastes((pending) => Math.max(0, pending - 1));
|
||||
});
|
||||
},
|
||||
[addImages, t],
|
||||
);
|
||||
|
||||
const uploadPickedFiles = useCallback(
|
||||
async (files: PickedFile[]) => {
|
||||
if (files.length === 0) return;
|
||||
@@ -1686,10 +1725,10 @@ export function Composer({
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result) return;
|
||||
setUserInput(result.text);
|
||||
replaceUserInput(result.text);
|
||||
setSelectedAttachments(result.attachments);
|
||||
},
|
||||
[agentId, queueWriter, setSelectedAttachments, setUserInput],
|
||||
[agentId, queueWriter, replaceUserInput, setSelectedAttachments],
|
||||
);
|
||||
|
||||
const handleSendQueuedNow = useCallback(
|
||||
@@ -2073,7 +2112,8 @@ export function Composer({
|
||||
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
|
||||
const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile;
|
||||
const isSubmitLoadingVisible =
|
||||
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
|
||||
const isSubmitDisabled =
|
||||
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
|
||||
@@ -2142,6 +2182,7 @@ export function Composer({
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
onPasteImages={handleNativePasteImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
@@ -2167,6 +2208,7 @@ export function Composer({
|
||||
attachmentSlot={attachmentTray}
|
||||
inputMode={inputMode}
|
||||
readOnly={readOnly}
|
||||
textReplacementKey={textReplacementKey}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
<Combobox
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("dictation transcript behavior", () => {
|
||||
defaultSendBehavior: "interrupt",
|
||||
isAgentRunning: false,
|
||||
onQueue: undefined,
|
||||
onChangeText: (text) => actions.push(`change:${text}`),
|
||||
replaceText: (text) => actions.push(`replace:${text}`),
|
||||
onSubmit: (payload) => actions.push(`submit:${payload.text}`),
|
||||
attachments: [],
|
||||
cwd: "/repo",
|
||||
@@ -181,7 +181,7 @@ describe("dictation transcript behavior", () => {
|
||||
});
|
||||
|
||||
expect(actions).toEqual([
|
||||
"change:typed context spoken prompt",
|
||||
"replace:typed context spoken prompt",
|
||||
"submit:typed context spoken prompt",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ interface DictationTranscriptContext {
|
||||
isAgentRunning: boolean;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
onSubmit: (payload: MessagePayload) => void;
|
||||
onChangeText: (text: string) => void;
|
||||
replaceText: (text: string) => void;
|
||||
attachments: MessagePayload["attachments"];
|
||||
cwd: string;
|
||||
autoSend: boolean;
|
||||
@@ -64,15 +64,15 @@ export function applyDictationTranscript(text: string, ctx: DictationTranscriptC
|
||||
const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`;
|
||||
|
||||
if (!ctx.autoSend) {
|
||||
ctx.onChangeText(nextValue);
|
||||
ctx.replaceText(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.onChangeText(nextValue);
|
||||
ctx.replaceText(nextValue);
|
||||
|
||||
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
|
||||
ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd });
|
||||
ctx.onChangeText("");
|
||||
ctx.replaceText("");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TextInputProps } from "react-native";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
|
||||
export interface ComposerTextInputHandle {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getText: () => string;
|
||||
replaceText: (text: string, selection?: { start: number; end: number }) => void;
|
||||
getNativeRef?: () => unknown;
|
||||
}
|
||||
|
||||
export interface ComposerTextInputProps extends Omit<
|
||||
TextInputProps,
|
||||
"defaultValue" | "onChangeText" | "value"
|
||||
> {
|
||||
text: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onPasteImages?: (files: readonly NativePastedFile[]) => void;
|
||||
onPasteError?: (message: string) => void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./text-input.native";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import PasteInput, {
|
||||
type PastedFile,
|
||||
type PasteTextInputInstance,
|
||||
} from "@mattermost/react-native-paste-input";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
|
||||
|
||||
type NativeInputInstance = PasteTextInputInstance & {
|
||||
blur: () => void;
|
||||
focus: () => void;
|
||||
setNativeProps: (props: { text: string }) => void;
|
||||
};
|
||||
|
||||
const ThemedPasteInput = withUnistyles(PasteInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.surface4,
|
||||
}));
|
||||
|
||||
export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>(
|
||||
function ComposerTextInputNative(
|
||||
{ text, onChangeText, onPasteImages, onPasteError, ...props },
|
||||
ref,
|
||||
) {
|
||||
const inputRef = useRef<NativeInputInstance | null>(null);
|
||||
const textRef = useRef(text);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
blur: () => inputRef.current?.blur(),
|
||||
getText: () => textRef.current,
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
inputRef.current?.setNativeProps({ text: nextText });
|
||||
if (selection) {
|
||||
inputRef.current?.setSelection(selection.start, selection.end);
|
||||
}
|
||||
},
|
||||
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
|
||||
}));
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(nextText: string) => {
|
||||
textRef.current = nextText;
|
||||
onChangeText(nextText);
|
||||
},
|
||||
[onChangeText],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(error: string | null | undefined, files: PastedFile[]) => {
|
||||
if (error) {
|
||||
onPasteError?.(error);
|
||||
return;
|
||||
}
|
||||
if (files.length > 0) {
|
||||
onPasteImages?.(files);
|
||||
}
|
||||
},
|
||||
[onPasteError, onPasteImages],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedPasteInput
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
defaultValue={text}
|
||||
onChangeText={handleChangeText}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ComposerTextInput } from "./text-input.web";
|
||||
|
||||
interface MountedInput {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
textarea: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
interface TextRecorder {
|
||||
changes: string[];
|
||||
onChangeText: (text: string) => void;
|
||||
}
|
||||
|
||||
const mountedInputs: MountedInput[] = [];
|
||||
|
||||
function mountInput(onChangeText: (text: string) => void): MountedInput {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ComposerTextInput
|
||||
text=""
|
||||
multiline={true}
|
||||
onChangeText={onChangeText}
|
||||
testID="composer-input"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Composer text input did not render a textarea");
|
||||
}
|
||||
|
||||
const mounted = { root, container, textarea };
|
||||
mountedInputs.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
function dispatchComposition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: "compositionstart" | "compositionend",
|
||||
) {
|
||||
textarea.dispatchEvent(new CompositionEvent(type, { bubbles: true }));
|
||||
}
|
||||
|
||||
function typeFromIme(textarea: HTMLTextAreaElement, text: string): void {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
if (!valueSetter) {
|
||||
throw new Error("HTML textarea value setter is unavailable");
|
||||
}
|
||||
valueSetter.call(textarea, text);
|
||||
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
|
||||
}
|
||||
|
||||
function ignoreTextChange(): void {}
|
||||
|
||||
function createTextRecorder(): TextRecorder {
|
||||
const changes: string[] = [];
|
||||
return {
|
||||
changes,
|
||||
onChangeText: (text) => changes.push(text),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const mounted of mountedInputs.splice(0)) {
|
||||
act(() => mounted.root.unmount());
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ComposerTextInput web IME composition", () => {
|
||||
it("keeps the DOM-owned candidate during composition and reports the committed text", () => {
|
||||
vi.useFakeTimers();
|
||||
const recorder = createTextRecorder();
|
||||
const mounted = mountInput(recorder.onChangeText);
|
||||
|
||||
act(() => {
|
||||
dispatchComposition(mounted.textarea, "compositionstart");
|
||||
mounted.textarea.value = "你好";
|
||||
mounted.root.render(
|
||||
<ComposerTextInput
|
||||
text=""
|
||||
multiline={true}
|
||||
onChangeText={recorder.onChangeText}
|
||||
placeholder="rerender while composing"
|
||||
testID="composer-input"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mounted.textarea.value).toBe("你好");
|
||||
|
||||
act(() => {
|
||||
dispatchComposition(mounted.textarea, "compositionend");
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(recorder.changes).toEqual(["你好"]);
|
||||
});
|
||||
|
||||
it("does not let a previous composition-end timer take ownership from a new composition", () => {
|
||||
vi.useFakeTimers();
|
||||
const mounted = mountInput(ignoreTextChange);
|
||||
|
||||
act(() => {
|
||||
dispatchComposition(mounted.textarea, "compositionstart");
|
||||
mounted.textarea.value = "你";
|
||||
dispatchComposition(mounted.textarea, "compositionend");
|
||||
dispatchComposition(mounted.textarea, "compositionstart");
|
||||
mounted.textarea.value = "你好";
|
||||
vi.runAllTimers();
|
||||
mounted.root.render(
|
||||
<ComposerTextInput
|
||||
text="你"
|
||||
multiline={true}
|
||||
onChangeText={ignoreTextChange}
|
||||
placeholder="second composition"
|
||||
testID="composer-input"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mounted.textarea.value).toBe("你好");
|
||||
});
|
||||
|
||||
it("does not report committed text twice when the input event already reported it", () => {
|
||||
vi.useFakeTimers();
|
||||
const changes: string[] = [];
|
||||
const mounted = mountInput((text) => changes.push(text));
|
||||
|
||||
act(() => {
|
||||
dispatchComposition(mounted.textarea, "compositionstart");
|
||||
typeFromIme(mounted.textarea, "你好");
|
||||
dispatchComposition(mounted.textarea, "compositionend");
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(changes).toEqual(["你好"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TextInput } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
|
||||
|
||||
interface WebTextInputElement extends TextInput {
|
||||
value?: string;
|
||||
setSelectionRange?: (start: number, end: number) => void;
|
||||
addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void;
|
||||
removeEventListener: (
|
||||
type: "compositionstart" | "compositionend",
|
||||
listener: EventListener,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.surface4,
|
||||
}));
|
||||
|
||||
export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>(
|
||||
function ComposerTextInputWeb(
|
||||
{ text, onChangeText, onPasteImages: _, onPasteError: __, ...props },
|
||||
ref,
|
||||
) {
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const textRef = useRef(text);
|
||||
const onChangeTextRef = useRef(onChangeText);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
if (!isComposing) textRef.current = text;
|
||||
onChangeTextRef.current = onChangeText;
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
if (!input) return;
|
||||
|
||||
let compositionEndTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const startComposition = () => {
|
||||
if (compositionEndTimer !== null) {
|
||||
clearTimeout(compositionEndTimer);
|
||||
compositionEndTimer = null;
|
||||
}
|
||||
setIsComposing(true);
|
||||
};
|
||||
const endComposition = () => {
|
||||
const nextText = input.value ?? "";
|
||||
if (nextText !== textRef.current) {
|
||||
textRef.current = nextText;
|
||||
onChangeTextRef.current(nextText);
|
||||
}
|
||||
compositionEndTimer = setTimeout(() => {
|
||||
compositionEndTimer = null;
|
||||
setIsComposing(false);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
input.addEventListener("compositionstart", startComposition);
|
||||
input.addEventListener("compositionend", endComposition);
|
||||
return () => {
|
||||
if (compositionEndTimer !== null) clearTimeout(compositionEndTimer);
|
||||
input.removeEventListener("compositionstart", startComposition);
|
||||
input.removeEventListener("compositionend", endComposition);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(nextText: string) => {
|
||||
if (nextText === textRef.current) return;
|
||||
textRef.current = nextText;
|
||||
onChangeText(nextText);
|
||||
},
|
||||
[onChangeText],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
blur: () => inputRef.current?.blur(),
|
||||
getText: () => textRef.current,
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
if (input && "value" in input) {
|
||||
input.value = nextText;
|
||||
}
|
||||
if (selection && typeof input?.setSelectionRange === "function") {
|
||||
input.setSelectionRange(selection.start, selection.end);
|
||||
}
|
||||
},
|
||||
getNativeRef: () => inputRef.current,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ThemedTextInput
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
value={isComposing ? undefined : text}
|
||||
onChangeText={handleChangeText}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeNativePastedImages, UnsupportedPastedImageError } from "./native-pasted-image";
|
||||
|
||||
describe("normalizeNativePastedImages", () => {
|
||||
it("turns pasted native image files into the existing picked-image input", () => {
|
||||
expect(
|
||||
normalizeNativePastedImages([
|
||||
{
|
||||
fileName: "clipboard.jpg",
|
||||
fileSize: 128,
|
||||
type: "image/jpg",
|
||||
uri: "file:///cache/clipboard.jpg",
|
||||
},
|
||||
{
|
||||
fileName: "animation.gif",
|
||||
fileSize: 256,
|
||||
type: "image/gif",
|
||||
uri: "file:///cache/animation.gif",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///cache/clipboard.jpg" },
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "clipboard.jpg",
|
||||
},
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///cache/animation.gif" },
|
||||
mimeType: "image/gif",
|
||||
fileName: "animation.gif",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects non-image content instead of swallowing it as an attachment", () => {
|
||||
expect(() =>
|
||||
normalizeNativePastedImages([
|
||||
{
|
||||
fileName: "notes.txt",
|
||||
fileSize: 12,
|
||||
type: "text/plain",
|
||||
uri: "file:///cache/notes.txt",
|
||||
},
|
||||
]),
|
||||
).toThrow(UnsupportedPastedImageError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { resolveRasterImageMimeType } from "@/attachments/file-types";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
|
||||
export interface NativePastedFile {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
type: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export class UnsupportedPastedImageError extends Error {
|
||||
constructor(fileName: string) {
|
||||
super(`Unsupported pasted image '${fileName}'.`);
|
||||
this.name = "UnsupportedPastedImageError";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNativePastedImages(
|
||||
files: readonly NativePastedFile[],
|
||||
): PickedImageAttachmentInput[] {
|
||||
return files.map((file) => {
|
||||
const mimeType = resolveRasterImageMimeType({
|
||||
mimeType: file.type,
|
||||
path: file.fileName,
|
||||
});
|
||||
if (!mimeType) {
|
||||
throw new UnsupportedPastedImageError(file.fileName);
|
||||
}
|
||||
return {
|
||||
source: { kind: "file_uri", uri: file.uri },
|
||||
mimeType,
|
||||
fileName: file.fileName,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1228,7 +1228,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
// when only toast state changes (which does not affect any draft field).
|
||||
const {
|
||||
text,
|
||||
setText,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1239,7 +1241,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const agentInputDraft = useMemo(
|
||||
(): AgentInputDraft => ({
|
||||
text,
|
||||
setText,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1249,7 +1253,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}),
|
||||
[
|
||||
text,
|
||||
setText,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1296,7 +1302,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
|
||||
|
||||
return (
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<RewindComposerRestoreProvider
|
||||
text={agentInputDraft.text}
|
||||
setText={agentInputDraft.replaceText}
|
||||
>
|
||||
<View style={styles.root}>
|
||||
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
@@ -1629,7 +1638,8 @@ function ActiveAgentComposer({
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
onChangeText={agentInputDraft.editText}
|
||||
textReplacementKey={agentInputDraft.textReplacementKey}
|
||||
attachments={agentInputDraft.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
|
||||
@@ -2309,6 +2309,7 @@ export function NewWorkspaceScreen({
|
||||
blurOnSubmit={true}
|
||||
value={terminalComposerValue}
|
||||
onChangeText={setTerminalPromptText}
|
||||
textReplacementKey={launchFocusKey}
|
||||
attachments={NO_TERMINAL_ATTACHMENTS}
|
||||
onChangeAttachments={noopChangeAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
@@ -2332,7 +2333,8 @@ export function NewWorkspaceScreen({
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacementKey={chatDraft.textReplacementKey}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
|
||||
Reference in New Issue
Block a user