Skip to content

feat(mobile): add steer and queue messaging modes (port #2663)#2752

Merged
charlesvien merged 1 commit into
mainfrom
posthog-code/mobile-steer-queue-messaging-modes
Jun 18, 2026
Merged

feat(mobile): add steer and queue messaging modes (port #2663)#2752
charlesvien merged 1 commit into
mainfrom
posthog-code/mobile-steer-queue-messaging-modes

Conversation

@Gilbert09

Copy link
Copy Markdown
Member

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:

  • Queue (default): messages are held locally while a turn is running, then flushed as a single combined prompt — in the order they were typed — once the turn ends.
  • Steer: the message interrupts the running turn and is resent right away. Mobile is cloud-only and has no native mid-turn inject, so Steer maps to interrupt-and-resend (cancel the live turn via the existing cloud cancel command, 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's zustand + persist + AsyncStorage pattern.
  • messageQueueStore — lightweight in-memory queue with enqueue / drain / prepend and a pure combineQueuedMessages helper (FIFO order preserved, attachments concatenated).
  • taskSessionStore — a single sendInterrupting chokepoint 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)).
  • Settings — a global "Messaging mode" default.
  • Analytics — sends emit a Prompt sent event carrying is_steer.

All changes are confined to apps/mobile; no desktop or shared package code is touched.

Testing

  • Unit tests for the mode store (override vs global default resolution) and the queue store (FIFO ordering, drain-clears, failed-flush rollback ordering, combine ordering).
  • vitest run — 41 files / 280 tests pass.
  • Typecheck and Biome lint clean on the touched files.

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
@Gilbert09 Gilbert09 requested a review from a team June 18, 2026 10:55
@github-actions

Copy link
Copy Markdown

React Doctor found 1 issue in 1 file · 1 warning.

1 warning

src/features/tasks/composer/TaskChatComposer.tsx

Reviewed by React Doctor for commit 16fc41f.

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix 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

Comment on lines +766 to +773
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);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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.

Comment on lines +775 to +795
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);
}
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +110 to 111
Stack: icon("Stack"),
Stop: icon("Stop"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

@charlesvien charlesvien merged commit b54d8c3 into main Jun 18, 2026
20 checks passed
@charlesvien charlesvien deleted the posthog-code/mobile-steer-queue-messaging-modes branch June 18, 2026 20:13
Gilbert09 added a commit that referenced this pull request Jun 19, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants