diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 45fa8b137..ddbe6bd11 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-i5PbVUe2Ec+GtghV9IpCJQJ9hcUT5hFhmxneNvoD584= +sha256-qPIKchcbGkJHFSqgeM/BUSmu7TiVfKy3IEgUcMNNJYc= diff --git a/package-lock.json b/package-lock.json index f6a403bfe..7a691c1df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7762,6 +7762,16 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", @@ -36108,6 +36118,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", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index db7120882..1e1399795 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -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, [ diff --git a/packages/app/package.json b/packages/app/package.json index b46400887..89e183cf6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/plugins/with-paste-input.js b/packages/app/plugins/with-paste-input.js new file mode 100644 index 000000000..76be6a8fc --- /dev/null +++ b/packages/app/plugins/with-paste-input.js @@ -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 "; +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; diff --git a/packages/app/plugins/with-paste-input.test.ts b/packages/app/plugins/with-paste-input.test.ts new file mode 100644 index 000000000..fdc271d7a --- /dev/null +++ b/packages/app/plugins/with-paste-input.test.ts @@ -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 "); + 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); + }); +}); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 1985a8361..0ca4b2f8b 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -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} diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index ff3797466..ed7a332fb 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -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 () => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 1a6875517..a579064e0 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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(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, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index e8c992b4e..0293d7c3e 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 02e8d6d1b..f2b6c6788 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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(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(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} /> 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, autoFocus: boolean, autoFocusKey: string | undefined, ): void { @@ -588,8 +591,8 @@ function FocusHint({ interface ComposerTextSurfaceProps { readOnly: boolean; value: string; - textInputRef: React.Ref; - textInputStyle: React.ComponentProps["style"]; + textInputRef: React.Ref; + textInputStyle: React.ComponentProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; placeholder: string; accessibilityLabel: string; @@ -602,6 +605,8 @@ interface ComposerTextSurfaceProps { onContentSizeChange: (event: NativeSyntheticEvent) => void; onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined; onSelectionChange: (event: NativeSyntheticEvent) => 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 ( - 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( attachmentMenuItems, onAttachButtonRef, onAddImages, + onPasteImages, client, isReadyForDictation, placeholder, @@ -1144,6 +1153,7 @@ export const MessageInput = forwardRef( attachmentSlot, inputMode, readOnly, + textReplacementKey, submitLabel, } = resolveMessageInputProps(props); const mode = resolveComposerInputMode(inputMode); @@ -1161,18 +1171,29 @@ export const MessageInput = forwardRef( const [isInputFocused, setIsInputFocused] = useState(false); const rootRef = useRef(null); const inputWrapperRef = useRef(null); - const textInputRef = useRef unknown }) | null>( - null, - ); + const textInputRef = useRef(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( })); 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( ); 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( 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( const handleSendMessage = useCallback( () => sendMessageImpl({ - value: valueRef.current, + value: textInputRef.current?.getText() ?? valueRef.current, attachments, hasExternalContent, allowEmptySubmit, @@ -1438,14 +1461,14 @@ export const MessageInput = forwardRef( 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( 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( 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, -}); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index d6265b063..2b0c216a5 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -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", ]); }); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 733a30cc4..15ea7199e 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -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; } diff --git a/packages/app/src/composer/input/text-input-types.ts b/packages/app/src/composer/input/text-input-types.ts new file mode 100644 index 000000000..5963057bc --- /dev/null +++ b/packages/app/src/composer/input/text-input-types.ts @@ -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; +} diff --git a/packages/app/src/composer/input/text-input.d.ts b/packages/app/src/composer/input/text-input.d.ts new file mode 100644 index 000000000..5043ffbeb --- /dev/null +++ b/packages/app/src/composer/input/text-input.d.ts @@ -0,0 +1 @@ +export * from "./text-input.native"; diff --git a/packages/app/src/composer/input/text-input.native.tsx b/packages/app/src/composer/input/text-input.native.tsx new file mode 100644 index 000000000..875c983d3 --- /dev/null +++ b/packages/app/src/composer/input/text-input.native.tsx @@ -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( + function ComposerTextInputNative( + { text, onChangeText, onPasteImages, onPasteError, ...props }, + ref, + ) { + const inputRef = useRef(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 ( + + ); + }, +); diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx new file mode 100644 index 000000000..10fc2696a --- /dev/null +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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(["你好"]); + }); +}); diff --git a/packages/app/src/composer/input/text-input.web.tsx b/packages/app/src/composer/input/text-input.web.tsx new file mode 100644 index 000000000..5b46172ee --- /dev/null +++ b/packages/app/src/composer/input/text-input.web.tsx @@ -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( + function ComposerTextInputWeb( + { text, onChangeText, onPasteImages: _, onPasteError: __, ...props }, + ref, + ) { + const inputRef = useRef(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 | 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 ( + + ); + }, +); diff --git a/packages/app/src/composer/native-pasted-image.test.ts b/packages/app/src/composer/native-pasted-image.test.ts new file mode 100644 index 000000000..59d2954a1 --- /dev/null +++ b/packages/app/src/composer/native-pasted-image.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/composer/native-pasted-image.ts b/packages/app/src/composer/native-pasted-image.ts new file mode 100644 index 000000000..34f3d822b --- /dev/null +++ b/packages/app/src/composer/native-pasted-image.ts @@ -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, + }; + }); +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 24a85b307..59eb41112 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 = {streamContent}; return ( - + {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} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index d887fa128..2b75b1b2d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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}