From 467d3f64a97ec6a9b4d973db6ec1e18d1f832a52 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:05:02 -0400 Subject: [PATCH 1/2] feat(mobile): queue/send from the keyboard Return key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #8. Closes #7. Mobile queueing already worked end-to-end (every send enqueues to the thread outbox, which sends-when-idle and holds-when-busy; the primary button already relabels to "Queue" while busy and a queueCount indicator exists). The only gap was the keyboard: the soft-keyboard Return inserted a newline, and submit was only wired to hardware Command-Return on iOS (and not at all on Android). - iOS: intercept a bare "\n" in the shouldChangeText delegate and fire onComposerSubmit (soft keyboard or hardware Return). Exact single "\n" only, so a multi-line paste ("a\nb") is unaffected; Command-Return keyCommand intact. - Android: wire onComposerSubmit for the first time — an onComposerSubmit event, plus a plain-Enter interception on both the key-event path (onKeyDown) and the IME commitText path, with Shift+Enter still inserting a newline. Multi-line input type preserved. - RN: forward onSubmit -> onComposerSubmit through the Android wrapper (was dropped); iOS wrapper already forwarded it. - Composer: Return routes to the existing handleSend (queue-when-busy / send-when-idle), so queueCount updates the same way as the button. Add a dedicated "Insert line break" toolbar button (inserts "\n" at the caret via the same replaceTextRange helper used for @file / skill tokens) as the cross-platform newline affordance now that Return submits. - Extract resolveComposerSendLabel as a pure, unit-tested helper. Trade-off: hardware plain-Return now submits instead of inserting a newline (matching desktop Enter=submit); the newline button and Shift+Return cover multiline. Tests: composerSendLabel (Send/Queue matrix) and replaceTextRange newline cases. Native keyboard behavior verified on the iOS Simulator; Android soft-keyboard paths need on-device QA (noted on the PR). Co-Authored-By: Claude Opus 4.8 --- .../T3ComposerEditorModule.kt | 1 + .../t3composereditor/T3ComposerEditorView.kt | 51 +++++++++++++++++++ .../ios/T3ComposerEditorView.swift | 10 ++++ apps/mobile/src/components/AppSymbol.tsx | 2 + .../src/features/threads/ThreadComposer.tsx | 30 +++++++++-- .../threads/composerSendLabel.test.ts | 45 ++++++++++++++++ .../src/features/threads/composerSendLabel.ts | 22 ++++++++ .../src/native/T3ComposerEditor.native.tsx | 3 ++ .../src/native/T3ComposerEditor.types.ts | 6 ++- packages/shared/src/composerTrigger.test.ts | 32 +++++++++++- 10 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/threads/composerSendLabel.test.ts create mode 100644 apps/mobile/src/features/threads/composerSendLabel.ts diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index e11181e81a9..6b8366b44a5 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -54,6 +54,7 @@ class T3ComposerEditorModule : Module() { "onComposerSelectionChange", "onComposerFocus", "onComposerBlur", + "onComposerSubmit", "onComposerPasteImages", "onComposerContentSizeChange", ) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index e13c0a52189..20d77aa193d 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -13,7 +13,11 @@ import android.text.Spanned import android.text.TextWatcher import android.text.style.ReplacementSpan import android.view.Gravity +import android.view.KeyEvent import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper import android.view.inputmethod.InputMethodManager import android.widget.EditText import expo.modules.kotlin.AppContext @@ -31,6 +35,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private val onComposerSelectionChange by EventDispatcher() private val onComposerFocus by EventDispatcher() private val onComposerBlur by EventDispatcher() + private val onComposerSubmit by EventDispatcher() private val onComposerPasteImages by EventDispatcher() private val onComposerContentSizeChange by EventDispatcher() private var applyingNativeValue = false @@ -65,6 +70,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.pasteImagesListener = { uris -> onComposerPasteImages(mapOf("uris" to uris)) } + // A bare Enter (no Shift) submits — mirrors iOS and desktop Enter=submit. + // Empty payload matches the iOS `onComposerSubmit([:])` contract. + editor.submitListener = { + onComposerSubmit(emptyMap()) + } editor.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { onComposerFocus(emptyMap()) @@ -450,6 +460,47 @@ private fun parseTokens(value: String): List = try { private class SelectionAwareEditText(context: Context) : EditText(context) { var selectionListener: ((Int, Int) -> Unit)? = null var pasteImagesListener: ((List) -> Unit)? = null + var submitListener: (() -> Unit)? = null + + private fun isPlainEnter(keyCode: Int, event: KeyEvent?): Boolean { + val isEnter = keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + // Shift+Enter still inserts a newline; only an unmodified Enter submits. + return isEnter && event?.isShiftPressed != true + } + + // Hardware keyboards (and soft keyboards that route Enter as a key event) + // land here. Fire submit on the down edge and consume both edges so the + // multi-line field never inserts the newline. + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (isPlainEnter(keyCode, event)) { + submitListener?.invoke() + return true + } + return super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + if (isPlainEnter(keyCode, event)) { + return true + } + return super.onKeyUp(keyCode, event) + } + + // Soft keyboards that commit the newline as text (rather than a key event) + // land here; an exact "\n" commit means a bare Return, so submit instead. + // A multi-line paste ("a\nb") is unaffected because it is not exactly "\n". + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val base = super.onCreateInputConnection(outAttrs) ?: return null + return object : InputConnectionWrapper(base, false) { + override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { + if (text?.toString() == "\n") { + submitListener?.invoke() + return true + } + return super.commitText(text, newCursorPosition) + } + } + } override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 180da203008..dfab647f9c9 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -496,6 +496,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { shouldChangeTextIn range: NSRange, replacementText text: String ) -> Bool { + // A bare Return (soft keyboard or hardware) submits — matching desktop + // Enter=submit and the composer's queue-when-busy / send-when-idle outbox. + // Only an exact single "\n" is intercepted, so a multi-line paste like + // "a\nb" still inserts normally. The newline button inserts "\n" through + // the controlled value (setText), never this typing path. Hardware + // Command-Return keeps flowing through the UIKeyCommand above. + if text == "\n" { + onComposerSubmit([:]) + return false + } restoreBaseTypingAttributes() return true } diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index ac813bdbe0a..4e75e3a4f2b 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -23,6 +23,7 @@ import { IconCircleCheck, IconCircleXFilled, IconCopy, + IconCornerDownLeft, IconDeviceDesktop, IconDots, IconDotsCircleHorizontal, @@ -122,6 +123,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { play: IconPlayerPlay, plus: IconPlus, "qrcode.viewfinder": IconQrcode, + return: IconCornerDownLeft, "point.3.connected.trianglepath.dotted": IconNetwork, "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, safari: IconExternalLink, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 59af7199af7..4a6c2915c27 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -69,6 +69,7 @@ import { } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { resolveComposerSendLabel } from "./composerSendLabel"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -309,10 +310,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; - const sendLabel = - props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 - ? "Queue" - : "Send"; + const sendLabel = resolveComposerSendLabel({ + connectionState: props.connectionState, + activeThreadBusy: props.activeThreadBusy, + queueCount: props.queueCount, + }); const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; @@ -536,6 +538,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.id, props.selectedThread.title, ]); + // Return now submits (queues-when-busy / sends-when-idle), matching desktop + // Enter=submit. The newline button is the cross-platform multiline escape + // hatch: it inserts "\n" at the caret through the same insert-at-selection + // mechanism used for @file / skill tokens (replaceTextRange + selection). + const handleInsertNewline = useCallback(() => { + const result = replaceTextRange( + draftMessage, + composerSelection.start, + composerSelection.end, + "\n", + ); + setComposerSelection({ start: result.cursor, end: result.cursor }); + onChangeDraftMessage(result.text); + }, [composerSelection.start, composerSelection.end, draftMessage, onChangeDraftMessage]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; @@ -865,6 +881,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPress={() => void props.onPickDraftImages()} showChevron={false} /> + handleModelMenuAction(nativeEvent.event)} diff --git a/apps/mobile/src/features/threads/composerSendLabel.test.ts b/apps/mobile/src/features/threads/composerSendLabel.test.ts new file mode 100644 index 00000000000..89f3b78dbc5 --- /dev/null +++ b/apps/mobile/src/features/threads/composerSendLabel.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { resolveComposerSendLabel } from "./composerSendLabel"; + +describe("resolveComposerSendLabel", () => { + it("labels Send when connected, idle, and the outbox is empty", () => { + expect( + resolveComposerSendLabel({ + connectionState: "connected", + activeThreadBusy: false, + queueCount: 0, + }), + ).toBe("Send"); + }); + + it("labels Queue while the active thread is busy", () => { + expect( + resolveComposerSendLabel({ + connectionState: "connected", + activeThreadBusy: true, + queueCount: 0, + }), + ).toBe("Queue"); + }); + + it("labels Queue when messages are already queued to send later", () => { + expect( + resolveComposerSendLabel({ + connectionState: "connected", + activeThreadBusy: false, + queueCount: 2, + }), + ).toBe("Queue"); + }); + + it("labels Queue while the environment is not connected", () => { + expect( + resolveComposerSendLabel({ + connectionState: "reconnecting", + activeThreadBusy: false, + queueCount: 0, + }), + ).toBe("Queue"); + }); +}); diff --git a/apps/mobile/src/features/threads/composerSendLabel.ts b/apps/mobile/src/features/threads/composerSendLabel.ts new file mode 100644 index 00000000000..6fbaa42c042 --- /dev/null +++ b/apps/mobile/src/features/threads/composerSendLabel.ts @@ -0,0 +1,22 @@ +import type { RemoteClientConnectionState } from "../../lib/connection"; + +/** + * The composer's send affordance doubles as a queue affordance: every "send" + * enqueues to the thread outbox, which sends-when-idle and holds-when-busy. + * The button label mirrors that decision so the user knows whether pressing it + * (or hitting Return) will dispatch now or queue for later. + * + * Returns "Queue" when the message cannot be delivered immediately — the + * environment is not connected, the active thread is still working, or the + * outbox already holds messages — and "Send" only when it will go out now. + */ +export function resolveComposerSendLabel(input: { + readonly connectionState: RemoteClientConnectionState; + readonly activeThreadBusy: boolean; + readonly queueCount: number; +}): "Send" | "Queue" { + if (input.connectionState !== "connected" || input.activeThreadBusy || input.queueCount > 0) { + return "Queue"; + } + return "Send"; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 84d1c22084f..e1473ee9025 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -71,6 +71,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerSubmit?: () => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -95,6 +96,7 @@ export function ComposerEditor({ onPasteImages, onFocus, onBlur, + onSubmit, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -275,6 +277,7 @@ export function ComposerEditor({ onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerSubmit={onSubmit} /> ); } diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index bfc47ed367b..7638d16e3b5 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -37,6 +37,10 @@ export interface ComposerEditorProps { readonly onPasteImages?: (uris: ReadonlyArray) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; - /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ + /** + * Invoked by the native editor when the composer should submit: any Return + * key with no modifier (soft keyboard or hardware), plus hardware + * Command-Return. Shift-Return still inserts a newline instead of submitting. + */ readonly onSubmit?: () => void; } diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 50c8cd7c208..a10aa8200fb 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { serializeComposerFileLink, serializeComposerMentionPath } from "./composerTrigger.ts"; +import { + replaceTextRange, + serializeComposerFileLink, + serializeComposerMentionPath, +} from "./composerTrigger.ts"; describe("serializeComposerMentionPath", () => { it("keeps simple mention paths unquoted", () => { @@ -41,3 +45,29 @@ describe("serializeComposerFileLink", () => { ); }); }); + +// The mobile composer's newline button inserts "\n" at the caret through this +// same insert-at-selection helper it uses for @file / skill tokens. These cover +// the newline cases specifically so the button's behaviour stays pinned. +describe("replaceTextRange newline insertion", () => { + it("inserts a newline at a collapsed caret in the middle of the text", () => { + expect(replaceTextRange("abcd", 2, 2, "\n")).toEqual({ + text: "ab\ncd", + cursor: 3, + }); + }); + + it("inserts a newline at the end of the text", () => { + expect(replaceTextRange("abc", 3, 3, "\n")).toEqual({ + text: "abc\n", + cursor: 4, + }); + }); + + it("replaces a selection range with a newline", () => { + expect(replaceTextRange("abcdef", 1, 4, "\n")).toEqual({ + text: "a\nef", + cursor: 2, + }); + }); +}); From 511d5c76a9805aa7de21237b770c8bd85f2c157b Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:19:31 -0400 Subject: [PATCH 2/2] fix(mobile): iOS Shift+Return inserts a newline instead of submitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: a bare Return now submits, but Shift+Return was also delivering "\n" through the shouldChangeText delegate and submitting — inconsistent with Android (Shift+Enter inserts a newline) and with this PR's own description. Add a Shift+Return UIKeyCommand that inserts a line break via an `isInsertingHardLineBreak` flag the delegate honors, so hardware Shift+Return matches Android and desktop Shift+Enter. Soft keyboards still use the composer's line-break button. Verified: swiftlint --strict clean; iOS Simulator (iPhone 17 Pro) build succeeds. Co-Authored-By: Claude Opus 4.8 --- .../ios/T3ComposerEditorView.swift | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index dfab647f9c9..c708e7261a1 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -65,6 +65,11 @@ private final class ComposerTextView: UITextView { var onAttributedMutation: (() -> Void)? var onSubmit: (() -> Void)? + // Set only while a Shift+Return hardware key command is programmatically + // inserting a line break, so the delegate lets that "\n" through instead of + // treating it as a submit. Reset synchronously after the insert. + private(set) var isInsertingHardLineBreak = false + override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] let submit = UIKeyCommand( @@ -75,6 +80,17 @@ private final class ComposerTextView: UITextView { submit.discoverabilityTitle = "Send Message" submit.wantsPriorityOverSystemBehavior = true commands.append(submit) + // Shift+Return inserts a newline on a hardware keyboard (mirrors Android and + // desktop Shift+Enter), since a bare Return now submits. Soft keyboards use + // the composer's line-break button instead. + let newline = UIKeyCommand( + input: "\r", + modifierFlags: .shift, + action: #selector(insertHardLineBreak(_:)) + ) + newline.discoverabilityTitle = "Insert Line Break" + newline.wantsPriorityOverSystemBehavior = true + commands.append(newline) return commands } @@ -82,6 +98,12 @@ private final class ComposerTextView: UITextView { onSubmit?() } + @objc private func insertHardLineBreak(_ sender: UIKeyCommand) { + isInsertingHardLineBreak = true + insertText("\n") + isInsertingHardLineBreak = false + } + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(paste(_:)) { let pasteboard = UIPasteboard.general @@ -501,8 +523,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { // Only an exact single "\n" is intercepted, so a multi-line paste like // "a\nb" still inserts normally. The newline button inserts "\n" through // the controlled value (setText), never this typing path. Hardware - // Command-Return keeps flowing through the UIKeyCommand above. - if text == "\n" { + // Command-Return keeps flowing through the UIKeyCommand above, and a + // Shift+Return line break is let through via isInsertingHardLineBreak. + if text == "\n", (textView as? ComposerTextView)?.isInsertingHardLineBreak != true { onComposerSubmit([:]) return false }