From 29a54794513faaf67605a81c4a45ea500840ae6e Mon Sep 17 00:00:00 2001 From: bjspi Date: Fri, 19 Jun 2026 09:47:29 +0200 Subject: [PATCH] feat(composer): long-press send button to queue message Tap the send button still sends (unchanged). A long-press now queues the draft instead, reusing the existing queue path (queueComposerMessage / onQueue) that already powers the "queue" send-behavior setting and Mod+Enter. This exposes queueing as a touch gesture so mobile users can queue a follow-up while an agent is running without changing a setting. - Add pure, tested runLongPressQueueAction helper in input/state.ts - queueMessageImpl now reports whether it queued, so haptic feedback only fires on a real queue (native only) - Send button gains an accessibilityHint (reuses composer.input.queueMessage) - Long-press is gated to the normal send state and to composers that actually support queueing Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/src/composer/input/input.tsx | 45 ++++++++++++++++--- packages/app/src/composer/input/state.test.ts | 35 ++++++++++++++- packages/app/src/composer/input/state.ts | 22 +++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 73dd7b1a9..9c3c235cc 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -58,8 +58,9 @@ 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 { isWeb, isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; +import * as Haptics from "expo-haptics"; import { useComposerHeightMirror } from "./height-mirror"; import { resolveSendTooltipLabel, @@ -67,7 +68,12 @@ import { resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, } from "./labels"; -import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state"; +import { + computeCanStartDictation, + runAlternateSendAction, + runDefaultSendAction, + runLongPressQueueAction, +} from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; @@ -783,6 +789,9 @@ function SendButtonTooltip({ canPressLoadingButton, onSubmitLoadingPress, onDefaultSendAction, + onLongPressQueue, + isQueueAvailable, + queueAccessibilityHint, isSendButtonDisabled, submitAccessibilityLabel, sendButtonCombinedStyle, @@ -797,6 +806,9 @@ function SendButtonTooltip({ canPressLoadingButton: boolean; onSubmitLoadingPress: (() => void) | undefined; onDefaultSendAction: () => void; + onLongPressQueue: (() => void) | undefined; + isQueueAvailable: boolean; + queueAccessibilityHint: string | undefined; isSendButtonDisabled: boolean; submitAccessibilityLabel: string; sendButtonCombinedStyle: React.ComponentProps["style"]; @@ -808,10 +820,15 @@ function SendButtonTooltip({ sendTooltipLabel: string; }) { if (!shouldShow) return null; + // Long-press to queue only applies when queueing is available and the button + // is in its normal send state (not the loading/cancel affordance). + const longPressQueue = canPressLoadingButton || !isQueueAvailable ? undefined : onLongPressQueue; return ( void; } -function queueMessageImpl(ctx: QueueMessageContext): void { - if (!ctx.onQueue) return; +function queueMessageImpl(ctx: QueueMessageContext): boolean { + if (!ctx.onQueue) return false; const trimmed = ctx.value.trim(); - if (!trimmed && ctx.attachments.length === 0) return; + if (!trimmed && ctx.attachments.length === 0) return false; ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.onChangeText(""); ctx.onMinimizeHeight(); + return true; } function computeIsRealtimeVoiceForAgent( @@ -1583,6 +1601,20 @@ export const MessageInput = forwardRef( [attachments, cwd, onQueue, onChangeText, minimizeInputHeight], ); + // Long-pressing the send button queues the draft instead of sending it, + // regardless of the default send behavior setting. Tap still sends. + const handleLongPressSend = useCallback(() => { + runLongPressQueueAction({ + onQueue, + queueMessage: handleQueueMessage, + onQueued: () => { + if (isNative) { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + } + }, + }); + }, [onQueue, handleQueueMessage]); + const handleDefaultSendAction = useCallback(() => { runDefaultSendAction({ defaultSendBehavior, @@ -1887,6 +1919,9 @@ export const MessageInput = forwardRef( canPressLoadingButton={canPressLoadingButton} onSubmitLoadingPress={onSubmitLoadingPress} onDefaultSendAction={handleDefaultSendAction} + onLongPressQueue={handleLongPressSend} + isQueueAvailable={Boolean(onQueue)} + queueAccessibilityHint={t("composer.input.queueMessage")} isSendButtonDisabled={isSendButtonDisabled} submitAccessibilityLabel={submitAccessibilityLabel} sendButtonCombinedStyle={sendButtonCombinedStyle} diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index ebea86bbb..4796d7a78 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state"; +import { describe, expect, it, vi } from "vitest"; +import { + computeCanStartDictation, + runAlternateSendAction, + runDefaultSendAction, + runLongPressQueueAction, +} from "./state"; const connected = { isConnected: true } as never; const disconnected = { isConnected: false } as never; @@ -149,3 +154,29 @@ describe("composer send behavior", () => { expect(alternateAction.calls).toEqual(["send"]); }); }); + +describe("runLongPressQueueAction", () => { + it("does nothing when queueing is unavailable", () => { + const queueMessage = vi.fn(() => true); + const onQueued = vi.fn(); + runLongPressQueueAction({ onQueue: undefined, queueMessage, onQueued }); + expect(queueMessage).not.toHaveBeenCalled(); + expect(onQueued).not.toHaveBeenCalled(); + }); + + it("queues and fires the queued side effect when there is content", () => { + const queueMessage = vi.fn(() => true); + const onQueued = vi.fn(); + runLongPressQueueAction({ onQueue: () => undefined, queueMessage, onQueued }); + expect(queueMessage).toHaveBeenCalledTimes(1); + expect(onQueued).toHaveBeenCalledTimes(1); + }); + + it("skips the queued side effect when nothing was queued", () => { + const queueMessage = vi.fn(() => false); + const onQueued = vi.fn(); + runLongPressQueueAction({ onQueue: () => undefined, queueMessage, onQueued }); + expect(queueMessage).toHaveBeenCalledTimes(1); + expect(onQueued).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 9eaa554fc..9499b8251 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -41,3 +41,25 @@ export function runAlternateSendAction(ctx: SendActionContext): void { ctx.handleQueueMessage(); } } + +interface LongPressQueueContext { + onQueue: ((payload: MessagePayload) => void) | undefined; + /** Queues the current draft. Returns true when something was actually queued. */ + queueMessage: () => boolean; + /** Side effect (e.g. haptic feedback) fired only when a message was queued. */ + onQueued: () => void; +} + +/** + * Long-pressing the send button queues the message instead of sending it, + * independent of the configured default send behavior. This reuses the same + * queue path as Mod+Enter / the "queue" send setting, but exposes it as a touch + * gesture so mobile users can queue without changing a setting. No-ops when + * queueing is unavailable (`onQueue` missing) or there is nothing to queue. + */ +export function runLongPressQueueAction(ctx: LongPressQueueContext): void { + if (!ctx.onQueue) return; + if (ctx.queueMessage()) { + ctx.onQueued(); + } +}