Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions packages/app/src/composer/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,22 @@ 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,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state";
import {
computeCanStartDictation,
runAlternateSendAction,
runDefaultSendAction,
runLongPressQueueAction,
} from "./state";

const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];

Expand Down Expand Up @@ -783,6 +789,9 @@ function SendButtonTooltip({
canPressLoadingButton,
onSubmitLoadingPress,
onDefaultSendAction,
onLongPressQueue,
isQueueAvailable,
queueAccessibilityHint,
isSendButtonDisabled,
submitAccessibilityLabel,
sendButtonCombinedStyle,
Expand All @@ -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<typeof TooltipTrigger>["style"];
Expand All @@ -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 (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : onDefaultSendAction}
onLongPress={longPressQueue}
accessibilityHint={longPressQueue ? queueAccessibilityHint : undefined}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
Expand Down Expand Up @@ -1019,13 +1036,14 @@ interface QueueMessageContext {
onMinimizeHeight: () => 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(
Expand Down Expand Up @@ -1583,6 +1601,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[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,
Expand Down Expand Up @@ -1887,6 +1919,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
onLongPressQueue={handleLongPressSend}
isQueueAvailable={Boolean(onQueue)}
queueAccessibilityHint={t("composer.input.queueMessage")}
isSendButtonDisabled={isSendButtonDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
Expand Down
35 changes: 33 additions & 2 deletions packages/app/src/composer/input/state.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
});
});
22 changes: 22 additions & 0 deletions packages/app/src/composer/input/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Loading