From 0731097ee5780b3569980d7ba93f8fcf4eee097d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 27 May 2025 19:44:10 -0300 Subject: [PATCH] agent: Improve consecutive tool call UX and rebrand Max Mode (#31470) This PR improves the consecutive tool call UX by allowing users to quickly continue an interrupted with one-click. What we do here is insert a hidden "Continue" message that will just nudge the LLM to keep going. We're also using the opportunity to upsell the previously called "Max Mode", now rebranded as "Burn Mode", which allows users to don't be interrupted anymore if they ever have 25 consecutive tool calls again. Release Notes: - agent: Improve consecutive tool call UX by allowing users to quickly continue an interrupted thread with one click. --------- Co-authored-by: Ben Brandt Co-authored-by: Agus Zubiaga Co-authored-by: Agus Zubiaga --- assets/icons/zed_burn_mode.svg | 3 + assets/icons/zed_burn_mode_on.svg | 13 +++ assets/icons/zed_max_mode.svg | 14 --- assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 4 +- crates/agent/src/active_thread.rs | 5 + crates/agent/src/agent.rs | 2 + crates/agent/src/agent_panel.rs | 116 ++++++++++++++++++++---- crates/agent/src/message_editor.rs | 13 +-- crates/agent/src/thread.rs | 27 +++++- crates/agent/src/thread_store.rs | 6 ++ crates/agent/src/ui/max_mode_tooltip.rs | 29 +++--- crates/icons/src/icons.rs | 3 +- crates/ui/src/components/banner.rs | 2 +- 14 files changed, 182 insertions(+), 59 deletions(-) create mode 100644 assets/icons/zed_burn_mode.svg create mode 100644 assets/icons/zed_burn_mode_on.svg delete mode 100644 assets/icons/zed_max_mode.svg diff --git a/assets/icons/zed_burn_mode.svg b/assets/icons/zed_burn_mode.svg new file mode 100644 index 0000000000..544368d8e0 --- /dev/null +++ b/assets/icons/zed_burn_mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/zed_burn_mode_on.svg b/assets/icons/zed_burn_mode_on.svg new file mode 100644 index 0000000000..94230b6fd6 --- /dev/null +++ b/assets/icons/zed_burn_mode_on.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icons/zed_max_mode.svg b/assets/icons/zed_max_mode.svg deleted file mode 100644 index 969785a83f..0000000000 --- a/assets/icons/zed_max_mode.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index eab1f72ff1..243406277e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -248,7 +248,9 @@ "ctrl-shift-i": "agent::ToggleOptionsMenu", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-alt-e": "agent::RemoveAllContext", - "ctrl-shift-e": "project_panel::ToggleFocus" + "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-enter": "agent::ContinueThread", + "alt-enter": "agent::ContinueWithBurnMode" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 570be05a31..5afb6e97c4 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -283,7 +283,9 @@ "cmd-shift-i": "agent::ToggleOptionsMenu", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd-alt-e": "agent::RemoveAllContext", - "cmd-shift-e": "project_panel::ToggleFocus" + "cmd-shift-e": "project_panel::ToggleFocus", + "cmd-shift-enter": "agent::ContinueThread", + "alt-enter": "agent::ContinueWithBurnMode" } }, { diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index 6dbbd2b69f..46f924c153 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -1778,6 +1778,11 @@ impl ActiveThread { let Some(message) = self.thread.read(cx).message(message_id) else { return Empty.into_any(); }; + + if message.is_hidden { + return Empty.into_any(); + } + let message_creases = message.creases.clone(); let Some(rendered_message) = self.rendered_messages_by_id.get(&message_id) else { diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index b4d1abdea4..f5ae6097a5 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -87,6 +87,8 @@ actions!( Follow, ResetTrialUpsell, ResetTrialEndUpsell, + ContinueThread, + ContinueWithBurnMode, ] ); diff --git a/crates/agent/src/agent_panel.rs b/crates/agent/src/agent_panel.rs index 0d59ad9595..324f98c2fd 100644 --- a/crates/agent/src/agent_panel.rs +++ b/crates/agent/src/agent_panel.rs @@ -7,7 +7,7 @@ use std::time::Duration; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; -use agent_settings::{AgentDockPosition, AgentSettings, DefaultView}; +use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView}; use anyhow::{Result, anyhow}; use assistant_context_editor::{ AgentPanelDelegate, AssistantContext, ConfigurationError, ContextEditor, ContextEvent, @@ -41,8 +41,8 @@ use theme::ThemeSettings; use time::UtcOffset; use ui::utils::WithRemSize; use ui::{ - Banner, CheckboxWithLabel, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*, + Banner, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, + PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*, }; use util::{ResultExt as _, maybe}; use workspace::dock::{DockPosition, Panel, PanelEvent}; @@ -64,10 +64,11 @@ use crate::thread_history::{HistoryEntryElement, ThreadHistory}; use crate::thread_store::ThreadStore; use crate::ui::AgentOnboardingModal; use crate::{ - AddContextServer, AgentDiffPane, ContextStore, DeleteRecentlyOpenThread, ExpandMessageEditor, - Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, - OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, TextThreadStore, ThreadEvent, - ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, + AddContextServer, AgentDiffPane, ContextStore, ContinueThread, ContinueWithBurnMode, + DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, + NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, + ResetTrialUpsell, TextThreadStore, ThreadEvent, ToggleContextPicker, ToggleNavigationMenu, + ToggleOptionsMenu, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -1283,6 +1284,26 @@ impl AgentPanel { matches!(self.active_view, ActiveView::Thread { .. }) } + fn continue_conversation(&mut self, window: &mut Window, cx: &mut Context) { + let thread_state = self.thread.read(cx).thread().read(cx); + if !thread_state.tool_use_limit_reached() { + return; + } + + let model = thread_state.configured_model().map(|cm| cm.model.clone()); + if let Some(model) = model { + self.thread.update(cx, |active_thread, cx| { + active_thread.thread().update(cx, |thread, cx| { + thread.insert_invisible_continue_message(cx); + thread.advance_prompt_id(); + thread.send_to_model(model, Some(window.window_handle()), cx); + }); + }); + } else { + log::warn!("No configured model available for continuation"); + } + } + pub(crate) fn active_context_editor(&self) -> Option> { match &self.active_view { ActiveView::PromptEditor { context_editor, .. } => Some(context_editor.clone()), @@ -2574,7 +2595,11 @@ impl AgentPanel { }) } - fn render_tool_use_limit_reached(&self, cx: &mut Context) -> Option { + fn render_tool_use_limit_reached( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { let tool_use_limit_reached = self .thread .read(cx) @@ -2593,17 +2618,59 @@ impl AgentPanel { .configured_model()? .model; - let max_mode_upsell = if model.supports_max_mode() { - " Enable max mode for unlimited tool use." - } else { - "" - }; + let focus_handle = self.focus_handle(cx); let banner = Banner::new() .severity(ui::Severity::Info) - .child(h_flex().child(Label::new(format!( - "Consecutive tool use limit reached.{max_mode_upsell}" - )))); + .child(Label::new("Consecutive tool use limit reached.").size(LabelSize::Small)) + .action_slot( + h_flex() + .gap_1() + .child( + Button::new("continue-conversation", "Continue") + .layer(ElevationIndex::ModalSurface) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &ContinueThread, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.continue_conversation(window, cx); + })), + ) + .when(model.supports_max_mode(), |this| { + this.child( + Button::new("continue-burn-mode", "Continue with Burn Mode") + .style(ButtonStyle::Filled) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .layer(ElevationIndex::ModalSurface) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &ContinueWithBurnMode, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use.")) + .on_click(cx.listener(|this, _, window, cx| { + this.thread.update(cx, |active_thread, cx| { + active_thread.thread().update(cx, |thread, _cx| { + thread.set_completion_mode(CompletionMode::Max); + }); + }); + this.continue_conversation(window, cx); + })), + ) + }), + ); Some(div().px_2().pb_2().child(banner).into_any_element()) } @@ -2958,9 +3025,9 @@ impl Render for AgentPanel { // non-obvious implications to the layout of children. // // If you need to change it, please confirm: - // - The message editor expands (⌘esc) correctly + // - The message editor expands (cmd-option-esc) correctly // - When expanded, the buttons at the bottom of the panel are displayed correctly - // - Font size works as expected and can be changed with ⌘+/⌘- + // - Font size works as expected and can be changed with cmd-+/cmd- // - Scrolling in all views works as expected // - Files can be dropped into the panel let content = v_flex() @@ -2987,6 +3054,17 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) + .on_action(cx.listener(|this, _: &ContinueThread, window, cx| { + this.continue_conversation(window, cx); + })) + .on_action(cx.listener(|this, _: &ContinueWithBurnMode, window, cx| { + this.thread.update(cx, |active_thread, cx| { + active_thread.thread().update(cx, |thread, _cx| { + thread.set_completion_mode(CompletionMode::Max); + }); + }); + this.continue_conversation(window, cx); + })) .child(self.render_toolbar(window, cx)) .children(self.render_upsell(window, cx)) .children(self.render_trial_end_upsell(window, cx)) @@ -2994,7 +3072,7 @@ impl Render for AgentPanel { ActiveView::Thread { .. } => parent .relative() .child(self.render_active_thread_or_empty_state(window, cx)) - .children(self.render_tool_use_limit_reached(cx)) + .children(self.render_tool_use_limit_reached(window, cx)) .child(h_flex().child(self.message_editor.clone())) .children(self.render_last_error(cx)) .child(self.render_drag_target(cx)), diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 3256299c89..a53fc475f4 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -480,16 +480,18 @@ impl MessageEditor { let active_completion_mode = thread.completion_mode(); let max_mode_enabled = active_completion_mode == CompletionMode::Max; + let icon = if max_mode_enabled { + IconName::ZedBurnModeOn + } else { + IconName::ZedBurnMode + }; Some( - Button::new("max-mode", "Max Mode") - .label_size(LabelSize::Small) - .color(Color::Muted) - .icon(IconName::ZedMaxMode) + IconButton::new("burn-mode", icon) .icon_size(IconSize::Small) .icon_color(Color::Muted) - .icon_position(IconPosition::Start) .toggle_state(max_mode_enabled) + .selected_icon_color(Color::Error) .on_click(cx.listener(move |this, _event, _window, cx| { this.thread.update(cx, |thread, _cx| { thread.set_completion_mode(match active_completion_mode { @@ -686,7 +688,6 @@ impl MessageEditor { .justify_between() .child( h_flex() - .gap_1() .child(self.render_follow_toggle(cx)) .children(self.render_max_mode_toggle(cx)), ) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index d0b63e0157..78a0f855ef 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -115,6 +115,7 @@ pub struct Message { pub segments: Vec, pub loaded_context: LoadedContext, pub creases: Vec, + pub is_hidden: bool, } impl Message { @@ -540,6 +541,7 @@ impl Thread { context: None, }) .collect(), + is_hidden: message.is_hidden, }) .collect(), next_message_id, @@ -560,7 +562,7 @@ impl Thread { cumulative_token_usage: serialized.cumulative_token_usage, exceeded_window_error: None, last_usage: None, - tool_use_limit_reached: false, + tool_use_limit_reached: serialized.tool_use_limit_reached, feedback: None, message_feedback: HashMap::default(), last_auto_capture_at: None, @@ -849,7 +851,7 @@ impl Thread { .get(ix + 1) .and_then(|message| { self.message(message.id) - .map(|next_message| next_message.role == Role::User) + .map(|next_message| next_message.role == Role::User && !next_message.is_hidden) }) .unwrap_or(false) } @@ -951,6 +953,7 @@ impl Thread { vec![MessageSegment::Text(text.into())], loaded_context.loaded_context, creases, + false, cx, ); @@ -966,6 +969,20 @@ impl Thread { message_id } + pub fn insert_invisible_continue_message(&mut self, cx: &mut Context) -> MessageId { + let id = self.insert_message( + Role::User, + vec![MessageSegment::Text("Continue where you left off".into())], + LoadedContext::default(), + vec![], + true, + cx, + ); + self.pending_checkpoint = None; + + id + } + pub fn insert_assistant_message( &mut self, segments: Vec, @@ -976,6 +993,7 @@ impl Thread { segments, LoadedContext::default(), Vec::new(), + false, cx, ) } @@ -986,6 +1004,7 @@ impl Thread { segments: Vec, loaded_context: LoadedContext, creases: Vec, + is_hidden: bool, cx: &mut Context, ) -> MessageId { let id = self.next_message_id.post_inc(); @@ -995,6 +1014,7 @@ impl Thread { segments, loaded_context, creases, + is_hidden, }); self.touch_updated_at(); cx.emit(ThreadEvent::MessageAdded(id)); @@ -1135,6 +1155,7 @@ impl Thread { label: crease.metadata.label.clone(), }) .collect(), + is_hidden: message.is_hidden, }) .collect(), initial_project_snapshot, @@ -1150,6 +1171,7 @@ impl Thread { model: model.model.id().0.to_string(), }), completion_mode: Some(this.completion_mode), + tool_use_limit_reached: this.tool_use_limit_reached, }) }) } @@ -1781,6 +1803,7 @@ impl Thread { thread.cancel_last_completion(window, cx); } } + cx.emit(ThreadEvent::Stopped(result.map_err(Arc::new))); if let Some((request_callback, (request, response_events))) = thread diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 8cc29e32ab..8c6fc909e9 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -676,6 +676,8 @@ pub struct SerializedThread { pub model: Option, #[serde(default)] pub completion_mode: Option, + #[serde(default)] + pub tool_use_limit_reached: bool, } #[derive(Serialize, Deserialize, Debug)] @@ -757,6 +759,8 @@ pub struct SerializedMessage { pub context: String, #[serde(default)] pub creases: Vec, + #[serde(default)] + pub is_hidden: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -815,6 +819,7 @@ impl LegacySerializedThread { exceeded_window_error: None, model: None, completion_mode: None, + tool_use_limit_reached: false, } } } @@ -840,6 +845,7 @@ impl LegacySerializedMessage { tool_results: self.tool_results, context: String::new(), creases: Vec::new(), + is_hidden: false, } } } diff --git a/crates/agent/src/ui/max_mode_tooltip.rs b/crates/agent/src/ui/max_mode_tooltip.rs index c6a5116e2e..d1bd94c201 100644 --- a/crates/agent/src/ui/max_mode_tooltip.rs +++ b/crates/agent/src/ui/max_mode_tooltip.rs @@ -18,18 +18,24 @@ impl MaxModeTooltip { impl Render for MaxModeTooltip { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let icon = if self.selected { + IconName::ZedBurnModeOn + } else { + IconName::ZedBurnMode + }; + + let title = h_flex() + .gap_1() + .child(Icon::new(icon).size(IconSize::Small)) + .child(Label::new("Burn Mode")); + tooltip_container(window, cx, |this, _, _| { - this.gap_1() + this.gap_0p5() .map(|header| if self.selected { header.child( h_flex() .justify_between() - .child( - h_flex() - .gap_1p5() - .child(Icon::new(IconName::ZedMaxMode).size(IconSize::Small).color(Color::Accent)) - .child(Label::new("Zed's Max Mode")) - ) + .child(title) .child( h_flex() .gap_0p5() @@ -38,18 +44,13 @@ impl Render for MaxModeTooltip { ) ) } else { - header.child( - h_flex() - .gap_1p5() - .child(Icon::new(IconName::ZedMaxMode).size(IconSize::Small)) - .child(Label::new("Zed's Max Mode")) - ) + header.child(title) }) .child( div() .max_w_72() .child( - Label::new("This mode enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning, offering an unfettered agentic experience.") + Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning, offering an unfettered agentic experience.") .size(LabelSize::Small) .color(Color::Muted) ) diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 3f51383f21..6d12edff83 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -256,7 +256,8 @@ pub enum IconName { XCircle, ZedAssistant, ZedAssistantFilled, - ZedMaxMode, + ZedBurnMode, + ZedBurnModeOn, ZedPredict, ZedPredictDisabled, ZedPredictDown, diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index d5bee5463f..043791cdd8 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -86,7 +86,7 @@ impl RenderOnce for Banner { IconName::Info, Color::Muted, cx.theme().status().info_background.opacity(0.5), - cx.theme().colors().border_variant, + cx.theme().colors().border.opacity(0.5), ), Severity::Success => ( IconName::Check,