From 69ecd31b02cb587b1b627702e7deeb8a0709fe49 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:27:34 -0300 Subject: [PATCH] agent: Add ability to queue messages (#46019) Closes https://github.com/zed-industries/zed/issues/37905 Closes https://github.com/zed-industries/zed/discussions/42338 Closes https://github.com/zed-industries/zed/discussions/33501 Closes https://github.com/zed-industries/zed/discussions/41414 This PR introduces a way to queue messages in the agent panel through the `cmd-shift-enter` keybinding. Queued up messages get sent as soon as the current generation wraps up. It's also possible to send a queued message before time, effectively interrupting the ongoing generation. You can also clean up the entire queue through another keybinding. Then, if you normally interrupt the thread and if there are queued up messages, those will get sent as soon as the interruption generation wraps up. Lastly, if you queue up a message with an idle thread, that's sent immediately, given that there can never exist a "stuck queue" with this implementation. https://github.com/user-attachments/assets/54e68d95-5abb-477c-aecb-9325dcb99175 Release Notes: - agent: Added the ability to queue messages in the agent panel. --- assets/keymaps/default-linux.json | 7 +- assets/keymaps/default-macos.json | 3 + assets/keymaps/default-windows.json | 7 +- crates/agent_ui/src/acp/message_editor.rs | 19 + crates/agent_ui/src/acp/thread_view.rs | 430 +++++++++++++++++++++- crates/agent_ui/src/agent_ui.rs | 6 + 6 files changed, 453 insertions(+), 19 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 1abe2be13dff62261203de3c59cc39c243da6fe4..5ba0005289024d245b5cd13aa1425b761dd23374 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -310,12 +310,15 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "agent::ChatWithFollow", - "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", + "ctrl-enter": "agent::ChatWithFollow", + "ctrl-shift-enter": "agent::QueueMessage", + "ctrl-shift-alt-enter": "agent::SendNextQueuedMessage", + "ctrl-shift-backspace": "agent::ClearMessageQueue", "ctrl-shift-v": "agent::PasteRaw", + "ctrl-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", "alt-tab": "agent::CycleFavoriteModels", }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 73c3181b63663dce1bd680f28e39bfbd4a013746..f9adbcd49bf7b3eae74116ac3da64b3cdfd3b4de 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -362,6 +362,9 @@ "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", "cmd-enter": "agent::ChatWithFollow", + "cmd-shift-enter": "agent::QueueMessage", + "cmd-shift-alt-enter": "agent::SendNextQueuedMessage", + "cmd-shift-backspace": "agent::ClearMessageQueue", "cmd-shift-v": "agent::PasteRaw", "cmd-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 7f1869e5775d628ea65e68d5d76472ac7ec5acff..931363319e7573c05b0d5d9f455c4e89f747ada9 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -313,12 +313,15 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "agent::ChatWithFollow", - "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", + "ctrl-enter": "agent::ChatWithFollow", + "ctrl-shift-enter": "agent::QueueMessage", + "ctrl-shift-alt-enter": "agent::SendNextQueuedMessage", + "ctrl-shift-backspace": "agent::ClearMessageQueue", "ctrl-shift-v": "agent::PasteRaw", + "ctrl-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", "alt-tab": "agent::CycleFavoriteModels", }, diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index d2c9cf9cb430793d788ed8cb61ecaa01e8f989a8..99023bb0d5d5c5dab5e781c197f3678633bb5b8e 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1,3 +1,4 @@ +use crate::QueueMessage; use crate::{ ChatWithFollow, completion_provider::{ @@ -50,6 +51,7 @@ pub struct MessageEditor { #[derive(Clone, Copy, Debug)] pub enum MessageEditorEvent { Send, + Queue, Cancel, Focus, LostFocus, @@ -495,6 +497,18 @@ impl MessageEditor { cx.emit(MessageEditorEvent::Send) } + pub fn queue(&mut self, cx: &mut Context) { + if self.is_empty(cx) { + return; + } + + self.editor.update(cx, |editor, cx| { + editor.clear_inlay_hints(cx); + }); + + cx.emit(MessageEditorEvent::Queue) + } + pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context) { let editor = self.editor.clone(); @@ -538,6 +552,10 @@ impl MessageEditor { self.send(cx); } + fn queue_message(&mut self, _: &QueueMessage, _: &mut Window, cx: &mut Context) { + self.queue(cx); + } + fn chat_with_follow( &mut self, _: &ChatWithFollow, @@ -984,6 +1002,7 @@ impl Render for MessageEditor { div() .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(Self::queue_message)) .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::paste_raw)) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 3f34250733cb9588542cb2fbbadce6376617c3a7..082ff9bca9f3daf5b2dd5ab8256223181149803a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -67,9 +67,10 @@ use crate::profile_selector::{ProfileProvider, ProfileSelector}; use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout}; use crate::{ - AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode, - CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, - OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector, + AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ClearMessageQueue, ContinueThread, + ContinueWithBurnMode, CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, + KeepAll, NewThread, OpenAgentDiff, OpenHistory, QueueMessage, RejectAll, RejectOnce, + SendNextQueuedMessage, ToggleBurnMode, ToggleProfileSelector, }; #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -288,6 +289,7 @@ pub struct AcpThreadView { expanded_thinking_blocks: HashSet<(usize, usize)>, edits_expanded: bool, plan_expanded: bool, + queue_expanded: bool, editor_expanded: bool, should_be_following: bool, editing_message: Option, @@ -300,6 +302,14 @@ pub struct AcpThreadView { _subscriptions: [Subscription; 5], show_codex_windows_warning: bool, in_flight_prompt: Option>, + message_queue: Vec, + skip_queue_processing_count: usize, + user_interrupted_generation: bool, +} + +struct QueuedMessage { + content: Vec, + tracked_buffers: Vec>, } enum ThreadState { @@ -448,6 +458,7 @@ impl AcpThreadView { editing_message: None, edits_expanded: false, plan_expanded: false, + queue_expanded: true, prompt_capabilities, available_commands, editor_expanded: false, @@ -462,6 +473,9 @@ impl AcpThreadView { resume_thread_metadata: resume_thread, show_codex_windows_warning, in_flight_prompt: None, + message_queue: Vec::new(), + skip_queue_processing_count: 0, + user_interrupted_generation: false, } } @@ -477,6 +491,7 @@ impl AcpThreadView { ); self.available_commands.replace(vec![]); self.new_server_version_available.take(); + self.message_queue.clear(); cx.notify(); } @@ -991,6 +1006,7 @@ impl AcpThreadView { ) { match event { MessageEditorEvent::Send => self.send(window, cx), + MessageEditorEvent::Queue => self.queue_message(window, cx), MessageEditorEvent::Cancel => self.cancel_generation(cx), MessageEditorEvent::Focus => { self.cancel_editing(&Default::default(), window, cx); @@ -1042,6 +1058,7 @@ impl AcpThreadView { } } } + ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Queue) => {} ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { self.regenerate(event.entry_index, editor.clone(), window, cx); } @@ -1141,6 +1158,9 @@ impl AcpThreadView { return; }; + self.skip_queue_processing_count = 0; + self.user_interrupted_generation = true; + let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx)); cx.spawn_in(window, async move |this, cx| { @@ -1276,6 +1296,178 @@ impl AcpThreadView { .detach(); } + fn queue_message(&mut self, window: &mut Window, cx: &mut Context) { + let is_idle = self + .thread() + .map(|t| t.read(cx).status() == acp_thread::ThreadStatus::Idle) + .unwrap_or(true); + + if is_idle { + self.send_impl(self.message_editor.clone(), window, cx); + return; + } + + let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { + let thread = thread.read(cx); + AgentSettings::get_global(cx) + .profiles + .get(thread.profile()) + .is_some_and(|profile| profile.tools.is_empty()) + }); + + let contents = self.message_editor.update(cx, |message_editor, cx| { + message_editor.contents(full_mention_content, cx) + }); + + let message_editor = self.message_editor.clone(); + + cx.spawn_in(window, async move |this, cx| { + let (content, tracked_buffers) = contents.await?; + + if content.is_empty() { + return Ok::<(), anyhow::Error>(()); + } + + this.update_in(cx, |this, window, cx| { + this.message_queue.push(QueuedMessage { + content, + tracked_buffers, + }); + message_editor.update(cx, |message_editor, cx| { + message_editor.clear(window, cx); + }); + cx.notify(); + })?; + Ok(()) + }) + .detach_and_log_err(cx); + } + + fn send_queued_message_at_index( + &mut self, + index: usize, + is_send_now: bool, + window: &mut Window, + cx: &mut Context, + ) { + if index >= self.message_queue.len() { + return; + } + + let queued = self.message_queue.remove(index); + let content = queued.content; + let tracked_buffers = queued.tracked_buffers; + + let Some(thread) = self.thread().cloned() else { + return; + }; + + // Only increment skip count for "Send Now" operations (out-of-order sends) + // Normal auto-processing from the Stopped handler doesn't need to skip + if is_send_now { + let is_generating = thread.read(cx).status() == acp_thread::ThreadStatus::Generating; + self.skip_queue_processing_count += if is_generating { 2 } else { 1 }; + } + + // Ensure we don't end up with multiple concurrent generations + let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx)); + + let session_id = thread.read(cx).session_id().clone(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); + let thread = thread.downgrade(); + + let should_be_following = self.should_be_following; + let workspace = self.workspace.clone(); + + self.is_loading_contents = true; + let model_id = self.current_model_id(cx); + let mode_id = self.current_mode_id(cx); + let guard = cx.new(|_| ()); + + cx.observe_release(&guard, |this, _guard, cx| { + this.is_loading_contents = false; + cx.notify(); + }) + .detach(); + + let task = cx.spawn_in(window, async move |this, cx| { + cancelled.await; + this.update_in(cx, |this, window, cx| { + if should_be_following { + workspace + .update(cx, |workspace, cx| { + workspace.follow(CollaboratorId::Agent, window, cx); + }) + .ok(); + } + + this.in_flight_prompt = Some(content.clone()); + this.set_editor_is_expanded(false, cx); + this.scroll_to_bottom(cx); + })?; + + let turn_start_time = Instant::now(); + let send = thread.update(cx, |thread, cx| { + thread.action_log().update(cx, |action_log, cx| { + for buffer in tracked_buffers { + action_log.buffer_read(buffer, cx) + } + }); + drop(guard); + + telemetry::event!( + "Agent Message Sent", + agent = agent_telemetry_id, + session = session_id, + model = model_id, + mode = mode_id + ); + + thread.send(content, cx) + })?; + + let res = send.await; + let turn_time_ms = turn_start_time.elapsed().as_millis(); + let status = if res.is_ok() { + this.update(cx, |this, _| this.in_flight_prompt.take()).ok(); + "success" + } else { + "failure" + }; + + telemetry::event!( + "Agent Turn Completed", + agent = agent_telemetry_id, + session = session_id, + model = model_id, + mode = mode_id, + status, + turn_time_ms, + ); + res + }); + + cx.spawn(async move |this, cx| { + if let Err(err) = task.await { + this.update(cx, |this, cx| { + this.handle_thread_error(err, cx); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + this.should_be_following = this + .workspace + .update(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) + }) + .unwrap_or_default(); + }) + .ok(); + } + }) + .detach(); + } + fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { let Some(thread) = self.thread().cloned() else { return; @@ -1474,6 +1666,16 @@ impl AcpThreadView { window, cx, ); + + if self.skip_queue_processing_count > 0 { + self.skip_queue_processing_count -= 1; + } else if self.user_interrupted_generation { + // Manual interruption: don't auto-process queue. + // Reset the flag so future completions can process normally. + self.user_interrupted_generation = false; + } else if !self.message_queue.is_empty() { + self.send_queued_message_at_index(0, false, window, cx); + } } AcpThreadEvent::Refusal => { self.thread_retry_status.take(); @@ -3831,7 +4033,7 @@ impl AcpThreadView { let changed_buffers = action_log.read(cx).changed_buffers(cx); let plan = thread.plan(); - if changed_buffers.is_empty() && plan.is_empty() { + if changed_buffers.is_empty() && plan.is_empty() && self.message_queue.is_empty() { return None; } @@ -3882,6 +4084,15 @@ impl AcpThreadView { )) }) }) + .when(!self.message_queue.is_empty(), |this| { + this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| { + this.child(Divider::horizontal().color(DividerColor::Border)) + }) + .child(self.render_message_queue_summary(window, cx)) + .when(self.queue_expanded, |parent| { + parent.child(self.render_message_queue_entries(window, cx)) + }) + }) .into_any() .into() } @@ -4357,6 +4568,154 @@ impl AcpThreadView { .into_any_element() } + fn render_message_queue_summary( + &self, + _window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + let queue_count = self.message_queue.len(); + let title: SharedString = if queue_count == 1 { + "1 Queued Message".into() + } else { + format!("{} Queued Messages", queue_count).into() + }; + + h_flex() + .p_1() + .w_full() + .gap_1() + .justify_between() + .when(self.queue_expanded, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .id("queue_summary") + .gap_1() + .child(Disclosure::new("queue_disclosure", self.queue_expanded)) + .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) + .on_click(cx.listener(|this, _, _, cx| { + this.queue_expanded = !this.queue_expanded; + cx.notify(); + })), + ) + .child( + Button::new("clear_queue", "Clear All") + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx)) + .on_click(cx.listener(|this, _, _, cx| { + this.message_queue.clear(); + cx.notify(); + })), + ) + } + + fn render_message_queue_entries( + &self, + _window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + let message_editor = self.message_editor.read(cx); + let focus_handle = message_editor.focus_handle(cx); + + v_flex() + .id("message_queue_list") + .max_h_40() + .overflow_y_scroll() + .children( + self.message_queue + .iter() + .enumerate() + .map(|(index, queued)| { + let is_next = index == 0; + let icon_color = if is_next { Color::Accent } else { Color::Muted }; + let queue_len = self.message_queue.len(); + + let preview = queued + .content + .iter() + .find_map(|block| match block { + acp::ContentBlock::Text(text) => { + text.text.lines().next().map(str::to_owned) + } + _ => None, + }) + .unwrap_or_default(); + + h_flex() + .group("queue_entry") + .w_full() + .p_1() + .pl_2() + .gap_1() + .justify_between() + .bg(cx.theme().colors().editor_background) + .when(index < queue_len - 1, |parent| { + parent.border_color(cx.theme().colors().border).border_b_1() + }) + .child( + h_flex() + .id(("queued_prompt", index)) + .min_w_0() + .w_full() + .gap_1p5() + .child( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(icon_color), + ) + .child( + Label::new(preview) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + .truncate(), + ) + .when(is_next, |this| { + this.tooltip(Tooltip::text("Next Prompt in the Queue")) + }), + ) + .child( + h_flex() + .flex_none() + .gap_1() + .visible_on_hover("queue_entry") + .child( + Button::new(("delete", index), "Remove") + .label_size(LabelSize::Small) + .on_click(cx.listener(move |this, _, _, cx| { + if index < this.message_queue.len() { + this.message_queue.remove(index); + cx.notify(); + } + })), + ) + .child( + Button::new(("send_now", index), "Send Now") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .when(is_next, |this| { + this.key_binding( + KeyBinding::for_action_in( + &SendNextQueuedMessage, + &focus_handle.clone(), + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.send_queued_message_at_index( + index, true, window, cx, + ); + })), + ), + ) + }), + ) + .into_any_element() + } + fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let focus_handle = self.message_editor.focus_handle(cx); let editor_bg_color = cx.theme().colors().editor_background; @@ -4639,7 +4998,10 @@ impl AcpThreadView { } fn render_send_button(&self, cx: &mut Context) -> AnyElement { - let is_editor_empty = self.message_editor.read(cx).is_empty(cx); + let message_editor = self.message_editor.read(cx); + let is_editor_empty = message_editor.is_empty(cx); + let focus_handle = message_editor.focus_handle(cx); + let is_generating = self .thread() .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle); @@ -4654,21 +5016,13 @@ impl AcpThreadView { } else if is_generating && is_editor_empty { IconButton::new("stop-generation", IconName::Stop) .icon_color(Color::Error) - .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .style(ButtonStyle::Tinted(TintColor::Error)) .tooltip(move |_window, cx| { Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx) }) .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) .into_any_element() } else { - let send_btn_tooltip = if is_editor_empty && !is_generating { - "Type to Send" - } else if is_generating { - "Stop and Send Message" - } else { - "Send" - }; - IconButton::new("send-message", IconName::Send) .style(ButtonStyle::Filled) .map(|this| { @@ -4678,7 +5032,46 @@ impl AcpThreadView { this.icon_color(Color::Accent) } }) - .tooltip(move |_window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, cx)) + .tooltip(move |_window, cx| { + if is_editor_empty && !is_generating { + Tooltip::for_action("Type to Send", &Chat, cx) + } else { + let title = if is_generating { + "Stop and Send Message" + } else { + "Send" + }; + + let focus_handle = focus_handle.clone(); + + Tooltip::element(move |_window, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new(title)) + .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .justify_between() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Queue Message")) + .child(KeyBinding::for_action_in( + &QueueMessage, + &focus_handle, + cx, + )), + ) + .into_any_element() + })(_window, cx) + } + }) .on_click(cx.listener(|this, _, window, cx| { this.send(window, cx); })) @@ -6073,6 +6466,13 @@ impl Render for AcpThreadView { .on_action(cx.listener(Self::allow_always)) .on_action(cx.listener(Self::allow_once)) .on_action(cx.listener(Self::reject_once)) + .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { + this.send_queued_message_at_index(0, true, window, cx); + })) + .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { + this.message_queue.clear(); + cx.notify(); + })) .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { if let Some(profile_selector) = this.profile_selector.as_ref() { profile_selector.read(cx).menu_handle().toggle(window, cx); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 401b506b302d9c2a86a36ddce0fc72df075f4c18..6e308e06373603d933297e9d9be9e4cbad380d6e 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -124,6 +124,12 @@ actions!( ContinueWithBurnMode, /// Toggles burn mode for faster responses. ToggleBurnMode, + /// Queues a message to be sent when generation completes. + QueueMessage, + /// Sends the next queued message immediately. + SendNextQueuedMessage, + /// Clears all messages from the queue. + ClearMessageQueue, ] );