Add configurable send behavior

This commit is contained in:
Mohamed Boudra
2026-04-09 18:56:09 +07:00
parent ce77947654
commit 74f7b9e416
4 changed files with 111 additions and 30 deletions
+4
View File
@@ -48,6 +48,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit"; import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -139,6 +140,8 @@ export function Composer({
agentDirectoryStatus === "revalidating" || agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready"); agentDirectoryStatus === "error_after_ready");
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => { useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -760,6 +763,7 @@ export function Composer({
voiceServerId={serverId} voiceServerId={serverId}
voiceAgentId={agentId} voiceAgentId={agentId}
isAgentRunning={isAgentRunning} isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue} onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress} onKeyPress={handleCommandKeyPress}
+56 -17
View File
@@ -90,6 +90,9 @@ export interface MessageInputProps {
voiceAgentId?: string; voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */ /** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean; 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 */ /** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void; onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */ /** Optional handler used when submit button is in loading state. */
@@ -217,6 +220,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
voiceServerId, voiceServerId,
voiceAgentId, voiceAgentId,
isAgentRunning = false, isAgentRunning = false,
defaultSendBehavior = "interrupt",
onQueue, onQueue,
onSubmitLoadingPress, onSubmitLoadingPress,
onKeyPress: onKeyPressCallback, onKeyPress: onKeyPressCallback,
@@ -362,11 +366,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) { if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined; const imageAttachments = images.length > 0 ? images : undefined;
onSubmit({ // Respect send behavior setting: when "queue", dictation queues too.
text: nextValue, if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
images: imageAttachments, onQueue({ text: nextValue, images: imageAttachments });
forceSend: isAgentRunning || undefined, onChangeText("");
}); } else {
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
}
} else { } else {
onChangeText(nextValue); onChangeText(nextValue);
} }
@@ -377,7 +387,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}); });
} }
}, },
[onChangeText, onSubmit, images, isAgentRunning], [onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
); );
const handleDictationError = useCallback( const handleDictationError = useCallback(
@@ -588,6 +598,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onHeightChange?.(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onQueue, onChangeText, onHeightChange]); }, [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 // Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle { function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v; return typeof v === "object" && v !== null && "scrollHeight" in v;
@@ -882,18 +912,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Shift+Enter: add newline (default behavior, don't intercept) // Shift+Enter: add newline (default behavior, don't intercept)
if (shiftKey) return; 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 ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
if (isSubmitDisabled || isSubmitLoading || disabled) return; if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault(); event.preventDefault();
handleQueueMessage(); handleAlternateSendAction();
return; return;
} }
// Enter: send (interrupts agent if running) // Enter: default send action (interrupt or queue, based on setting)
if (isSubmitDisabled || isSubmitLoading || disabled) return; if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault(); event.preventDefault();
handleSendMessage(); handleDefaultSendAction();
} }
const hasImages = images.length > 0; const hasImages = images.length > 0;
@@ -904,11 +934,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled = const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent" ? "Interrupt agent"
: isAgentRunning : defaultActionQueues
? "Send and interrupt" ? "Queue message"
: "Send message"; : isAgentRunning
? "Send and interrupt"
: "Send message";
const handleInputChange = useCallback( const handleInputChange = useCallback(
(nextValue: string) => { (nextValue: string) => {
@@ -1087,10 +1120,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{rightContent} {rightContent}
{hasSendableContent && isAgentRunning && onQueue && ( {hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={handleQueueMessage} onPress={handleAlternateSendAction}
disabled={!isConnected || disabled} disabled={!isConnected || disabled}
accessibilityLabel="Queue message" accessibilityLabel="Queue message"
accessibilityRole="button" accessibilityRole="button"
@@ -1115,7 +1148,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{shouldShowSendButton && ( {shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage} onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
disabled={isSendButtonDisabled} disabled={isSendButtonDisabled}
accessibilityLabel={showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel} accessibilityLabel={showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel}
accessibilityRole="button" accessibilityRole="button"
@@ -1134,7 +1167,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{showEmptySubmitLabel ? emptySubmitLabel : "Send"}</Text> <Text style={styles.tooltipText}>
{showEmptySubmitLabel
? emptySubmitLabel
: defaultActionQueues
? "Queue"
: "Send"}
</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null} {sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View> </View>
</TooltipContent> </TooltipContent>
+4
View File
@@ -6,14 +6,18 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"]; const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendBehavior = "interrupt" | "queue";
export interface AppSettings { export interface AppSettings {
theme: "dark" | "light" | "auto"; theme: "dark" | "light" | "auto";
manageBuiltInDaemon: boolean; manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
} }
export const DEFAULT_APP_SETTINGS: AppSettings = { export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto", theme: "auto",
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
sendBehavior: "interrupt",
}; };
export interface UseAppSettingsReturn { export interface UseAppSettingsReturn {
+47 -13
View File
@@ -15,7 +15,6 @@ import {
RotateCw, RotateCw,
Trash2, Trash2,
Server, Server,
Palette,
Keyboard, Keyboard,
Stethoscope, Stethoscope,
Info, Info,
@@ -24,7 +23,7 @@ import {
Blocks, Blocks,
Smartphone, Smartphone,
} from "lucide-react-native"; } 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 type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -77,7 +76,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
type SettingsSectionId = type SettingsSectionId =
| "hosts" | "hosts"
| "appearance" | "general"
| "shortcuts" | "shortcuts"
| "integrations" | "integrations"
| "providers" | "providers"
@@ -96,7 +95,7 @@ interface SettingsSectionDef {
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] { function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [ const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server }, { 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: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "permissions", label: "Permissions", icon: Shield }, { id: "permissions", label: "Permissions", icon: Shield },
]; ];
@@ -386,15 +385,20 @@ function HostsSection(props: HostsSectionProps) {
); );
} }
interface AppearanceSectionProps { interface GeneralSectionProps {
settings: AppSettings; settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void; handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
} }
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) { function GeneralSection({
settings,
handleThemeChange,
handleSendBehaviorChange,
}: GeneralSectionProps) {
return ( return (
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Appearance</Text> <Text style={settingsStyles.sectionTitle}>General</Text>
<View style={[settingsStyles.card, styles.audioCard]}> <View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}> <View style={styles.audioRow}>
<View style={styles.audioRowContent}> <View style={styles.audioRowContent}>
@@ -424,6 +428,23 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro
]} ]}
/> />
</View> </View>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Default send</Text>
<Text style={styles.audioRowSubtitle}>
What happens when you press Enter while the agent is running
</Text>
</View>
<SegmentedControl
size="sm"
value={settings.sendBehavior}
onValueChange={handleSendBehaviorChange}
options={[
{ value: "interrupt", label: "Interrupt" },
{ value: "queue", label: "Queue" },
]}
/>
</View>
</View> </View>
</View> </View>
); );
@@ -613,7 +634,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
interface SettingsSectionContentProps { interface SettingsSectionContentProps {
sectionId: SettingsSectionId; sectionId: SettingsSectionId;
hostsProps: HostsSectionProps; hostsProps: HostsSectionProps;
appearanceProps: AppearanceSectionProps; generalProps: GeneralSectionProps;
providersProps: ProvidersSectionProps; providersProps: ProvidersSectionProps;
diagnosticsProps: DiagnosticsSectionProps; diagnosticsProps: DiagnosticsSectionProps;
aboutProps: AboutSectionProps; aboutProps: AboutSectionProps;
@@ -625,7 +646,7 @@ interface SettingsSectionContentProps {
function SettingsSectionContent({ function SettingsSectionContent({
sectionId, sectionId,
hostsProps, hostsProps,
appearanceProps, generalProps,
providersProps, providersProps,
diagnosticsProps, diagnosticsProps,
aboutProps, aboutProps,
@@ -636,8 +657,8 @@ function SettingsSectionContent({
switch (sectionId) { switch (sectionId) {
case "hosts": case "hosts":
return <HostsSection {...hostsProps} />; return <HostsSection {...hostsProps} />;
case "appearance": case "general":
return <AppearanceSection {...appearanceProps} />; return <GeneralSection {...generalProps} />;
case "shortcuts": case "shortcuts":
return <KeyboardShortcutsSection />; return <KeyboardShortcutsSection />;
case "providers": case "providers":
@@ -1002,6 +1023,13 @@ export default function SettingsScreen() {
[updateSettings], [updateSettings],
); );
const handleSendBehaviorChange = useCallback(
(behavior: SendBehavior) => {
void updateSettings({ sendBehavior: behavior });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => { const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) { if (!voiceAudioEngine || isPlaybackTestRunning) {
return; return;
@@ -1068,9 +1096,10 @@ export default function SettingsScreen() {
isMountedRef, isMountedRef,
}; };
const appearanceProps: AppearanceSectionProps = { const generalProps: GeneralSectionProps = {
settings, settings,
handleThemeChange, handleThemeChange,
handleSendBehaviorChange,
}; };
const providersProps: ProvidersSectionProps = { const providersProps: ProvidersSectionProps = {
@@ -1091,7 +1120,7 @@ export default function SettingsScreen() {
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = { const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
hostsProps, hostsProps,
appearanceProps, generalProps,
providersProps, providersProps,
diagnosticsProps, diagnosticsProps,
aboutProps, aboutProps,
@@ -1900,6 +1929,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
audioRowSubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
providerActions: { providerActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",