Skip to content

Commit 16fc41f

Browse files
authored
feat(mobile): add steer and queue messaging modes (port #2663)
Adds a per-task messaging mode toggle — Steer vs Queue — controlling what happens when the user sends a message while a session turn is still running. - Queue (default): messages are held locally while a turn is running and flushed as a single combined prompt (in order) when the turn ends. - Steer: interrupts the running turn and resends immediately. Mobile is cloud-only with no native mid-turn inject, so steer maps to interrupt-and-resend via the existing cloud cancel command. New state: - messagingModeStore: persisted per-task override + global default (Queue). - messageQueueStore: in-memory buffer with combine/drain/prepend primitives. The composer gains a touch-friendly toggle pill showing the current mode and the queued count; settings gains a global default. Sends emit a "Prompt sent" analytics event carrying is_steer. Generated-By: PostHog Code Task-Id: 148dc71c-a24a-4b9a-a9e6-04586e782678
1 parent e1ba833 commit 16fc41f

11 files changed

Lines changed: 521 additions & 5 deletions

File tree

apps/mobile/src/app/settings/index.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import { FloatingSettingsHeader } from "@/features/settings/components/FloatingS
1919
import { SettingsRow } from "@/features/settings/components/SettingsRow";
2020
import { SettingsSection } from "@/features/settings/components/SettingsSection";
2121
import { SelectSheet } from "@/features/tasks/composer/SelectSheet";
22+
import {
23+
type MessagingMode,
24+
useMessagingModeStore,
25+
} from "@/features/tasks/stores/messagingModeStore";
2226
import { playCompletionSound } from "@/features/tasks/utils/sounds";
2327
import { useScreenInsets } from "@/hooks/useScreenInsets";
2428
import { logger } from "@/lib/logger";
@@ -71,6 +75,23 @@ const TASK_MODE_OPTIONS = [
7175
},
7276
] as const;
7377

78+
const MESSAGING_MODE_OPTIONS: ReadonlyArray<{
79+
value: MessagingMode;
80+
label: string;
81+
description: string;
82+
}> = [
83+
{
84+
value: "queue",
85+
label: "Queue",
86+
description: "Hold messages until the current turn finishes",
87+
},
88+
{
89+
value: "steer",
90+
label: "Steer",
91+
description: "Interrupt the current turn and send right away",
92+
},
93+
];
94+
7495
const REASONING_EFFORT_OPTIONS: ReadonlyArray<{
7596
value: DefaultReasoningEffort;
7697
label: string;
@@ -111,6 +132,10 @@ function taskModeLabel(mode: InitialTaskMode): string {
111132
return TASK_MODE_OPTIONS.find((o) => o.value === mode)?.label ?? "Plan";
112133
}
113134

135+
function messagingModeLabel(mode: MessagingMode): string {
136+
return MESSAGING_MODE_OPTIONS.find((o) => o.value === mode)?.label ?? "Queue";
137+
}
138+
114139
function reasoningEffortLabel(effort: DefaultReasoningEffort): string {
115140
return (
116141
REASONING_EFFORT_OPTIONS.find((o) => o.value === effort)?.label ??
@@ -161,6 +186,10 @@ export default function SettingsScreen() {
161186
const setDefaultReasoningEffort = usePreferencesStore(
162187
(s) => s.setDefaultReasoningEffort,
163188
);
189+
const defaultMessagingMode = useMessagingModeStore((s) => s.defaultMode);
190+
const setDefaultMessagingMode = useMessagingModeStore(
191+
(s) => s.setDefaultMode,
192+
);
164193
const decidedCount = useDismissedReportsStore(
165194
(s) => s.dismissedIds.length + s.acceptedIds.length,
166195
);
@@ -173,6 +202,7 @@ export default function SettingsScreen() {
173202
const [taskModeSheetOpen, setTaskModeSheetOpen] = useState(false);
174203
const [reasoningEffortSheetOpen, setReasoningEffortSheetOpen] =
175204
useState(false);
205+
const [messagingModeSheetOpen, setMessagingModeSheetOpen] = useState(false);
176206
const [projectSheetOpen, setProjectSheetOpen] = useState(false);
177207

178208
// The selected project's name. Prefer the names fetched for the scoped teams
@@ -349,7 +379,6 @@ export default function SettingsScreen() {
349379
label="Default effort level"
350380
description="Reasoning effort to pre-fill on new tasks"
351381
onPress={() => setReasoningEffortSheetOpen(true)}
352-
showDivider={false}
353382
rightSlot={
354383
<>
355384
<Text className="text-[14px] text-gray-11">
@@ -359,6 +388,20 @@ export default function SettingsScreen() {
359388
</>
360389
}
361390
/>
391+
<SettingsRow
392+
label="Messaging mode"
393+
description="What happens when you send while a turn is running"
394+
onPress={() => setMessagingModeSheetOpen(true)}
395+
showDivider={false}
396+
rightSlot={
397+
<>
398+
<Text className="text-[14px] text-gray-11">
399+
{messagingModeLabel(defaultMessagingMode)}
400+
</Text>
401+
<CaretRight size={14} color={themeColors.gray[10]} />
402+
</>
403+
}
404+
/>
362405
</SettingsSection>
363406

364407
{/* Integrations */}
@@ -612,6 +655,19 @@ export default function SettingsScreen() {
612655
}))}
613656
/>
614657

658+
<SelectSheet
659+
open={messagingModeSheetOpen}
660+
title="Messaging mode"
661+
value={defaultMessagingMode}
662+
onChange={(value) => setDefaultMessagingMode(value as MessagingMode)}
663+
onClose={() => setMessagingModeSheetOpen(false)}
664+
options={MESSAGING_MODE_OPTIONS.map((option) => ({
665+
value: option.value,
666+
label: option.label,
667+
description: option.description,
668+
}))}
669+
/>
670+
615671
<SelectSheet
616672
open={projectSheetOpen}
617673
title="Active project"

apps/mobile/src/app/task/[id].tsx

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ import {
3131
type ReasoningEffort,
3232
} from "@/features/tasks/composer/options";
3333
import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer";
34+
import {
35+
useMessagingMode,
36+
useQueuedCount,
37+
useToggleMessagingMode,
38+
} from "@/features/tasks/hooks/useMessagingMode";
3439
import { taskKeys } from "@/features/tasks/hooks/useTasks";
40+
import { useMessageQueueStore } from "@/features/tasks/stores/messageQueueStore";
3541
import {
3642
pendingTaskPromptStoreApi,
3743
usePendingTaskPrompt,
@@ -41,7 +47,11 @@ import { useTaskStore } from "@/features/tasks/stores/taskStore";
4147
import type { Task } from "@/features/tasks/types";
4248
import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity";
4349
import { useScreenInsets } from "@/hooks/useScreenInsets";
44-
import { useActiveTaskAnalyticsContext } from "@/lib/analytics";
50+
import {
51+
ANALYTICS_EVENTS,
52+
useActiveTaskAnalyticsContext,
53+
useAnalytics,
54+
} from "@/lib/analytics";
4555
import { logger } from "@/lib/logger";
4656
import { useThemeColors } from "@/lib/theme";
4757

@@ -81,6 +91,7 @@ export default function TaskDetailScreen() {
8191
disconnectFromTask,
8292
sendPrompt,
8393
cancelPrompt,
94+
sendInterrupting,
8495
sendPermissionResponse,
8596
setConfigOption,
8697
getSessionForTask,
@@ -147,6 +158,11 @@ export default function TaskDetailScreen() {
147158
const composerReasoning: ReasoningEffort =
148159
composerConfig?.reasoning ?? DEFAULT_REASONING;
149160

161+
const messagingMode = useMessagingMode(taskId);
162+
const queuedCount = useQueuedCount(taskId);
163+
const toggleMessagingMode = useToggleMessagingMode(taskId);
164+
const analytics = useAnalytics();
165+
150166
const { height } = useReanimatedKeyboardAnimation();
151167

152168
// useReanimatedKeyboardAnimation returns negative height values
@@ -309,6 +325,20 @@ export default function TaskDetailScreen() {
309325
],
310326
);
311327

328+
const trackPromptSent = useCallback(
329+
(text: string, isSteer: boolean) => {
330+
if (!taskId) return;
331+
analytics.track(ANALYTICS_EVENTS.PROMPT_SENT, {
332+
task_id: taskId,
333+
is_initial: false,
334+
execution_type: "cloud",
335+
prompt_length_chars: text.length,
336+
is_steer: isSteer,
337+
});
338+
},
339+
[taskId, analytics],
340+
);
341+
312342
const handleSendPrompt = useCallback(
313343
(text: string, attachments: PendingAttachment[]) => {
314344
if (!taskId) return;
@@ -319,15 +349,41 @@ export default function TaskDetailScreen() {
319349
return;
320350
}
321351

322-
sendPrompt(taskId, text, attachments).catch((err) => {
352+
const onSendFailed = (err: unknown) => {
323353
log.error("Failed to send prompt", err);
324354
Alert.alert(
325355
"Failed to send",
326356
"Your message could not be delivered. Please try again.",
327357
);
328-
});
358+
};
359+
360+
// A turn is running. Queue holds the message locally until it ends;
361+
// Steer interrupts the turn and resends right away.
362+
if (session?.isPromptPending) {
363+
if (messagingMode === "queue") {
364+
useMessageQueueStore.getState().enqueue(taskId, text, attachments);
365+
return;
366+
}
367+
sendInterrupting(taskId, text, attachments)
368+
.then(() => trackPromptSent(text, true))
369+
.catch(onSendFailed);
370+
return;
371+
}
372+
373+
sendPrompt(taskId, text, attachments)
374+
.then(() => trackPromptSent(text, false))
375+
.catch(onSendFailed);
329376
},
330-
[taskId, sendPrompt, session?.terminalStatus, handleSendAfterTerminal],
377+
[
378+
taskId,
379+
sendPrompt,
380+
sendInterrupting,
381+
session?.terminalStatus,
382+
session?.isPromptPending,
383+
messagingMode,
384+
handleSendAfterTerminal,
385+
trackPromptSent,
386+
],
331387
);
332388

333389
const handleModeChange = useCallback(
@@ -577,6 +633,9 @@ export default function TaskDetailScreen() {
577633
onModeChange={handleModeChange}
578634
onModelChange={handleModelChange}
579635
onReasoningChange={handleReasoningChange}
636+
messagingMode={messagingMode}
637+
queuedCount={queuedCount}
638+
onToggleMessagingMode={toggleMessagingMode}
580639
/>
581640
</Animated.View>
582641
</Animated.View>

apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import * as Haptics from "expo-haptics";
22
import {
33
ArrowUp,
44
BrainIcon,
5+
Lightning,
56
Microphone,
67
PaperclipIcon,
78
PauseIcon,
89
PencilIcon,
910
Robot,
1011
ShieldCheck,
1112
Sparkle,
13+
Stack,
1214
Stop,
1315
} from "phosphor-react-native";
1416
import {
@@ -31,6 +33,7 @@ import {
3133
import { useVoiceRecording } from "@/features/chat";
3234
import { logger } from "@/lib/logger";
3335
import { useThemeColors } from "@/lib/theme";
36+
import type { MessagingMode } from "../stores/messagingModeStore";
3437
import { AttachmentSheet } from "./attachments/AttachmentSheet";
3538
import { AttachmentsBar } from "./attachments/AttachmentsBar";
3639
import {
@@ -72,6 +75,10 @@ interface TaskChatComposerProps {
7275
onModeChange: (mode: ExecutionMode) => void;
7376
onModelChange: (model: string) => void;
7477
onReasoningChange: (reasoning: ReasoningEffort) => void;
78+
/** Steer vs Queue behaviour for messages sent while a turn is running. */
79+
messagingMode: MessagingMode;
80+
queuedCount: number;
81+
onToggleMessagingMode: () => void;
7582
}
7683

7784
function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode {
@@ -154,6 +161,9 @@ export function TaskChatComposer({
154161
onModeChange,
155162
onModelChange,
156163
onReasoningChange,
164+
messagingMode,
165+
queuedCount,
166+
onToggleMessagingMode,
157167
}: TaskChatComposerProps) {
158168
const themeColors = useThemeColors();
159169
const [message, setMessage] = useState(() => initialMessage ?? "");
@@ -229,6 +239,18 @@ export function TaskChatComposer({
229239
onStop?.();
230240
};
231241

242+
const isSteer = messagingMode === "steer";
243+
const messagingModeLabel = isSteer
244+
? "Steer"
245+
: queuedCount > 0
246+
? `Queue (${queuedCount})`
247+
: "Queue";
248+
249+
const handleToggleMessagingMode = () => {
250+
Haptics.selectionAsync();
251+
onToggleMessagingMode();
252+
};
253+
232254
return (
233255
<>
234256
<View className="px-3">
@@ -288,6 +310,23 @@ export function TaskChatComposer({
288310
paddingRight: 4,
289311
}}
290312
>
313+
<Pill
314+
icon={
315+
isSteer ? (
316+
<Lightning
317+
size={14}
318+
color={themeColors.accent[11]}
319+
weight="fill"
320+
/>
321+
) : (
322+
<Stack size={14} color={themeColors.gray[11]} />
323+
)
324+
}
325+
label={messagingModeLabel}
326+
accent={isSteer}
327+
onPress={handleToggleMessagingMode}
328+
/>
329+
291330
<Pill
292331
icon={modeIcon(
293332
mode,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useCallback } from "react";
2+
import { useMessageQueueStore } from "../stores/messageQueueStore";
3+
import {
4+
type MessagingMode,
5+
useMessagingModeStore,
6+
} from "../stores/messagingModeStore";
7+
import { useTaskSessionStore } from "../stores/taskSessionStore";
8+
9+
/** Effective mode for a task: per-task override, else the global default. */
10+
export function useMessagingMode(taskId: string | undefined): MessagingMode {
11+
return useMessagingModeStore((s) => s.getEffectiveMode(taskId));
12+
}
13+
14+
export function useQueuedCount(taskId: string | undefined): number {
15+
return useMessageQueueStore((s) => (taskId ? s.getQueue(taskId).length : 0));
16+
}
17+
18+
/**
19+
* Toggle the per-task messaging mode. Switching to Steer flushes any buffered
20+
* messages into the current turn so nothing stays stuck in a queue the user
21+
* just turned off.
22+
*/
23+
export function useToggleMessagingMode(taskId: string | undefined): () => void {
24+
const mode = useMessagingMode(taskId);
25+
return useCallback(() => {
26+
if (!taskId) return;
27+
const next: MessagingMode = mode === "steer" ? "queue" : "steer";
28+
useMessagingModeStore.getState().setMode(taskId, next);
29+
if (next === "steer") {
30+
void useTaskSessionStore.getState().flushQueuedMessages(taskId);
31+
}
32+
}, [taskId, mode]);
33+
}

0 commit comments

Comments
 (0)