From 88bd1cf05a45dd3d36a7380683e6872b5b9826b1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 14:04:30 +0700 Subject: [PATCH] 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> --- .../browser/agent-message-submission.spec.ts | 45 +++++++++++++++++ packages/app/e2e/support/helpers/composer.ts | 8 ++++ packages/app/src/composer/input/input.tsx | 48 ++++++++++++++----- .../app/src/composer/input/labels.test.ts | 34 +++++++++++++ packages/app/src/composer/input/labels.ts | 14 ++++++ packages/app/src/composer/input/state.test.ts | 42 ++++++++++++++++ packages/app/src/composer/input/state.ts | 24 ++++++++-- packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/ko.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + 16 files changed, 216 insertions(+), 17 deletions(-) diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 38598f4e65..0009188b34 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -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"); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 19183fee4b..bc0fef67f3 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -87,6 +87,14 @@ export async function sendDraftToQueue(page: Page): Promise { 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 { + 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 { await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({ timeout: 10_000, diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 06ba946bf2..73c54331d1 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; @@ -800,8 +802,10 @@ function SendButtonTooltip({ ( }); }, [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( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, - isAgentRunning, - onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1751,8 +1766,6 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, - isAgentRunning, - onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1792,6 +1805,11 @@ export const MessageInput = forwardRef( isAgentRunning, t, }); + const submitAccessibilityHint = resolveSubmitAccessibilityHint({ + canPressLoadingButton, + alternateSendAction, + t, + }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, @@ -1952,8 +1970,10 @@ export const MessageInput = forwardRef( 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( canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, + handleLongPressSend, isPresentedSendDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, diff --git a/packages/app/src/composer/input/labels.test.ts b/packages/app/src/composer/input/labels.test.ts index 15aa16b8e0..90c60d2781 100644 --- a/packages/app/src/composer/input/labels.test.ts +++ b/packages/app/src/composer/input/labels.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -12,6 +13,8 @@ const translations: Record = { "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 = { 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({ diff --git a/packages/app/src/composer/input/labels.ts b/packages/app/src/composer/input/labels.ts index 0820cd7ecb..50ac3eb8bf 100644 --- a/packages/app/src/composer/input/labels.ts +++ b/packages/app/src/composer/input/labels.ts @@ -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; diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 6f2cdef74d..82f30da364 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -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", () => { diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 698965b5ac..9cf6279482 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -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, +): 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( diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a5743acad3..da49bcec67 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -133,6 +133,8 @@ export const ar: TranslationResources = { sendAndInterrupt: "إرسال ومقاطعة", sendAndSteer: "إرسال وتوجيه", sendMessage: "أرسل رسالة", + longPressToQueue: "اضغط مطولًا لإضافتها إلى الطابور", + longPressToSendNow: "اضغط مطولًا للإرسال الآن", queue: "طابور", send: "يرسل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 10564eab19..57768f0ac0 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7f0e2b6529..51839eaf16 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 8fbf03bb1f..4f7c9be2aa 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index b6f218adef..c0f22ffde5 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -134,6 +134,8 @@ export const ja: TranslationResources = { sendAndInterrupt: "送信して中断", sendAndSteer: "送信して指示を追加", sendMessage: "メッセージを送信", + longPressToQueue: "長押しでキューに追加", + longPressToSendNow: "長押しで今すぐ送信", queue: "キュー", send: "送信", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7bd4bc1b63..df571aae4d 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -133,6 +133,8 @@ export const ko: TranslationResources = { sendAndInterrupt: "보내고 중단", sendAndSteer: "보내고 지시 추가", sendMessage: "메시지 보내기", + longPressToQueue: "길게 눌러 대기열에 추가", + longPressToSendNow: "길게 눌러 지금 보내기", queue: "대기열", send: "보내기", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 21293e9c44..6ba6ec46bb 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 8f51e5567e..f8f9969bf9 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -134,6 +134,8 @@ export const ru: TranslationResources = { sendAndInterrupt: "Отправить и прервать", sendAndSteer: "Отправить и направить", sendMessage: "Отправить сообщение", + longPressToQueue: "Удерживайте, чтобы поставить в очередь", + longPressToSendNow: "Удерживайте, чтобы отправить сейчас", queue: "Очередь", send: "Отправить", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f3626c933a..9954bdb61b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -133,6 +133,8 @@ export const zhCN: TranslationResources = { sendAndInterrupt: "发送并中断", sendAndSteer: "发送并引导", sendMessage: "发送消息", + longPressToQueue: "长按加入队列", + longPressToSendNow: "长按立即发送", queue: "排队", send: "发送", },