Skip to content
Merged
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
58 changes: 57 additions & 1 deletion apps/mobile/src/app/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { FloatingSettingsHeader } from "@/features/settings/components/FloatingS
import { SettingsRow } from "@/features/settings/components/SettingsRow";
import { SettingsSection } from "@/features/settings/components/SettingsSection";
import { SelectSheet } from "@/features/tasks/composer/SelectSheet";
import {
type MessagingMode,
useMessagingModeStore,
} from "@/features/tasks/stores/messagingModeStore";
import { playCompletionSound } from "@/features/tasks/utils/sounds";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
Expand Down Expand Up @@ -71,6 +75,23 @@ const TASK_MODE_OPTIONS = [
},
] as const;

const MESSAGING_MODE_OPTIONS: ReadonlyArray<{
value: MessagingMode;
label: string;
description: string;
}> = [
{
value: "queue",
label: "Queue",
description: "Hold messages until the current turn finishes",
},
{
value: "steer",
label: "Steer",
description: "Interrupt the current turn and send right away",
},
];

const REASONING_EFFORT_OPTIONS: ReadonlyArray<{
value: DefaultReasoningEffort;
label: string;
Expand Down Expand Up @@ -111,6 +132,10 @@ function taskModeLabel(mode: InitialTaskMode): string {
return TASK_MODE_OPTIONS.find((o) => o.value === mode)?.label ?? "Plan";
}

function messagingModeLabel(mode: MessagingMode): string {
return MESSAGING_MODE_OPTIONS.find((o) => o.value === mode)?.label ?? "Queue";
}

function reasoningEffortLabel(effort: DefaultReasoningEffort): string {
return (
REASONING_EFFORT_OPTIONS.find((o) => o.value === effort)?.label ??
Expand Down Expand Up @@ -161,6 +186,10 @@ export default function SettingsScreen() {
const setDefaultReasoningEffort = usePreferencesStore(
(s) => s.setDefaultReasoningEffort,
);
const defaultMessagingMode = useMessagingModeStore((s) => s.defaultMode);
const setDefaultMessagingMode = useMessagingModeStore(
(s) => s.setDefaultMode,
);
const decidedCount = useDismissedReportsStore(
(s) => s.dismissedIds.length + s.acceptedIds.length,
);
Expand All @@ -173,6 +202,7 @@ export default function SettingsScreen() {
const [taskModeSheetOpen, setTaskModeSheetOpen] = useState(false);
const [reasoningEffortSheetOpen, setReasoningEffortSheetOpen] =
useState(false);
const [messagingModeSheetOpen, setMessagingModeSheetOpen] = useState(false);
const [projectSheetOpen, setProjectSheetOpen] = useState(false);

// The selected project's name. Prefer the names fetched for the scoped teams
Expand Down Expand Up @@ -349,7 +379,6 @@ export default function SettingsScreen() {
label="Default effort level"
description="Reasoning effort to pre-fill on new tasks"
onPress={() => setReasoningEffortSheetOpen(true)}
showDivider={false}
rightSlot={
<>
<Text className="text-[14px] text-gray-11">
Expand All @@ -359,6 +388,20 @@ export default function SettingsScreen() {
</>
}
/>
<SettingsRow
label="Messaging mode"
description="What happens when you send while a turn is running"
onPress={() => setMessagingModeSheetOpen(true)}
showDivider={false}
rightSlot={
<>
<Text className="text-[14px] text-gray-11">
{messagingModeLabel(defaultMessagingMode)}
</Text>
<CaretRight size={14} color={themeColors.gray[10]} />
</>
}
/>
</SettingsSection>

{/* Integrations */}
Expand Down Expand Up @@ -612,6 +655,19 @@ export default function SettingsScreen() {
}))}
/>

<SelectSheet
open={messagingModeSheetOpen}
title="Messaging mode"
value={defaultMessagingMode}
onChange={(value) => setDefaultMessagingMode(value as MessagingMode)}
onClose={() => setMessagingModeSheetOpen(false)}
options={MESSAGING_MODE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
}))}
/>

<SelectSheet
open={projectSheetOpen}
title="Active project"
Expand Down
67 changes: 63 additions & 4 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ import {
type ReasoningEffort,
} from "@/features/tasks/composer/options";
import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer";
import {
useMessagingMode,
useQueuedCount,
useToggleMessagingMode,
} from "@/features/tasks/hooks/useMessagingMode";
import { taskKeys } from "@/features/tasks/hooks/useTasks";
import { useMessageQueueStore } from "@/features/tasks/stores/messageQueueStore";
import {
pendingTaskPromptStoreApi,
usePendingTaskPrompt,
Expand All @@ -41,7 +47,11 @@ import { useTaskStore } from "@/features/tasks/stores/taskStore";
import type { Task } from "@/features/tasks/types";
import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useActiveTaskAnalyticsContext } from "@/lib/analytics";
import {
ANALYTICS_EVENTS,
useActiveTaskAnalyticsContext,
useAnalytics,
} from "@/lib/analytics";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";

Expand Down Expand Up @@ -81,6 +91,7 @@ export default function TaskDetailScreen() {
disconnectFromTask,
sendPrompt,
cancelPrompt,
sendInterrupting,
sendPermissionResponse,
setConfigOption,
getSessionForTask,
Expand Down Expand Up @@ -147,6 +158,11 @@ export default function TaskDetailScreen() {
const composerReasoning: ReasoningEffort =
composerConfig?.reasoning ?? DEFAULT_REASONING;

const messagingMode = useMessagingMode(taskId);
const queuedCount = useQueuedCount(taskId);
const toggleMessagingMode = useToggleMessagingMode(taskId);
const analytics = useAnalytics();

const { height } = useReanimatedKeyboardAnimation();

// useReanimatedKeyboardAnimation returns negative height values
Expand Down Expand Up @@ -309,6 +325,20 @@ export default function TaskDetailScreen() {
],
);

const trackPromptSent = useCallback(
(text: string, isSteer: boolean) => {
if (!taskId) return;
analytics.track(ANALYTICS_EVENTS.PROMPT_SENT, {
task_id: taskId,
is_initial: false,
execution_type: "cloud",
prompt_length_chars: text.length,
is_steer: isSteer,
});
},
[taskId, analytics],
);

const handleSendPrompt = useCallback(
(text: string, attachments: PendingAttachment[]) => {
if (!taskId) return;
Expand All @@ -319,15 +349,41 @@ export default function TaskDetailScreen() {
return;
}

sendPrompt(taskId, text, attachments).catch((err) => {
const onSendFailed = (err: unknown) => {
log.error("Failed to send prompt", err);
Alert.alert(
"Failed to send",
"Your message could not be delivered. Please try again.",
);
});
};

// A turn is running. Queue holds the message locally until it ends;
// Steer interrupts the turn and resends right away.
if (session?.isPromptPending) {
if (messagingMode === "queue") {
useMessageQueueStore.getState().enqueue(taskId, text, attachments);
return;
}
sendInterrupting(taskId, text, attachments)
.then(() => trackPromptSent(text, true))
.catch(onSendFailed);
return;
}

sendPrompt(taskId, text, attachments)
.then(() => trackPromptSent(text, false))
.catch(onSendFailed);
},
[taskId, sendPrompt, session?.terminalStatus, handleSendAfterTerminal],
[
taskId,
sendPrompt,
sendInterrupting,
session?.terminalStatus,
session?.isPromptPending,
messagingMode,
handleSendAfterTerminal,
trackPromptSent,
],
);

const handleModeChange = useCallback(
Expand Down Expand Up @@ -577,6 +633,9 @@ export default function TaskDetailScreen() {
onModeChange={handleModeChange}
onModelChange={handleModelChange}
onReasoningChange={handleReasoningChange}
messagingMode={messagingMode}
queuedCount={queuedCount}
onToggleMessagingMode={toggleMessagingMode}
/>
</Animated.View>
</Animated.View>
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import * as Haptics from "expo-haptics";
import {
ArrowUp,
BrainIcon,
Lightning,
Microphone,
PaperclipIcon,
PauseIcon,
PencilIcon,
Robot,
ShieldCheck,
Sparkle,
Stack,
Stop,
} from "phosphor-react-native";
import {
Expand All @@ -31,6 +33,7 @@ import {
import { useVoiceRecording } from "@/features/chat";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
import type { MessagingMode } from "../stores/messagingModeStore";
import { AttachmentSheet } from "./attachments/AttachmentSheet";
import { AttachmentsBar } from "./attachments/AttachmentsBar";
import {
Expand Down Expand Up @@ -72,6 +75,10 @@ interface TaskChatComposerProps {
onModeChange: (mode: ExecutionMode) => void;
onModelChange: (model: string) => void;
onReasoningChange: (reasoning: ReasoningEffort) => void;
/** Steer vs Queue behaviour for messages sent while a turn is running. */
messagingMode: MessagingMode;
queuedCount: number;
onToggleMessagingMode: () => void;
}

function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode {
Expand Down Expand Up @@ -154,6 +161,9 @@ export function TaskChatComposer({
onModeChange,
onModelChange,
onReasoningChange,
messagingMode,
queuedCount,
onToggleMessagingMode,
}: TaskChatComposerProps) {
const themeColors = useThemeColors();
const [message, setMessage] = useState(() => initialMessage ?? "");
Expand Down Expand Up @@ -229,6 +239,18 @@ export function TaskChatComposer({
onStop?.();
};

const isSteer = messagingMode === "steer";
const messagingModeLabel = isSteer
? "Steer"
: queuedCount > 0
? `Queue (${queuedCount})`
: "Queue";

const handleToggleMessagingMode = () => {
Haptics.selectionAsync();
onToggleMessagingMode();
};

return (
<>
<View className="px-3">
Expand Down Expand Up @@ -288,6 +310,23 @@ export function TaskChatComposer({
paddingRight: 4,
}}
>
<Pill
icon={
isSteer ? (
<Lightning
size={14}
color={themeColors.accent[11]}
weight="fill"
/>
) : (
<Stack size={14} color={themeColors.gray[11]} />
)
}
label={messagingModeLabel}
accent={isSteer}
onPress={handleToggleMessagingMode}
/>

<Pill
icon={modeIcon(
mode,
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/features/tasks/hooks/useMessagingMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useCallback } from "react";
import { useMessageQueueStore } from "../stores/messageQueueStore";
import {
type MessagingMode,
useMessagingModeStore,
} from "../stores/messagingModeStore";
import { useTaskSessionStore } from "../stores/taskSessionStore";

/** Effective mode for a task: per-task override, else the global default. */
export function useMessagingMode(taskId: string | undefined): MessagingMode {
return useMessagingModeStore((s) => s.getEffectiveMode(taskId));
}

export function useQueuedCount(taskId: string | undefined): number {
return useMessageQueueStore((s) => (taskId ? s.getQueue(taskId).length : 0));
}

/**
* Toggle the per-task messaging mode. Switching to Steer flushes any buffered
* messages into the current turn so nothing stays stuck in a queue the user
* just turned off.
*/
export function useToggleMessagingMode(taskId: string | undefined): () => void {
const mode = useMessagingMode(taskId);
return useCallback(() => {
if (!taskId) return;
const next: MessagingMode = mode === "steer" ? "queue" : "steer";
useMessagingModeStore.getState().setMode(taskId, next);
if (next === "steer") {
void useTaskSessionStore.getState().flushQueuedMessages(taskId);
}
}, [taskId, mode]);
}
Loading
Loading