Queue nudges while plan generating (#10457)

## Summary

This PR fixes a UI/streaming race when nudged or steer-enabled messages
are queued during an active Plan stream.

Previously, `submit_user_message_with_mode` switched collaboration mode
immediately (via `set_collaboration_mask`) even when the message was
queued. If that happened mid-Plan stream, `active_mode_kind` could flip
away from Plan before the turn finished, causing subsequent
`on_plan_delta` updates to be ignored in the UI.

Now, mode switching is deferred until the queued message is actually
submitted.

## What changed

- Added a per-message deferred mode override on `UserMessage`:
  - `collaboration_mode_override: Option<CollaborationModeMask>`
- Updated `submit_user_message_with_mode` to:
  - create a `UserMessage` carrying the mode override
- queue or submit that message without mutating global mode immediately
- Updated `submit_user_message` to:
- apply `collaboration_mode_override` just before constructing/sending
`Op::UserTurn`
- Kept queueing condition scoped to active Plan stream rendering:
- queue only while plan output is actively streaming in TUI
(`plan_stream_controller.is_some()`)

## Why

This preserves Plan mode for the remainder of the in-flight Plan turn,
so streamed plan deltas continue rendering correctly, while still
ensuring the follow-up queued message is sent with the intended
collaboration mode.

## Behavior after this change

- If a nudged/steer submission happens while Plan output is actively
streaming:
  - message is queued
  - UI stays in Plan mode for the running turn
- once dequeued/submitted, mode override is applied and the message is
sent in the intended mode
- If no Plan stream is active:
- submission proceeds immediately and mode override is applied as before

## Tests

Added/updated coverage in `tui/src/chatwidget/tests.rs`:

- `submit_user_message_with_mode_queues_while_plan_stream_is_active`
  - asserts mode remains Plan while queued
- asserts mode switches to Code when queued message is actually
submitted
- `submit_user_message_with_mode_submits_when_plan_stream_is_not_active`
- `steer_enter_queues_while_plan_stream_is_active`
- `steer_enter_submits_when_plan_stream_is_not_active`

Also updated existing `UserMessage { ... }` test fixtures to include the
new field.

## Codex author
`codex fork 019c1047-d5d5-7c92-a357-6009604dc7e8`
This commit is contained in:
Charley Cunningham authored and GitHub committed 2026-02-06 09:43:00 -08:00
1 parent 4521a6e852
commit b7ecd166a6
2 files changed
+228 -23

No files matched your search

+40 -23
View File
@@ -1643,16 +1643,7 @@ impl ChatWidget {
}
if let Some(combined) = self.drain_queued_messages_for_restore() {
let combined_local_image_paths = combined
.local_images
.iter()
.map(|img| img.path.clone())
.collect();
self.bottom_pane.set_composer_text(
combined.text,
combined.text_elements,
combined_local_image_paths,
);
self.restore_user_message_to_composer(combined);
self.refresh_queued_user_messages();
}
@@ -1715,6 +1706,18 @@ impl ChatWidget {
Some(combined)
}
fn restore_user_message_to_composer(&mut self, user_message: UserMessage) {
let UserMessage {
text,
local_images,
text_elements,
mention_paths: _,
} = user_message;
let local_image_paths = local_images.into_iter().map(|img| img.path).collect();
self.bottom_pane
.set_composer_text(text, text_elements, local_image_paths);
}
fn on_plan_update(&mut self, update: UpdatePlanArgs) {
self.saw_plan_update_this_turn = true;
self.add_to_history(history_cell::new_plan_update(update));
@@ -3003,16 +3006,7 @@ impl ChatWidget {
} if !self.queued_user_messages.is_empty() => {
// Prefer the most recently queued item.
if let Some(user_message) = self.queued_user_messages.pop_back() {
let local_image_paths = user_message
.local_images
.iter()
.map(|img| img.path.clone())
.collect();
self.bottom_pane.set_composer_text(
user_message.text,
user_message.text_elements,
local_image_paths,
);
self.restore_user_message_to_composer(user_message);
self.refresh_queued_user_messages();
self.request_redraw();
}
@@ -3030,8 +3024,8 @@ impl ChatWidget {
text_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
};
if self.is_session_configured() {
// Submitted is only emitted when steer is enabled (Enter sends immediately).
if self.is_session_configured() && !self.is_plan_streaming_in_tui() {
// Submitted is only emitted when steer is enabled.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
@@ -6415,6 +6409,10 @@ impl ChatWidget {
self.bottom_pane.is_task_running() || self.is_review_mode
}
fn is_plan_streaming_in_tui(&self) -> bool {
self.plan_stream_controller.is_some()
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.bottom_pane.composer_is_empty()
}
@@ -6424,8 +6422,27 @@ impl ChatWidget {
text: String,
collaboration_mode: CollaborationModeMask,
) {
if self.agent_turn_running
&& self.active_collaboration_mask.as_ref() != Some(&collaboration_mode)
{
self.add_error_message(
"Cannot switch collaboration mode while a turn is running.".to_string(),
);
return;
}
self.set_collaboration_mask(collaboration_mode);
self.submit_user_message(text.into());
let should_queue = self.is_plan_streaming_in_tui();
let user_message = UserMessage {
text,
local_images: Vec::new(),
text_elements: Vec::new(),
mention_paths: HashMap::new(),
};
if should_queue {
self.queue_user_message(user_message);
} else {
self.submit_user_message(user_message);
}
}
/// True when the UI is in the regular composer state with no running task,