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: "发送", },