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
+1 -1
View File
@@ -1 +1 @@
sha256-i5PbVUe2Ec+GtghV9IpCJQJ9hcUT5hFhmxneNvoD584= sha256-qPIKchcbGkJHFSqgeM/BUSmu7TiVfKy3IEgUcMNNJYc=
+11
View File
@@ -7762,6 +7762,16 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mattermost/react-native-paste-input": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-2.0.1.tgz",
"integrity": "sha512-Q9zjXvjwxVQ1M8ylxr9Y+wUpG7xb1wW4u+I34oPvF3etTyBrGtVyA4Bt7Xphgc29eW6wQFUxTIbzZc3E6W2MQg==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": ">=0.76.0"
}
},
"node_modules/@mermaid-js/parser": { "node_modules/@mermaid-js/parser": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
@@ -36108,6 +36118,7 @@
"@getpaseo/protocol": "*", "@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14", "@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.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-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5", "@react-native/normalize-colors": "^0.81.5",
+2
View File
@@ -4,6 +4,7 @@ const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size"); const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable"); const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking"); const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const { getNativeReleaseVersion } = require("./native-release-version"); const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production"; const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
@@ -139,6 +140,7 @@ export default {
}, },
plugins: [ plugins: [
"expo-router", "expo-router",
withPasteInput,
[withAndroidAsyncStorageSize, 64], [withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins, ...buildProfile.cameraPlugins,
[ [
+1
View File
@@ -48,6 +48,7 @@
"@getpaseo/protocol": "*", "@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14", "@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.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-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5", "@react-native/normalize-colors": "^0.81.5",
+82
View File
@@ -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"} isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true} blurOnSubmit={true}
value={chatDraft.text} value={chatDraft.text}
onChangeText={chatDraft.setText} onChangeText={chatDraft.editText}
textReplacementKey={chatDraft.textReplacementKey}
attachments={chatDraft.attachments} attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments} onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory} cwd={sourceDirectory}
@@ -224,11 +224,25 @@ describe("useAgentInputDraft live contract", () => {
thinkingOptionId: "high", thinkingOptionId: "high",
}); });
const hydratedReplacementKey = getLatest().textReplacementKey;
await act(async () => { await act(async () => {
getLatest().setText("hello world"); getLatest().editText("hello world");
getLatest().setAttachments([{ kind: "image", metadata: image }]); 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 () => { await act(async () => {
root!.unmount(); root!.unmount();
}); });
@@ -418,7 +432,7 @@ describe("useAgentInputDraft live contract", () => {
}); });
await act(async () => { await act(async () => {
getLatest().setText("with attachment"); getLatest().editText("with attachment");
getLatest().setAttachments([{ kind: "image", metadata: image }]); getLatest().setAttachments([{ kind: "image", metadata: image }]);
}); });
@@ -500,7 +514,7 @@ describe("useAgentInputDraft live contract", () => {
}); });
await act(async () => { await act(async () => {
getLatest().setText("queued message"); getLatest().editText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: image }]); getLatest().setAttachments([{ kind: "image", metadata: image }]);
}); });
@@ -554,7 +568,7 @@ describe("useAgentInputDraft live contract", () => {
}); });
await act(async () => { await act(async () => {
getLatest().setText("queued message"); getLatest().editText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]); getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
}); });
@@ -570,7 +584,7 @@ describe("useAgentInputDraft live contract", () => {
}); });
await act(async () => { await act(async () => {
getLatest().setText("draft again"); getLatest().editText("draft again");
}); });
await act(async () => { await act(async () => {
+17 -3
View File
@@ -52,7 +52,9 @@ type DraftComposerState = UseAgentFormStateResult & {
export interface AgentInputDraft { export interface AgentInputDraft {
text: string; text: string;
setText: (text: string) => void; editText: (text: string) => void;
replaceText: (text: string) => void;
textReplacementKey: string;
attachments: UserComposerAttachment[]; attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void; setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void; clear: (lifecycle: "sent" | "abandoned") => void;
@@ -84,6 +86,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0, (state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
); );
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null); const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const [textReplacementRevision, setTextReplacementRevision] = useState(0);
const text = draft?.text ?? ""; const text = draft?.text ?? "";
const attachments = draft?.attachments ?? []; const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey; const isHydrated = hydratedDraftKey === draftKey;
@@ -107,13 +110,21 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey], [draftKey],
); );
const setText = useCallback( const editText = useCallback(
(nextText: string) => { (nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText })); saveDraft((current) => ({ ...current, text: nextText }));
}, },
[saveDraft], [saveDraft],
); );
const replaceText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
setTextReplacementRevision((revision) => revision + 1);
},
[saveDraft],
);
const setAttachments = useCallback( const setAttachments = useCallback(
(updater: AttachmentUpdater) => { (updater: AttachmentUpdater) => {
saveDraft((current) => ({ saveDraft((current) => ({
@@ -136,6 +147,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
void (async () => { void (async () => {
await useDraftStore.getState().hydrateDraftInput({ draftKey }); await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) { if (!cancelled) {
setTextReplacementRevision((revision) => revision + 1);
setHydratedDraftKey(draftKey); setHydratedDraftKey(draftKey);
} }
})(); })();
@@ -264,7 +276,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
return { return {
text, text,
setText, editText,
replaceText,
textReplacementKey: `${draftKey}:${textReplacementRevision}`,
attachments, attachments,
setAttachments, setAttachments,
clear, clear,
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
const draftOnSetFeature = composerState.agentControls.onSetFeature; const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear; const clearDraftInput = draftInput.clear;
const setDraftText = draftInput.setText; const replaceDraftText = draftInput.replaceText;
const setDraftAttachments = draftInput.setAttachments; const setDraftAttachments = draftInput.setAttachments;
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => { const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null; const pending = state.pendingByDraftId[draftId] ?? null;
@@ -587,7 +587,7 @@ export function WorkspaceDraftAgentTab({
return; return;
} }
autoSubmitKeyRef.current = submitKey; autoSubmitKeyRef.current = submitKey;
setDraftText(""); replaceDraftText("");
setDraftAttachments([]); setDraftAttachments([]);
const preparedAttempt = const preparedAttempt =
initialCreateAttempt?.clientMessageId === submission.clientMessageId initialCreateAttempt?.clientMessageId === submission.clientMessageId
@@ -604,7 +604,7 @@ export function WorkspaceDraftAgentTab({
cwd: submission.cwd, cwd: submission.cwd,
}); });
void createPromise.catch(() => { void createPromise.catch(() => {
setDraftText(submission.text); replaceDraftText(submission.text);
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments)); setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
autoSubmitKeyRef.current = null; autoSubmitKeyRef.current = null;
}); });
@@ -617,7 +617,7 @@ export function WorkspaceDraftAgentTab({
isReadyForPendingAutoSubmit, isReadyForPendingAutoSubmit,
serverId, serverId,
setDraftAttachments, setDraftAttachments,
setDraftText, replaceDraftText,
workspaceId, workspaceId,
]); ]);
@@ -699,7 +699,8 @@ export function WorkspaceDraftAgentTab({
isSubmitLoading={isSubmitting} isSubmitLoading={isSubmitting}
blurOnSubmit={true} blurOnSubmit={true}
value={draftInput.text} value={draftInput.text}
onChangeText={draftInput.setText} onChangeText={draftInput.editText}
textReplacementKey={draftInput.textReplacementKey}
attachments={draftInput.attachments} attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys} attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
+52 -10
View File
@@ -125,6 +125,7 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon"; import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach"; import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { readClipboardImage } from "./clipboard-image"; import { readClipboardImage } from "./clipboard-image";
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import { import {
appendWorkspaceFileAttachment, appendWorkspaceFileAttachment,
@@ -846,6 +847,7 @@ interface ComposerProps {
blurOnSubmit?: boolean; blurOnSubmit?: boolean;
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
textReplacementKey: string;
attachments: UserComposerAttachment[]; attachments: UserComposerAttachment[];
attachmentScopeKeys?: readonly string[]; attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
@@ -1054,6 +1056,7 @@ export function Composer({
blurOnSubmit = false, blurOnSubmit = false,
value, value,
onChangeText, onChangeText,
textReplacementKey,
attachments, attachments,
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS, attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment, onOpenWorkspaceAttachment,
@@ -1151,6 +1154,7 @@ export function Composer({
const [cursorIndex, setCursorIndex] = useState(0); const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false);
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
@@ -1163,6 +1167,17 @@ export function Composer({
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, `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( const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => { (command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) { if (command.execution !== "immediate" || !onClientSlashCommand) {
@@ -1173,7 +1188,7 @@ export function Composer({
messageInputRef.current?.blur(); messageInputRef.current?.blur();
} }
clearDraft("sent"); clearDraft("sent");
setUserInput(""); replaceUserInput("");
setSelectedAttachments([]); setSelectedAttachments([]);
resetSuppression(); resetSuppression();
setSendError(null); setSendError(null);
@@ -1194,14 +1209,14 @@ export function Composer({
onClientSlashCommand, onClientSlashCommand,
resetSuppression, resetSuppression,
setSelectedAttachments, setSelectedAttachments,
setUserInput, replaceUserInput,
], ],
); );
const autocomplete = useAgentAutocomplete({ const autocomplete = useAgentAutocomplete({
userInput, userInput,
cursorIndex, cursorIndex,
setUserInput, setUserInput: replaceUserInput,
serverId, serverId,
agentId, agentId,
draftConfig: commandDraftConfig, draftConfig: commandDraftConfig,
@@ -1356,7 +1371,7 @@ export function Composer({
}); });
if (!result.queued) return; if (!result.queued) return;
setUserInput(""); replaceUserInput("");
setSelectedAttachments([]); setSelectedAttachments([]);
resetSuppression(); resetSuppression();
clearSentAttachments(queuedAttachments); clearSentAttachments(queuedAttachments);
@@ -1367,7 +1382,7 @@ export function Composer({
queueWriter, queueWriter,
resetSuppression, resetSuppression,
setSelectedAttachments, setSelectedAttachments,
setUserInput, replaceUserInput,
], ],
); );
@@ -1398,7 +1413,7 @@ export function Composer({
await submitMessage(submitText, submitAttachments); await submitMessage(submitText, submitAttachments);
}, },
clearDraft, clearDraft,
setUserInput, setUserInput: replaceUserInput,
setAttachments: (nextAttachments) => { setAttachments: (nextAttachments) => {
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments)); setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
}, },
@@ -1423,7 +1438,7 @@ export function Composer({
isAgentRunning, isAgentRunning,
queueMessage, queueMessage,
setSelectedAttachments, setSelectedAttachments,
setUserInput, replaceUserInput,
submitBehavior, submitBehavior,
submitMessage, submitMessage,
t, t,
@@ -1484,6 +1499,30 @@ export function Composer({
} }
}, [addImages, t]); }, [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( const uploadPickedFiles = useCallback(
async (files: PickedFile[]) => { async (files: PickedFile[]) => {
if (files.length === 0) return; if (files.length === 0) return;
@@ -1686,10 +1725,10 @@ export function Composer({
queue: queueWriter, queue: queueWriter,
}); });
if (!result) return; if (!result) return;
setUserInput(result.text); replaceUserInput(result.text);
setSelectedAttachments(result.attachments); setSelectedAttachments(result.attachments);
}, },
[agentId, queueWriter, setSelectedAttachments, setUserInput], [agentId, queueWriter, replaceUserInput, setSelectedAttachments],
); );
const handleSendQueuedNow = useCallback( const handleSendQueuedNow = useCallback(
@@ -2073,7 +2112,8 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null); const messageInputContainerRef = useRef<View>(null);
const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile; const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
const isSubmitDisabled = const isSubmitDisabled =
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving); isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
@@ -2142,6 +2182,7 @@ export function Composer({
attachmentMenuItems={attachmentMenuItems} attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef} onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages} onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client} client={client}
isReadyForDictation={isDictationReady} isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder} placeholder={messagePlaceholder}
@@ -2167,6 +2208,7 @@ export function Composer({
attachmentSlot={attachmentTray} attachmentSlot={attachmentTray}
inputMode={inputMode} inputMode={inputMode}
readOnly={readOnly} readOnly={readOnly}
textReplacementKey={textReplacementKey}
submitLabel={submitLabel} submitLabel={submitLabel}
/> />
<Combobox <Combobox
+63 -34
View File
@@ -56,6 +56,9 @@ import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./height-mirror"; import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; 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 { import {
resolveSendTooltipLabel, resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel, resolveSubmitAccessibilityLabel,
@@ -105,6 +108,7 @@ export interface MessageInputProps {
attachmentMenuItems: AttachmentMenuItem[]; attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef?: (node: View | null) => void; onAttachButtonRef?: (node: View | null) => void;
onAddImages?: (images: ImageAttachment[]) => void; onAddImages?: (images: ImageAttachment[]) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
client: DaemonClient | null; client: DaemonClient | null;
/** Dictation start gate from host runtime (socket connected + directory ready). */ /** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean; isReadyForDictation?: boolean;
@@ -147,6 +151,8 @@ export interface MessageInputProps {
inputMode?: ComposerInputMode; inputMode?: ComposerInputMode;
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */ /** Renders `value` as static text on the same surface, for content there is nothing to type into. */
readOnly?: boolean; 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. */ /** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
submitLabel?: string; submitLabel?: string;
} }
@@ -154,6 +160,8 @@ export interface MessageInputProps {
export interface MessageInputRef { export interface MessageInputRef {
focus: () => void; focus: () => void;
blur: () => void; blur: () => void;
getText: () => string;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/** /**
* Web-only: return the underlying DOM element for focus assertions/retries. * Web-only: return the underlying DOM element for focus assertions/retries.
@@ -397,12 +405,9 @@ function handleDesktopKeyPressImpl(
ctx.handleDefaultSendAction(); ctx.handleDefaultSendAction();
} }
function getTextInputNativeElement( function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTMLElement | null {
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): HTMLElement | null {
if (!current) return null; if (!current) return null;
const handle = current as TextInput & { getNativeRef?: () => unknown }; const native = typeof current.getNativeRef === "function" ? current.getNativeRef() : current;
const native = typeof handle.getNativeRef === "function" ? handle.getNativeRef() : current;
return native instanceof HTMLElement ? native : null; return native instanceof HTMLElement ? native : null;
} }
@@ -478,9 +483,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
} }
function useAutoFocusOnWebEffect( function useAutoFocusOnWebEffect(
textInputRef: React.MutableRefObject< textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>,
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>,
autoFocus: boolean, autoFocus: boolean,
autoFocusKey: string | undefined, autoFocusKey: string | undefined,
): void { ): void {
@@ -588,8 +591,8 @@ function FocusHint({
interface ComposerTextSurfaceProps { interface ComposerTextSurfaceProps {
readOnly: boolean; readOnly: boolean;
value: string; value: string;
textInputRef: React.Ref<TextInput>; textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: React.ComponentProps<typeof ThemedTextInput>["style"]; textInputStyle: React.ComponentProps<typeof TextInput>["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"]; readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
placeholder: string; placeholder: string;
accessibilityLabel: string; accessibilityLabel: string;
@@ -602,6 +605,8 @@ interface ComposerTextSurfaceProps {
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void; onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined; onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void; onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
onPasteError: (message: string) => void;
focusHintVisible: boolean; focusHintVisible: boolean;
focusInputKeys: ShortcutChord | null | undefined; focusInputKeys: ShortcutChord | null | undefined;
focusHintLabel: string; focusHintLabel: string;
@@ -624,13 +629,12 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
} }
return ( return (
<View style={styles.textInputScrollWrapper}> <View style={styles.textInputScrollWrapper}>
<ThemedTextInput <ComposerTextInput
ref={props.textInputRef} ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET} dataSet={COMPOSER_INPUT_DATASET}
value={props.value} text={props.value}
onChangeText={props.onChangeText} onChangeText={props.onChangeText}
placeholder={props.placeholder} placeholder={props.placeholder}
uniProps={textInputPlaceholderColorMapping}
accessibilityLabel={props.accessibilityLabel} accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus} onFocus={props.onFocus}
onBlur={props.onBlur} onBlur={props.onBlur}
@@ -641,6 +645,8 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
editable={props.editable} editable={props.editable}
onKeyPress={props.onKeyPress} onKeyPress={props.onKeyPress}
onSelectionChange={props.onSelectionChange} onSelectionChange={props.onSelectionChange}
onPasteImages={props.onPasteImages}
onPasteError={props.onPasteError}
autoFocus={props.autoFocus} autoFocus={props.autoFocus}
/> />
<FocusHint <FocusHint
@@ -904,7 +910,7 @@ interface QueueMessageContext {
attachments: ComposerAttachment[]; attachments: ComposerAttachment[];
cwd: string; cwd: string;
onQueue: ((payload: MessagePayload) => void) | undefined; onQueue: ((payload: MessagePayload) => void) | undefined;
onChangeText: (text: string) => void; replaceText: (text: string) => void;
onMinimizeHeight: () => void; onMinimizeHeight: () => void;
} }
@@ -913,7 +919,7 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
const trimmed = ctx.value.trim(); const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return; if (!trimmed && ctx.attachments.length === 0) return;
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.onChangeText(""); ctx.replaceText("");
ctx.onMinimizeHeight(); ctx.onMinimizeHeight();
} }
@@ -965,9 +971,7 @@ function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v; return typeof v === "object" && v !== null && "scrollHeight" in v;
} }
function getWebTextAreaImpl( function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHandle | null {
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): TextAreaHandle | null {
if (!current) return null; if (!current) return null;
const candidate = current as { getNativeRef?: () => unknown }; const candidate = current as { getNativeRef?: () => unknown };
if (typeof candidate.getNativeRef === "function") { if (typeof candidate.getNativeRef === "function") {
@@ -1019,6 +1023,7 @@ interface ResolvedMessageInputProps {
attachmentMenuItems: AttachmentMenuItem[]; attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef: ((node: View | null) => void) | undefined; onAttachButtonRef: ((node: View | null) => void) | undefined;
onAddImages: ((images: ImageAttachment[]) => void) | undefined; onAddImages: ((images: ImageAttachment[]) => void) | undefined;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
client: DaemonClient | null; client: DaemonClient | null;
isReadyForDictation: boolean | undefined; isReadyForDictation: boolean | undefined;
placeholder: string | undefined; placeholder: string | undefined;
@@ -1044,6 +1049,7 @@ interface ResolvedMessageInputProps {
attachmentSlot: React.ReactNode; attachmentSlot: React.ReactNode;
inputMode: ComposerInputMode; inputMode: ComposerInputMode;
readOnly: boolean; readOnly: boolean;
textReplacementKey: string;
submitLabel: string | undefined; submitLabel: string | undefined;
} }
@@ -1065,6 +1071,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentMenuItems: props.attachmentMenuItems, attachmentMenuItems: props.attachmentMenuItems,
onAttachButtonRef: props.onAttachButtonRef, onAttachButtonRef: props.onAttachButtonRef,
onAddImages: props.onAddImages, onAddImages: props.onAddImages,
onPasteImages: props.onPasteImages,
client: props.client, client: props.client,
isReadyForDictation: props.isReadyForDictation, isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder, placeholder: props.placeholder,
@@ -1090,6 +1097,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentSlot: props.attachmentSlot, attachmentSlot: props.attachmentSlot,
inputMode: props.inputMode ?? "chat", inputMode: props.inputMode ?? "chat",
readOnly: props.readOnly ?? false, readOnly: props.readOnly ?? false,
textReplacementKey: props.textReplacementKey,
submitLabel: props.submitLabel, submitLabel: props.submitLabel,
}; };
} }
@@ -1119,6 +1127,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentMenuItems, attachmentMenuItems,
onAttachButtonRef, onAttachButtonRef,
onAddImages, onAddImages,
onPasteImages,
client, client,
isReadyForDictation, isReadyForDictation,
placeholder, placeholder,
@@ -1144,6 +1153,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentSlot, attachmentSlot,
inputMode, inputMode,
readOnly, readOnly,
textReplacementKey,
submitLabel, submitLabel,
} = resolveMessageInputProps(props); } = resolveMessageInputProps(props);
const mode = resolveComposerInputMode(inputMode); const mode = resolveComposerInputMode(inputMode);
@@ -1161,18 +1171,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef<View | null>(null); const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null); const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>( const textInputRef = useRef<ComposerTextInputHandle | null>(null);
null,
);
const isInputFocusedRef = useRef(false); 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, () => ({ useImperativeHandle(ref, () => ({
focus: () => { focus: () => {
textInputRef.current?.focus(); textInputRef.current?.focus();
}, },
blur: () => { blur: () => {
textInputRef.current?.blur?.(); textInputRef.current?.blur();
}, },
getText: () => textInputRef.current?.getText() ?? valueRef.current,
replaceText,
runKeyboardAction: (action) => runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, { runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(), focusInput: () => textInputRef.current?.focus(),
@@ -1191,7 +1212,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
})); }));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const sendAfterTranscriptRef = useRef(false); const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore( const serverInfo = useSessionStore(
useCallback( useCallback(
(state) => { (state) => {
@@ -1205,8 +1225,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
); );
useEffect(() => { useEffect(() => {
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey;
valueRef.current = value; valueRef.current = value;
}, [value]); textInputRef.current?.replaceText(value);
}, [textReplacementKey, value]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1226,13 +1249,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning, isAgentRunning,
onQueue, onQueue,
onSubmit, onSubmit,
onChangeText, replaceText,
attachments, attachments,
cwd, cwd,
autoSend, autoSend,
}); });
}, },
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior], [replaceText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
); );
const handleDictationError = useCallback( const handleDictationError = useCallback(
@@ -1413,7 +1436,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
() => () =>
sendMessageImpl({ sendMessageImpl({
value: valueRef.current, value: textInputRef.current?.getText() ?? valueRef.current,
attachments, attachments,
hasExternalContent, hasExternalContent,
allowEmptySubmit, allowEmptySubmit,
@@ -1438,14 +1461,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleQueueMessage = useCallback( const handleQueueMessage = useCallback(
() => () =>
queueMessageImpl({ queueMessageImpl({
value: valueRef.current, value: textInputRef.current?.getText() ?? valueRef.current,
attachments, attachments,
cwd, cwd,
onQueue, onQueue,
onChangeText, replaceText,
onMinimizeHeight: minimizeInputHeight, onMinimizeHeight: minimizeInputHeight,
}), }),
[attachments, cwd, onQueue, onChangeText, minimizeInputHeight], [attachments, cwd, onQueue, replaceText, minimizeInputHeight],
); );
const handleDefaultSendAction = useCallback(() => { const handleDefaultSendAction = useCallback(() => {
@@ -1618,6 +1641,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onFocusChange?.(false); onFocusChange?.(false);
}, [onFocusChange]); }, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
console.error("[MessageInput] Native paste failed:", message);
toast.error(t("composer.errors.pasteImageFailed"));
},
[t, toast],
);
const attachButtonStyle = useCallback( const attachButtonStyle = useCallback(
({ hovered }: { hovered?: boolean }) => [ ({ hovered }: { hovered?: boolean }) => [
styles.attachButton, styles.attachButton,
@@ -1732,6 +1763,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value} focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
focusInputKeys={focusInputKeys} focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", { focusHintLabel={t("composer.input.focusHint", {
@@ -1982,11 +2015,7 @@ const ThemedMicOff = withUnistyles(MicOff);
const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft); const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); 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", defaultSendBehavior: "interrupt",
isAgentRunning: false, isAgentRunning: false,
onQueue: undefined, onQueue: undefined,
onChangeText: (text) => actions.push(`change:${text}`), replaceText: (text) => actions.push(`replace:${text}`),
onSubmit: (payload) => actions.push(`submit:${payload.text}`), onSubmit: (payload) => actions.push(`submit:${payload.text}`),
attachments: [], attachments: [],
cwd: "/repo", cwd: "/repo",
@@ -181,7 +181,7 @@ describe("dictation transcript behavior", () => {
}); });
expect(actions).toEqual([ expect(actions).toEqual([
"change:typed context spoken prompt", "replace:typed context spoken prompt",
"submit:typed context spoken prompt", "submit:typed context spoken prompt",
]); ]);
}); });
+4 -4
View File
@@ -52,7 +52,7 @@ interface DictationTranscriptContext {
isAgentRunning: boolean; isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined; onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmit: (payload: MessagePayload) => void; onSubmit: (payload: MessagePayload) => void;
onChangeText: (text: string) => void; replaceText: (text: string) => void;
attachments: MessagePayload["attachments"]; attachments: MessagePayload["attachments"];
cwd: string; cwd: string;
autoSend: boolean; autoSend: boolean;
@@ -64,15 +64,15 @@ export function applyDictationTranscript(text: string, ctx: DictationTranscriptC
const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`; const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`;
if (!ctx.autoSend) { if (!ctx.autoSend) {
ctx.onChangeText(nextValue); ctx.replaceText(nextValue);
return; return;
} }
ctx.onChangeText(nextValue); ctx.replaceText(nextValue);
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) { if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.onChangeText(""); ctx.replaceText("");
return; 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;
}
+1
View File
@@ -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,
};
});
}
+15 -5
View File
@@ -1228,7 +1228,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
// when only toast state changes (which does not affect any draft field). // when only toast state changes (which does not affect any draft field).
const { const {
text, text,
setText, editText,
replaceText,
textReplacementKey,
attachments, attachments,
setAttachments, setAttachments,
clear, clear,
@@ -1239,7 +1241,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const agentInputDraft = useMemo( const agentInputDraft = useMemo(
(): AgentInputDraft => ({ (): AgentInputDraft => ({
text, text,
setText, editText,
replaceText,
textReplacementKey,
attachments, attachments,
setAttachments, setAttachments,
clear, clear,
@@ -1249,7 +1253,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}), }),
[ [
text, text,
setText, editText,
replaceText,
textReplacementKey,
attachments, attachments,
setAttachments, setAttachments,
clear, clear,
@@ -1296,7 +1302,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>; const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
return ( return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}> <RewindComposerRestoreProvider
text={agentInputDraft.text}
setText={agentInputDraft.replaceText}
>
<View style={styles.root}> <View style={styles.root}>
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}> <FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
{contentContainer} {contentContainer}
@@ -1629,7 +1638,8 @@ function ActiveAgentComposer({
externalKeyboardShift externalKeyboardShift
isPaneFocused={isPaneFocused} isPaneFocused={isPaneFocused}
value={agentInputDraft.text} value={agentInputDraft.text}
onChangeText={agentInputDraft.setText} onChangeText={agentInputDraft.editText}
textReplacementKey={agentInputDraft.textReplacementKey}
attachments={agentInputDraft.attachments} attachments={agentInputDraft.attachments}
attachmentScopeKeys={attachmentScopeKeys} attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
@@ -2309,6 +2309,7 @@ export function NewWorkspaceScreen({
blurOnSubmit={true} blurOnSubmit={true}
value={terminalComposerValue} value={terminalComposerValue}
onChangeText={setTerminalPromptText} onChangeText={setTerminalPromptText}
textReplacementKey={launchFocusKey}
attachments={NO_TERMINAL_ATTACHMENTS} attachments={NO_TERMINAL_ATTACHMENTS}
onChangeAttachments={noopChangeAttachments} onChangeAttachments={noopChangeAttachments}
cwd={selectedSourceDirectory ?? ""} cwd={selectedSourceDirectory ?? ""}
@@ -2332,7 +2333,8 @@ export function NewWorkspaceScreen({
submitBehavior="preserve-and-lock" submitBehavior="preserve-and-lock"
blurOnSubmit={true} blurOnSubmit={true}
value={chatDraft.text} value={chatDraft.text}
onChangeText={chatDraft.setText} onChangeText={chatDraft.editText}
textReplacementKey={chatDraft.textReplacementKey}
attachments={chatDraft.attachments} attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys} attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments} onChangeAttachments={chatDraft.setAttachments}