From 0e54386c94b4df3d699cc209bfb3186136ef6e5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 12:01:35 -0700 Subject: [PATCH 1/3] fix(mobile): honor queue and steer send choices --- apps/mobile/src/components/OverlayPortal.tsx | 6 +- .../features/threads/ComposerFollowUpMenu.tsx | 129 ++++++++++++++++++ .../src/features/threads/ThreadComposer.tsx | 16 +++ .../features/threads/ThreadDetailScreen.tsx | 70 +++++----- 4 files changed, 184 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx diff --git a/apps/mobile/src/components/OverlayPortal.tsx b/apps/mobile/src/components/OverlayPortal.tsx index 747595174fff..a47410eef445 100644 --- a/apps/mobile/src/components/OverlayPortal.tsx +++ b/apps/mobile/src/components/OverlayPortal.tsx @@ -1,10 +1,8 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import { View } from "react-native"; -// Minimal in-tree portal for Android overlays. AndroidAnchoredMenu projects -// its dropdown here instead of into an RN Modal: a Modal is a separate native -// window, so presenting one moves window focus and closes the soft keyboard — -// which matters for menus anchored to the keyboard-sticky composer pills. +// Menus project into the app's window to preserve editor focus. An RN Modal +// moves window focus on Android; an iOS context menu dismisses the keyboard. type Entries = ReadonlyMap; type Listener = (entries: Entries) => void; diff --git a/apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx b/apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx new file mode 100644 index 000000000000..6beb7a9ed7f7 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx @@ -0,0 +1,129 @@ +import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; +import * as Haptics from "expo-haptics"; +import { useCallback, useRef, useState } from "react"; +import { useFocusEffect } from "@react-navigation/native"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { OverlayPortal } from "../../components/OverlayPortal"; +import { ComposerActionButton } from "../../components/ComposerToolbar"; +import type { ComposerSendPresentation } from "./composerSendPresentation"; + +const MENU_WIDTH = 260; +const MENU_MARGIN = 12; + +/** iOS send choices stay in the editor's window so presenting them preserves keyboard focus. */ +export function ComposerFollowUpMenu(props: { + readonly accessibilityLabel: string; + readonly icon: ComposerSendPresentation["icon"]; + readonly actions: ReadonlyArray<{ + readonly id: ActiveTurnComposerAction; + readonly title: string; + readonly subtitle: string; + }>; + readonly selectedAction: ActiveTurnComposerAction; + readonly onSend: (action?: ActiveTurnComposerAction) => void; +}) { + const anchorRef = useRef(null); + const overlayRef = useRef(null); + const [anchor, setAnchor] = useState<{ right: number; top: number } | null>(null); + const [overlay, setOverlay] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + + const close = useCallback(() => { + setAnchor(null); + setOverlay(null); + }, []); + useFocusEffect(useCallback(() => close, [close])); + const open = useCallback(() => { + anchorRef.current?.measureInWindow((x, y, width) => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + setAnchor({ right: x + width, top: y }); + }); + }, []); + + return ( + <> + + props.onSend()} + onLongPress={open} + /> + + {anchor === null ? null : ( + + + overlayRef.current?.measureInWindow((x, y, width, height) => + setOverlay({ x, y, width, height }), + ) + } + > + + {overlay === null ? null : ( + + {props.actions.map((action) => ( + { + close(); + props.onSend(action.id); + }} + > + + {action.id === props.selectedAction ? ( + + ) : null} + + + {action.title} + {action.subtitle} + + + ))} + + )} + + + )} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 0c9402f6b78c..48c3efb4c0f7 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -98,6 +98,7 @@ import { type ComposerSendPresentation, } from "./composerSendPresentation"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { ComposerFollowUpMenu } from "./ComposerFollowUpMenu"; import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -284,6 +285,21 @@ function SendActionButton(props: { const actions = [presentation.action, presentation.alternate].filter( (action): action is ActiveTurnComposerAction => action !== null, ); + if (Platform.OS === "ios") { + return ( + ({ + id: action, + title: FOLLOW_UP_ACTION_LABEL[action], + subtitle: FOLLOW_UP_ACTION_SUBTITLE[action], + }))} + selectedAction={presentation.action} + onSend={props.onSend} + /> + ); + } return ( Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise; readonly onReconnectEnvironment: () => void; /** Whether the model picker may offer providers other than this thread's. */ readonly canSwitchThreadProvider: boolean; @@ -857,40 +858,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThreadKey, ]); - const handleSendMessage = useCallback(async () => { - const targetThreadKey = selectedThreadKey; - const hasUserMessage = selectedThreadFeed.some( - (entry) => entry.type === "message" && entry.message.role === "user", - ); - const messageId = await props.onSendMessage(); - if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { - return messageId; - } + const handleSendMessage = useCallback( + async (followUp?: ActiveTurnComposerAction) => { + const targetThreadKey = selectedThreadKey; + const hasUserMessage = selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.message.role === "user", + ); + const messageId = await props.onSendMessage(followUp); + if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { + return messageId; + } - // A sent message makes the snapshot stale; a refused send leaves it in place. - clearUsageLimitsFor(targetThreadKey); + // A sent message makes the snapshot stale; a refused send leaves it in place. + clearUsageLimitsFor(targetThreadKey); - setSubmittedMessageId(messageId); - setAnchorMessageId( - resolveThreadFeedSubmissionAnchor({ - currentAnchorMessageId: anchorMessageId, - submittedMessageId: messageId, - hasStartedTurn: props.selectedThread.latestRun !== null, - hasUserMessage, - queuedMessageCount: props.selectedThreadQueueCount, - }), - ); - composerEditorRef.current?.blur(); - return messageId; - }, [ - anchorMessageId, - clearUsageLimitsFor, - props.onSendMessage, - props.selectedThread.latestRun, - props.selectedThreadQueueCount, - selectedThreadFeed, - selectedThreadKey, - ]); + setSubmittedMessageId(messageId); + setAnchorMessageId( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: anchorMessageId, + submittedMessageId: messageId, + hasStartedTurn: props.selectedThread.latestRun !== null, + hasUserMessage, + queuedMessageCount: props.selectedThreadQueueCount, + }), + ); + composerEditorRef.current?.blur(); + return messageId; + }, + [ + anchorMessageId, + clearUsageLimitsFor, + props.onSendMessage, + props.selectedThread.latestRun, + props.selectedThreadQueueCount, + selectedThreadFeed, + selectedThreadKey, + ], + ); const handleEditPendingMessage = useCallback(async (message: QueuedThreadMessage) => { try { From 052b8b06e1fc65041117abf09f631a7bde993e52 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 12:43:08 -0700 Subject: [PATCH 2/3] fix(mobile): retain native send menu --- apps/mobile/src/components/OverlayPortal.tsx | 6 +- .../features/threads/ComposerFollowUpMenu.tsx | 129 ------------------ .../src/features/threads/ThreadComposer.tsx | 16 --- 3 files changed, 4 insertions(+), 147 deletions(-) delete mode 100644 apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx diff --git a/apps/mobile/src/components/OverlayPortal.tsx b/apps/mobile/src/components/OverlayPortal.tsx index a47410eef445..747595174fff 100644 --- a/apps/mobile/src/components/OverlayPortal.tsx +++ b/apps/mobile/src/components/OverlayPortal.tsx @@ -1,8 +1,10 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import { View } from "react-native"; -// Menus project into the app's window to preserve editor focus. An RN Modal -// moves window focus on Android; an iOS context menu dismisses the keyboard. +// Minimal in-tree portal for Android overlays. AndroidAnchoredMenu projects +// its dropdown here instead of into an RN Modal: a Modal is a separate native +// window, so presenting one moves window focus and closes the soft keyboard — +// which matters for menus anchored to the keyboard-sticky composer pills. type Entries = ReadonlyMap; type Listener = (entries: Entries) => void; diff --git a/apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx b/apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx deleted file mode 100644 index 6beb7a9ed7f7..000000000000 --- a/apps/mobile/src/features/threads/ComposerFollowUpMenu.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; -import * as Haptics from "expo-haptics"; -import { useCallback, useRef, useState } from "react"; -import { useFocusEffect } from "@react-navigation/native"; -import { Pressable, View } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; -import { OverlayPortal } from "../../components/OverlayPortal"; -import { ComposerActionButton } from "../../components/ComposerToolbar"; -import type { ComposerSendPresentation } from "./composerSendPresentation"; - -const MENU_WIDTH = 260; -const MENU_MARGIN = 12; - -/** iOS send choices stay in the editor's window so presenting them preserves keyboard focus. */ -export function ComposerFollowUpMenu(props: { - readonly accessibilityLabel: string; - readonly icon: ComposerSendPresentation["icon"]; - readonly actions: ReadonlyArray<{ - readonly id: ActiveTurnComposerAction; - readonly title: string; - readonly subtitle: string; - }>; - readonly selectedAction: ActiveTurnComposerAction; - readonly onSend: (action?: ActiveTurnComposerAction) => void; -}) { - const anchorRef = useRef(null); - const overlayRef = useRef(null); - const [anchor, setAnchor] = useState<{ right: number; top: number } | null>(null); - const [overlay, setOverlay] = useState<{ - x: number; - y: number; - width: number; - height: number; - } | null>(null); - - const close = useCallback(() => { - setAnchor(null); - setOverlay(null); - }, []); - useFocusEffect(useCallback(() => close, [close])); - const open = useCallback(() => { - anchorRef.current?.measureInWindow((x, y, width) => { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - setAnchor({ right: x + width, top: y }); - }); - }, []); - - return ( - <> - - props.onSend()} - onLongPress={open} - /> - - {anchor === null ? null : ( - - - overlayRef.current?.measureInWindow((x, y, width, height) => - setOverlay({ x, y, width, height }), - ) - } - > - - {overlay === null ? null : ( - - {props.actions.map((action) => ( - { - close(); - props.onSend(action.id); - }} - > - - {action.id === props.selectedAction ? ( - - ) : null} - - - {action.title} - {action.subtitle} - - - ))} - - )} - - - )} - - ); -} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 48c3efb4c0f7..0c9402f6b78c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -98,7 +98,6 @@ import { type ComposerSendPresentation, } from "./composerSendPresentation"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; -import { ComposerFollowUpMenu } from "./ComposerFollowUpMenu"; import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -285,21 +284,6 @@ function SendActionButton(props: { const actions = [presentation.action, presentation.alternate].filter( (action): action is ActiveTurnComposerAction => action !== null, ); - if (Platform.OS === "ios") { - return ( - ({ - id: action, - title: FOLLOW_UP_ACTION_LABEL[action], - subtitle: FOLLOW_UP_ACTION_SUBTITLE[action], - }))} - selectedAction={presentation.action} - onSend={props.onSend} - /> - ); - } return ( Date: Sun, 20 Sep 2026 13:09:39 -0700 Subject: [PATCH 3/3] fix(mobile): preserve keyboard in native long-press menus --- patches/@react-native-menu__menu@2.0.0.patch | 18 +++++++++++------- pnpm-lock.yaml | 6 +++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index 65e6f24a7901..261779a3fb54 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -170,7 +170,7 @@ index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256 + } diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c77f2cc47 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..475a99b2a3b1800c9d3e3e5f77a64f552fa35828 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift @@ -60,17 +60,46 @@ public class MenuViewImplementation: UIButton { @@ -222,7 +222,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c func setup () { let menu = UIMenu(title: _title, identifier: nil, -@@ -86,8 +115,98 @@ public class MenuViewImplementation: UIButton { +@@ -86,8 +115,102 @@ public class MenuViewImplementation: UIButton { } } @@ -309,19 +309,23 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c + if let existing = longPressInteraction { + existing.view?.removeInteraction(existing) + } -+ let interaction = UIContextMenuInteraction(delegate: self) ++ // UIControl's built-in interaction presents a compact menu that ++ // preserves the keyboard; a separate view interaction presents a ++ // rich context menu and temporarily hides it. ++ guard let interaction = self.contextMenuInteraction else { return } + host.addInteraction(interaction) + longPressInteraction = interaction + return + } + if let existing = longPressInteraction { + existing.view?.removeInteraction(existing) ++ self.addInteraction(existing) + longPressInteraction = nil + } } public override func reactSetFrame(_ frame: CGRect) { -@@ -124,4 +243,8 @@ public class MenuViewImplementation: UIButton { +@@ -124,4 +247,8 @@ public class MenuViewImplementation: UIButton { @objc func sendMenuOpen() { // NO-OP (should be overriden by parent) } @@ -379,7 +383,7 @@ index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed995 actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain) title?: string; diff --git a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts -index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..5ee7b45bafea8547d68949aaba70584055f9fb53 100644 +index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..c1b4ea53b29193283295d35c18e068f2836c6176 100644 --- a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts +++ b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts @@ -41,6 +41,9 @@ export interface NativeProps extends ViewProps { @@ -393,7 +397,7 @@ index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..5ee7b45bafea8547d68949aaba705840 actionsHash: string; title?: string; diff --git a/lib/typescript/src/index.d.ts b/lib/typescript/src/index.d.ts -index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..84ac2bc8b4edac045ed59896d5eb4e6eaef4725d 100644 +index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..0672bea3352b9ad0623124e308f740c70a7e6940 100644 --- a/lib/typescript/src/index.d.ts +++ b/lib/typescript/src/index.d.ts @@ -4,6 +4,7 @@ declare const MenuView: import("react").ForwardRefExoticComponent<{ @@ -405,7 +409,7 @@ index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..84ac2bc8b4edac045ed59896d5eb4e6e title?: string; isAnchoredToRight?: boolean; diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts -index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..271cd27f777be06bc02f05d4b4dac5bb940f5f0f 100644 +index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..19e82df15b042bc0cda27349b2b8a8ee1d3f0b51 100644 --- a/lib/typescript/src/types.d.ts +++ b/lib/typescript/src/types.d.ts @@ -113,6 +113,12 @@ type MenuComponentPropsBase = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d2c87a6d9d7..d37b4ad77a05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,7 +97,7 @@ patchedDependencies: '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d - '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd + '@react-native-menu/menu@2.0.0': a229d9974fc0a153eb7fae34599cc51171f2ab9a2bb79dc62f540db1ef592398 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 effect@4.0.0-rc.115: 0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5 @@ -268,7 +268,7 @@ importers: version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=a229d9974fc0a153eb7fae34599cc51171f2ab9a2bb79dc62f540db1ef592398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 version: 2.9.26(c10301b6e0c42fc6434d2b643197a81e) @@ -14459,7 +14459,7 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=a229d9974fc0a153eb7fae34599cc51171f2ab9a2bb79dc62f540db1ef592398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)