From f83a4d08dacf67b898acc1a1b6a87c10e843bb24 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 09:56:04 +0700 Subject: [PATCH] Add configurable send behavior --- .../app/src/components/agent-input-area.tsx | 4 ++ packages/app/src/components/message-input.tsx | 67 ++++++++++++++----- packages/app/src/hooks/use-settings.ts | 4 ++ packages/app/src/screens/settings-screen.tsx | 60 +++++++++++++---- 4 files changed, 105 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 847f23488..8f549b05c 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -48,6 +48,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { submitAgentInput } from "@/components/agent-input-submit"; +import { useAppSettings } from "@/hooks/use-settings"; type QueuedMessage = { id: string; @@ -131,6 +132,8 @@ export function AgentInputArea({ agentDirectoryStatus === "revalidating" || agentDirectoryStatus === "error_after_ready"); + const { settings: appSettings } = useAppSettings(); + const agentState = useSessionStore( useShallow((state) => { const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; @@ -742,6 +745,7 @@ export function AgentInputArea({ voiceServerId={serverId} voiceAgentId={agentId} isAgentRunning={isAgentRunning} + defaultSendBehavior={appSettings.sendBehavior} onQueue={handleQueue} onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onKeyPress={handleCommandKeyPress} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 8e18b3706..649f6e2e1 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -87,6 +87,9 @@ export interface MessageInputProps { voiceAgentId?: string; /** When true and there's sendable content, calls onQueue instead of onSubmit */ isAgentRunning?: boolean; + /** Controls what the default send action (Enter, send button, dictation) does + * when the agent is running. "interrupt" sends immediately, "queue" queues. */ + defaultSendBehavior?: "interrupt" | "queue"; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -208,6 +211,7 @@ export const MessageInput = forwardRef(funct voiceServerId, voiceAgentId, isAgentRunning = false, + defaultSendBehavior = "interrupt", onQueue, onSubmitLoadingPress, onKeyPress: onKeyPressCallback, @@ -352,11 +356,17 @@ export const MessageInput = forwardRef(funct if (shouldAutoSend) { const imageAttachments = images.length > 0 ? images : undefined; - onSubmit({ - text: nextValue, - images: imageAttachments, - forceSend: isAgentRunning || undefined, - }); + // Respect send behavior setting: when "queue", dictation queues too. + if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { + onQueue({ text: nextValue, images: imageAttachments }); + onChangeText(""); + } else { + onSubmit({ + text: nextValue, + images: imageAttachments, + forceSend: isAgentRunning || undefined, + }); + } } else { onChangeText(nextValue); } @@ -367,7 +377,7 @@ export const MessageInput = forwardRef(funct }); } }, - [onChangeText, onSubmit, images, isAgentRunning], + [onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior], ); const handleDictationError = useCallback( @@ -578,6 +588,26 @@ export const MessageInput = forwardRef(funct onHeightChange?.(MIN_INPUT_HEIGHT); }, [value, images, onQueue, onChangeText, onHeightChange]); + // Default send action: respects the sendBehavior setting. + // When "interrupt" (default), primary action sends immediately (interrupts). + // When "queue", primary action queues when agent is running. + const handleDefaultSendAction = useCallback(() => { + if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { + handleQueueMessage(); + } else { + handleSendMessage(); + } + }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); + + // Alternate send action: always the opposite of the default. + const handleAlternateSendAction = useCallback(() => { + if (defaultSendBehavior === "queue") { + handleSendMessage(); // interrupt + } else if (onQueue) { + handleQueueMessage(); // queue + } + }, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]); + // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { return typeof v === "object" && v !== null && "scrollHeight" in v; @@ -872,18 +902,18 @@ export const MessageInput = forwardRef(funct // Shift+Enter: add newline (default behavior, don't intercept) if (shiftKey) return; - // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running + // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); - handleQueueMessage(); + handleAlternateSendAction(); return; } - // Enter: send (interrupts agent if running) + // Enter: default send action (interrupt or queue, based on setting) if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); - handleSendMessage(); + handleDefaultSendAction(); } const hasImages = images.length > 0; @@ -892,11 +922,14 @@ export const MessageInput = forwardRef(funct const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); + const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning; const submitAccessibilityLabel = canPressLoadingButton ? "Interrupt agent" - : isAgentRunning - ? "Send and interrupt" - : "Send message"; + : defaultActionQueues + ? "Queue message" + : isAgentRunning + ? "Send and interrupt" + : "Send message"; const handleInputChange = useCallback( (nextValue: string) => { @@ -1071,10 +1104,10 @@ export const MessageInput = forwardRef(funct {rightContent} - {hasSendableContent && isAgentRunning && onQueue && ( + {hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && ( (funct {shouldShowSendButton && ( (funct - Send + {defaultActionQueues ? "Queue" : "Send"} {sendKeys ? : null} diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index ab85b13eb..762f610e5 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -6,14 +6,18 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +export type SendBehavior = "interrupt" | "queue"; + export interface AppSettings { theme: "dark" | "light" | "auto"; manageBuiltInDaemon: boolean; + sendBehavior: SendBehavior; } export const DEFAULT_APP_SETTINGS: AppSettings = { theme: "auto", manageBuiltInDaemon: true, + sendBehavior: "interrupt", }; export interface UseAppSettingsReturn { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 1246ec6ac..7287f5ed4 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -15,7 +15,6 @@ import { RotateCw, Trash2, Server, - Palette, Keyboard, Stethoscope, Info, @@ -24,7 +23,7 @@ import { Blocks, Smartphone, } from "lucide-react-native"; -import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; @@ -77,7 +76,7 @@ import { StatusBadge } from "@/components/ui/status-badge"; type SettingsSectionId = | "hosts" - | "appearance" + | "general" | "shortcuts" | "integrations" | "providers" @@ -96,7 +95,7 @@ interface SettingsSectionDef { function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] { const sections: SettingsSectionDef[] = [ { id: "hosts", label: "Hosts", icon: Server }, - { id: "appearance", label: "Appearance", icon: Palette }, + { id: "general", label: "General", icon: Settings }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "permissions", label: "Permissions", icon: Shield }, ]; @@ -386,15 +385,20 @@ function HostsSection(props: HostsSectionProps) { ); } -interface AppearanceSectionProps { +interface GeneralSectionProps { settings: AppSettings; handleThemeChange: (theme: AppSettings["theme"]) => void; + handleSendBehaviorChange: (behavior: SendBehavior) => void; } -function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) { +function GeneralSection({ + settings, + handleThemeChange, + handleSendBehaviorChange, +}: GeneralSectionProps) { return ( - Appearance + General @@ -424,6 +428,23 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro ]} /> + + + Default send + + What happens when you press Enter while the agent is running + + + + ); @@ -614,7 +635,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) { interface SettingsSectionContentProps { sectionId: SettingsSectionId; hostsProps: HostsSectionProps; - appearanceProps: AppearanceSectionProps; + generalProps: GeneralSectionProps; providersProps: ProvidersSectionProps; diagnosticsProps: DiagnosticsSectionProps; aboutProps: AboutSectionProps; @@ -626,7 +647,7 @@ interface SettingsSectionContentProps { function SettingsSectionContent({ sectionId, hostsProps, - appearanceProps, + generalProps, providersProps, diagnosticsProps, aboutProps, @@ -637,8 +658,8 @@ function SettingsSectionContent({ switch (sectionId) { case "hosts": return ; - case "appearance": - return ; + case "general": + return ; case "shortcuts": return ; case "providers": @@ -1003,6 +1024,13 @@ export default function SettingsScreen() { [updateSettings], ); + const handleSendBehaviorChange = useCallback( + (behavior: SendBehavior) => { + void updateSettings({ sendBehavior: behavior }); + }, + [updateSettings], + ); + const handlePlaybackTest = useCallback(async () => { if (!voiceAudioEngine || isPlaybackTestRunning) { return; @@ -1069,9 +1097,10 @@ export default function SettingsScreen() { isMountedRef, }; - const appearanceProps: AppearanceSectionProps = { + const generalProps: GeneralSectionProps = { settings, handleThemeChange, + handleSendBehaviorChange, }; const diagnosticsProps: DiagnosticsSectionProps = { @@ -1092,7 +1121,7 @@ export default function SettingsScreen() { const sectionContentProps: Omit = { hostsProps, - appearanceProps, + generalProps, providersProps, diagnosticsProps, aboutProps, @@ -1901,6 +1930,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, + audioRowSubtitle: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + marginTop: theme.spacing[1], + }, providerActions: { flexDirection: "row", alignItems: "center",