feat(mobile): add steer and queue messaging modes (port #2663)#2752
Conversation
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
|
React Doctor found 1 issue in 1 file · 1 warning. 1 warning
Reviewed by React Doctor for commit |
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
apps/mobile/src/features/tasks/stores/taskSessionStore.ts:766-773
`cancelPrompt` returns `false` on network failure without throwing — it swallows the error internally. `sendInterrupting` ignores that return value and proceeds to call `sendPrompt` unconditionally. If the cancel request fails, the server-side turn is still active when the new prompt is sent, which can result in two prompts racing on the server with no user-visible error message.
```suggestion
sendInterrupting: async (taskId, prompt, attachments) => {
// The cloud has no mid-turn inject, so steering interrupts the running
// turn and resends as a fresh prompt.
if (get().getSessionForTask(taskId)?.isPromptPending) {
const cancelled = await get().cancelPrompt(taskId);
if (!cancelled) {
throw new Error("Failed to cancel running turn before interrupting");
}
}
await get().sendPrompt(taskId, prompt, attachments);
},
```
### Issue 2 of 3
apps/mobile/src/features/tasks/stores/taskSessionStore.ts:775-795
**Queue auto-flush emits no analytics**
`flushQueuedMessages` combines and sends queued messages via `sendInterrupting`, but never calls `trackPromptSent`. Every message that entered the queue and was flushed after a turn ended will be silently absent from `PROMPT_SENT` analytics, making it impossible to accurately measure prompt volume for users in Queue mode.
### Issue 3 of 3
apps/mobile/src/test/setup.ts:110-111
`TaskChatComposer.tsx` imports both `Lightning` and `Stack` as new icons in this PR, but only `Stack` was added to the `phosphor-react-native` mock. Any future component render test for `TaskChatComposer` will throw a missing-mock error for `Lightning`.
```suggestion
Lightning: icon("Lightning"),
Stack: icon("Stack"),
Stop: icon("Stop"),
```
Reviews (1): Last reviewed commit: "feat(mobile): add steer and queue messag..." | Re-trigger Greptile |
| sendInterrupting: async (taskId, prompt, attachments) => { | ||
| // The cloud has no mid-turn inject, so steering interrupts the running | ||
| // turn and resends as a fresh prompt. | ||
| if (get().getSessionForTask(taskId)?.isPromptPending) { | ||
| await get().cancelPrompt(taskId); | ||
| } | ||
| await get().sendPrompt(taskId, prompt, attachments); | ||
| }, |
There was a problem hiding this comment.
cancelPrompt returns false on network failure without throwing — it swallows the error internally. sendInterrupting ignores that return value and proceeds to call sendPrompt unconditionally. If the cancel request fails, the server-side turn is still active when the new prompt is sent, which can result in two prompts racing on the server with no user-visible error message.
| sendInterrupting: async (taskId, prompt, attachments) => { | |
| // The cloud has no mid-turn inject, so steering interrupts the running | |
| // turn and resends as a fresh prompt. | |
| if (get().getSessionForTask(taskId)?.isPromptPending) { | |
| await get().cancelPrompt(taskId); | |
| } | |
| await get().sendPrompt(taskId, prompt, attachments); | |
| }, | |
| sendInterrupting: async (taskId, prompt, attachments) => { | |
| // The cloud has no mid-turn inject, so steering interrupts the running | |
| // turn and resends as a fresh prompt. | |
| if (get().getSessionForTask(taskId)?.isPromptPending) { | |
| const cancelled = await get().cancelPrompt(taskId); | |
| if (!cancelled) { | |
| throw new Error("Failed to cancel running turn before interrupting"); | |
| } | |
| } | |
| await get().sendPrompt(taskId, prompt, attachments); | |
| }, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/features/tasks/stores/taskSessionStore.ts
Line: 766-773
Comment:
`cancelPrompt` returns `false` on network failure without throwing — it swallows the error internally. `sendInterrupting` ignores that return value and proceeds to call `sendPrompt` unconditionally. If the cancel request fails, the server-side turn is still active when the new prompt is sent, which can result in two prompts racing on the server with no user-visible error message.
```suggestion
sendInterrupting: async (taskId, prompt, attachments) => {
// The cloud has no mid-turn inject, so steering interrupts the running
// turn and resends as a fresh prompt.
if (get().getSessionForTask(taskId)?.isPromptPending) {
const cancelled = await get().cancelPrompt(taskId);
if (!cancelled) {
throw new Error("Failed to cancel running turn before interrupting");
}
}
await get().sendPrompt(taskId, prompt, attachments);
},
```
How can I resolve this? If you propose a fix, please make it concise.| flushQueuedMessages: async (taskId: string) => { | ||
| if (flushingTasks.has(taskId)) return; | ||
| flushingTasks.add(taskId); | ||
| try { | ||
| const drained = useMessageQueueStore.getState().drain(taskId); | ||
| if (drained.length === 0) return; | ||
|
|
||
| const { text, attachments } = combineQueuedMessages(drained); | ||
| try { | ||
| await get().sendInterrupting(taskId, text, attachments); | ||
| } catch (err) { | ||
| log.warn("Failed to flush queued messages, restoring queue", { | ||
| taskId, | ||
| error: err, | ||
| }); | ||
| useMessageQueueStore.getState().prepend(taskId, drained); | ||
| } | ||
| } finally { | ||
| flushingTasks.delete(taskId); | ||
| } | ||
| }, |
There was a problem hiding this comment.
Queue auto-flush emits no analytics
flushQueuedMessages combines and sends queued messages via sendInterrupting, but never calls trackPromptSent. Every message that entered the queue and was flushed after a turn ended will be silently absent from PROMPT_SENT analytics, making it impossible to accurately measure prompt volume for users in Queue mode.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/features/tasks/stores/taskSessionStore.ts
Line: 775-795
Comment:
**Queue auto-flush emits no analytics**
`flushQueuedMessages` combines and sends queued messages via `sendInterrupting`, but never calls `trackPromptSent`. Every message that entered the queue and was flushed after a turn ended will be silently absent from `PROMPT_SENT` analytics, making it impossible to accurately measure prompt volume for users in Queue mode.
How can I resolve this? If you propose a fix, please make it concise.| Stack: icon("Stack"), | ||
| Stop: icon("Stop"), |
There was a problem hiding this comment.
TaskChatComposer.tsx imports both Lightning and Stack as new icons in this PR, but only Stack was added to the phosphor-react-native mock. Any future component render test for TaskChatComposer will throw a missing-mock error for Lightning.
| Stack: icon("Stack"), | |
| Stop: icon("Stop"), | |
| Lightning: icon("Lightning"), | |
| Stack: icon("Stack"), | |
| Stop: icon("Stop"), |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/test/setup.ts
Line: 110-111
Comment:
`TaskChatComposer.tsx` imports both `Lightning` and `Stack` as new icons in this PR, but only `Stack` was added to the `phosphor-react-native` mock. Any future component render test for `TaskChatComposer` will throw a missing-mock error for `Lightning`.
```suggestion
Lightning: icon("Lightning"),
Stack: icon("Stack"),
Stop: icon("Stop"),
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Mobile already supports queuing follow-up messages while a cloud task runs (#2752), but queued messages could only be counted, not acted on. This adds per-message management, mirroring the desktop dock from #2768 with native mobile interaction. - Queued messages now render in a dock above the composer; tapping one opens a bottom-sheet with Steer now / Edit in composer / Discard. - Steer now: drop the message from the queue and resend it as a steer (interrupt + resend), rolling it back onto the head if the resend fails so it is never silently lost. No-ops while the task is compacting. - Edit in composer: pull the message (text + attachments) back into the composer to revise before resending. - Discard: remove a single queued message by id. - Track compaction state on the session from the cloud status stream so steer can be gated. Adds tests for queue removal and steer rollback-on-failure / compaction no-op. Generated-By: PostHog Code Task-Id: 899edfb9-ddb5-4e11-b640-917d167266bc
Ports the desktop Steer & Queue messaging modes (#2663) to the React Native / Expo mobile app.
What this adds
A per-task messaging mode toggle controlling what happens when you send a message while a session turn is still running:
cancelcommand, then resend as a fresh prompt).Changes
messagingModeStore— persisted per-task mode override plus a global default (defaults to Queue), mirroring the desktop store but using the app'szustand + persist + AsyncStoragepattern.messageQueueStore— lightweight in-memory queue withenqueue/drain/prependand a purecombineQueuedMessageshelper (FIFO order preserved, attachments concatenated).taskSessionStore— a singlesendInterruptingchokepoint for interrupt-and-resend, and an auto-flush that drains the queue when a turn ends. Failed flushes roll the messages back onto the head of the queue.TaskChatComposer— a touch-friendly toggle pill showing the current mode and the queued count (Queue (3)).Prompt sentevent carryingis_steer.All changes are confined to
apps/mobile; no desktop or shared package code is touched.Testing
vitest run— 41 files / 280 tests pass.