feat(app): long-press Send to queue or send now on touch screens (#7897)

Touch users had no way to reach what Cmd/Ctrl+Enter does: queue when
Default send is Steer or Interrupt, send now when it is Queue. Holding the
Send button now runs that same action on every platform, with a haptic on
native and an accessibility hint naming it. Tapping is unchanged.

Both paths go through runAlternateSendAction, which now falls back to the
default send when no turn is running, so holding Send while idle still
sends and the keyboard path no longer carries its own gate.

Co-authored-by: bjspi <43368639+bjspi@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-10-26 14:04:30 +07:00
committed by GitHub
co-authored by bjspi
parent 95c8491e2e
commit 88bd1cf05a
16 changed files with 216 additions and 17 deletions
@@ -18,7 +18,9 @@ import {
expectComposerVisible,
cancelAgent,
composerLocator,
expectQueuedMessageButton,
fillComposerDraft,
holdSendButton,
sendDraftToQueue,
startRunningMockAgent,
submitMessage,
@@ -1290,6 +1292,49 @@ test.describe("Agent message submission", () => {
}
});
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user holds Send.",
});
try {
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const queuedPrompt = "Queue this follow-up by holding Send.";
await test.step("holding Send queues the draft", async () => {
await fillComposerDraft(page, queuedPrompt);
await holdSendButton(page, "Send and steer");
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
await expectAgentReadyToInterrupt(page);
await expect(
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
).toHaveCount(0);
});
await test.step("tapping Send still steers", async () => {
const steeredPrompt = "Steer this turn with a tap.";
await fillComposerDraft(page, steeredPrompt);
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
const request = await gate.waitForRequest();
expect(request.text).toBe(steeredPrompt);
expect(request.activeTurnBehavior).toBe("steer");
gate.accept();
await expect(
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
).toHaveCount(1);
await expectQueuedMessageButton(page);
});
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -87,6 +87,14 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
export async function holdSendButton(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).hover();
await page.mouse.down();
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
await page.mouse.up();
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
+35 -13
View File
@@ -27,6 +27,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
import { hasFinePointer } from "@/hooks/use-fine-pointer";
import { DictationOverlay } from "@/components/dictation-controls";
@@ -57,7 +58,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
@@ -76,6 +77,7 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
}));
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityHint,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
@@ -83,6 +85,7 @@ import {
import {
applyDictationTranscript,
computeCanStartDictation,
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveRetainedSelection,
@@ -399,8 +402,6 @@ interface DesktopKeyPressContext {
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
disabled: boolean;
@@ -430,15 +431,12 @@ function handleDesktopKeyPressImpl(
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) {
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (metaKey || ctrlKey) {
ctx.handleAlternateSendAction();
return;
}
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
ctx.handleDefaultSendAction();
}
@@ -769,8 +767,10 @@ function SendButtonTooltip({
canPressLoadingButton,
onSubmitLoadingPress,
onDefaultSendAction,
onLongPressSend,
isSendButtonDisabled,
submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
@@ -784,8 +784,10 @@ function SendButtonTooltip({
canPressLoadingButton: boolean;
onSubmitLoadingPress: (() => void) | undefined;
onDefaultSendAction: () => void;
onLongPressSend: () => void;
isSendButtonDisabled: boolean;
submitAccessibilityLabel: string;
submitAccessibilityHint: string | undefined;
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
isSubmitLoading: boolean;
submitIcon: "arrow" | "return";
@@ -800,8 +802,10 @@ function SendButtonTooltip({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : onDefaultSendAction}
onLongPress={canPressLoadingButton ? undefined : onLongPressSend}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityHint={submitAccessibilityHint}
accessibilityRole="button"
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
@@ -1705,6 +1709,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
});
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
const alternateSendAction = resolveAlternateSendAction({
defaultSendBehavior,
isAgentRunning,
onQueue,
});
const handleLongPressSend = useCallback(() => {
if (alternateSendAction && isNative) {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
}
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteImagesEffect({
textarea: webTextArea,
isConnected,
@@ -1738,8 +1755,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1751,8 +1766,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1792,6 +1805,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
t,
});
const submitAccessibilityHint = resolveSubmitAccessibilityHint({
canPressLoadingButton,
alternateSendAction,
t,
});
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent,
@@ -1952,8 +1970,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
onLongPressSend={handleLongPressSend}
isSendButtonDisabled={isPresentedSendDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
submitAccessibilityHint={submitAccessibilityHint}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
@@ -1971,8 +1991,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
handleLongPressSend,
isPresentedSendDisabled,
submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityHint,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
@@ -12,6 +13,8 @@ const translations: Record<string, string> = {
"composer.input.sendAndInterrupt": "Send and interrupt",
"composer.input.sendAndSteer": "Send and steer",
"composer.input.sendMessage": "Send message",
"composer.input.longPressToQueue": "Long press to queue",
"composer.input.longPressToSendNow": "Long press to send now",
"composer.input.queue": "Queue",
"composer.input.send": "Send",
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
@@ -26,6 +29,37 @@ const translations: Record<string, string> = {
const t = ((key: string) => translations[key] ?? key) as never;
describe("composer input labels", () => {
it("names the long-press action in the submit hint only when one exists", () => {
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: "queue",
t,
}),
).toBe("Long press to queue");
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: "send",
t,
}),
).toBe("Long press to send now");
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: null,
t,
}),
).toBeUndefined();
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: true,
alternateSendAction: "queue",
t,
}),
).toBeUndefined();
});
it("resolves submit accessibility labels from translations", () => {
expect(
resolveSubmitAccessibilityLabel({
+14
View File
@@ -1,5 +1,6 @@
import type { TFunction } from "i18next";
import type { SendBehavior } from "@/hooks/use-settings/storage";
import type { AlternateSendAction } from "./state";
export function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
@@ -22,6 +23,19 @@ export function resolveSubmitAccessibilityLabel(input: {
return input.t("composer.input.sendMessage");
}
export function resolveSubmitAccessibilityHint(input: {
canPressLoadingButton: boolean;
alternateSendAction: AlternateSendAction | null;
t: TFunction;
}): string | undefined {
if (input.canPressLoadingButton || !input.alternateSendAction) return undefined;
return input.t(
input.alternateSendAction === "queue"
? "composer.input.longPressToQueue"
: "composer.input.longPressToSendNow",
);
}
export function resolveVoiceAccessibilityLabel(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
@@ -7,6 +7,7 @@ import {
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveRetainedSelection,
resolveAlternateSendAction,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -276,6 +277,47 @@ describe("composer send behavior", () => {
expect(defaultAction.calls).toEqual(["queue"]);
expect(alternateAction.calls).toEqual(["send"]);
});
it("sends with the alternate action while no turn is running", () => {
for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) {
const alternateAction = actions();
runAlternateSendAction({
defaultSendBehavior,
isAgentRunning: false,
onQueue: alternateAction.onQueue,
handleSendMessage: alternateAction.handleSendMessage,
handleQueueMessage: alternateAction.handleQueueMessage,
});
expect(alternateAction.calls).toEqual(["send"]);
}
});
it("offers an alternate action only while a turn runs and the composer can queue", () => {
const onQueue = () => undefined;
expect(
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }),
).toBe("queue");
expect(
resolveAlternateSendAction({
defaultSendBehavior: "interrupt",
isAgentRunning: true,
onQueue,
}),
).toBe("queue");
expect(
resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }),
).toBe("send");
expect(
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }),
).toBeNull();
expect(
resolveAlternateSendAction({
defaultSendBehavior: "queue",
isAgentRunning: true,
onQueue: undefined,
}),
).toBeNull();
});
});
describe("stopRealtimeVoice", () => {
+20 -4
View File
@@ -125,14 +125,30 @@ export function runDefaultSendAction(ctx: SendActionContext): void {
ctx.handleSendMessage();
}
export type AlternateSendAction = "queue" | "send";
/**
* What Mod+Enter and a long-press on Send do instead of the default send. Null while
* no turn is running, where both fall back to the default send.
*/
export function resolveAlternateSendAction(
ctx: Pick<SendActionContext, "defaultSendBehavior" | "isAgentRunning" | "onQueue">,
): AlternateSendAction | null {
if (!ctx.isAgentRunning || !ctx.onQueue) return null;
return ctx.defaultSendBehavior === "queue" ? "send" : "queue";
}
export function runAlternateSendAction(ctx: SendActionContext): void {
if (ctx.defaultSendBehavior === "queue") {
const action = resolveAlternateSendAction(ctx);
if (action === "queue") {
ctx.handleQueueMessage();
return;
}
if (action === "send") {
ctx.handleSendMessage();
return;
}
if (ctx.isAgentRunning && ctx.onQueue) {
ctx.handleQueueMessage();
}
runDefaultSendAction(ctx);
}
export function runMessageInputKeyboardAction(
+2
View File
@@ -133,6 +133,8 @@ export const ar: TranslationResources = {
sendAndInterrupt: "إرسال ومقاطعة",
sendAndSteer: "إرسال وتوجيه",
sendMessage: "أرسل رسالة",
longPressToQueue: "اضغط مطولًا لإضافتها إلى الطابور",
longPressToSendNow: "اضغط مطولًا للإرسال الآن",
queue: "طابور",
send: "يرسل",
},
+2
View File
@@ -129,6 +129,8 @@ export const en = {
sendAndInterrupt: "Send and interrupt",
sendAndSteer: "Send and steer",
sendMessage: "Send message",
longPressToQueue: "Long press to queue",
longPressToSendNow: "Long press to send now",
queue: "Queue",
send: "Send",
},
+2
View File
@@ -134,6 +134,8 @@ export const es: TranslationResources = {
sendAndInterrupt: "Enviar e interrumpir",
sendAndSteer: "Enviar y orientar",
sendMessage: "enviar mensaje",
longPressToQueue: "Mantén pulsado para poner en cola",
longPressToSendNow: "Mantén pulsado para enviar ahora",
queue: "Cola",
send: "Enviar",
},
+2
View File
@@ -135,6 +135,8 @@ export const fr: TranslationResources = {
sendAndInterrupt: "Envoyer et interrompre",
sendAndSteer: "Envoyer et réorienter",
sendMessage: "Envoyer le message",
longPressToQueue: "Appui long pour mettre en file d’attente",
longPressToSendNow: "Appui long pour envoyer maintenant",
queue: "Mettre en file d’attente",
send: "Envoyer",
},
+2
View File
@@ -134,6 +134,8 @@ export const ja: TranslationResources = {
sendAndInterrupt: "送信して中断",
sendAndSteer: "送信して指示を追加",
sendMessage: "メッセージを送信",
longPressToQueue: "長押しでキューに追加",
longPressToSendNow: "長押しで今すぐ送信",
queue: "キュー",
send: "送信",
},
+2
View File
@@ -133,6 +133,8 @@ export const ko: TranslationResources = {
sendAndInterrupt: "보내고 중단",
sendAndSteer: "보내고 지시 추가",
sendMessage: "메시지 보내기",
longPressToQueue: "길게 눌러 대기열에 추가",
longPressToSendNow: "길게 눌러 지금 보내기",
queue: "대기열",
send: "보내기",
},
+2
View File
@@ -134,6 +134,8 @@ export const ptBR: TranslationResources = {
sendAndInterrupt: "Enviar e interromper",
sendAndSteer: "Enviar e orientar",
sendMessage: "Enviar mensagem",
longPressToQueue: "Mantenha pressionado para enfileirar",
longPressToSendNow: "Mantenha pressionado para enviar agora",
queue: "Fila",
send: "Enviar",
},
+2
View File
@@ -134,6 +134,8 @@ export const ru: TranslationResources = {
sendAndInterrupt: "Отправить и прервать",
sendAndSteer: "Отправить и направить",
sendMessage: "Отправить сообщение",
longPressToQueue: "Удерживайте, чтобы поставить в очередь",
longPressToSendNow: "Удерживайте, чтобы отправить сейчас",
queue: "Очередь",
send: "Отправить",
},
+2
View File
@@ -133,6 +133,8 @@ export const zhCN: TranslationResources = {
sendAndInterrupt: "发送并中断",
sendAndSteer: "发送并引导",
sendMessage: "发送消息",
longPressToQueue: "长按加入队列",
longPressToSendNow: "长按立即发送",
queue: "排队",
send: "发送",
},