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 <benjamin.j.brandt@gmail.com>
Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Danilo Leal 2025-05-27 19:44:10 -03:00 committed by GitHub
parent 233b73b385
commit 0731097ee5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 182 additions and 59 deletions

View file

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.99207 8.14741C5.37246 8.14741 5.73726 7.9963 6.00623 7.72733C6.27521 7.45836 6.42631 7.09355 6.42631 6.71317C6.42631 5.92147 6.13946 5.56578 5.85262 4.99208C5.23761 3.76265 5.72411 2.66631 7.00001 1.5499C7.28686 2.98414 8.1474 4.36101 9.2948 5.27893C10.4422 6.19684 11.0159 7.28687 11.0159 8.43426C11.0159 8.96163 10.912 9.48384 10.7102 9.97107C10.5084 10.4583 10.2126 10.901 9.83967 11.2739C9.46676 11.6468 9.02405 11.9426 8.53682 12.1444C8.04959 12.3463 7.52738 12.4501 7.00001 12.4501C6.47264 12.4501 5.95043 12.3463 5.4632 12.1444C4.97597 11.9426 4.53326 11.6468 4.16035 11.2739C3.78745 10.901 3.49164 10.4583 3.28982 9.97107C3.088 9.48384 2.98413 8.96163 2.98413 8.43426C2.98413 7.77279 3.23254 7.1182 3.55783 6.71317C3.55783 7.09355 3.70894 7.45836 3.97791 7.72733C4.24688 7.9963 4.61169 8.14741 4.99207 8.14741Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1,018 B

View file

@ -0,0 +1,13 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2595_5640)">
<path d="M4.99207 8.14741C5.37246 8.14741 5.73726 7.9963 6.00623 7.72733C6.27521 7.45836 6.42631 7.09355 6.42631 6.71317C6.42631 5.92147 6.13946 5.56578 5.85262 4.99208C5.23761 3.76265 5.72411 2.66631 7.00001 1.5499C7.28686 2.98414 8.1474 4.36101 9.2948 5.27893C10.4422 6.19684 11.0159 7.28687 11.0159 8.43426C11.0159 8.96163 10.912 9.48384 10.7102 9.97107C10.5084 10.4583 10.2126 10.901 9.83967 11.2739C9.46676 11.6468 9.02405 11.9426 8.53682 12.1444C8.04959 12.3463 7.52738 12.4501 7.00001 12.4501C6.47264 12.4501 5.95043 12.3463 5.4632 12.1444C4.97597 11.9426 4.53326 11.6468 4.16035 11.2739C3.78745 10.901 3.49164 10.4583 3.28982 9.97107C3.088 9.48384 2.98413 8.96163 2.98413 8.43426C2.98413 7.77279 3.23254 7.1182 3.55783 6.71317C3.55783 7.09355 3.70894 7.45836 3.97791 7.72733C4.24688 7.9963 4.61169 8.14741 4.99207 8.14741Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 4C2.55228 4 3 3.55228 3 3C3 2.44772 2.55228 2 2 2C1.44772 2 1 2.44772 1 3C1 3.55228 1.44772 4 2 4Z" fill="black"/>
<path d="M10 2C10.5523 2 11 1.55228 11 1C11 0.44772 10.5523 0 10 0C9.44772 0 9 0.44772 9 1C9 1.55228 9.44772 2 10 2Z" fill="black"/>
<path d="M13 5C13.5522 5 14 4.55228 14 4C14 3.44772 13.5522 3 13 3C12.4478 3 12 3.44772 12 4C12 4.55228 12.4478 5 13 5Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_2595_5640">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,14 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2489_484)">
<path d="M11 8.9V11C8.51716 11 7.48284 11 5 11V10.4L11 5.6V5H5V7.1" stroke="black" stroke-width="1.5"/>
<path d="M1.5 5.5V1.5H5" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
<path d="M14.5 5.5V1.5H11" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
<path d="M1.5 10.5V14.5H5" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
<path d="M14.5 10.5V14.5H11" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
</g>
<defs>
<clipPath id="clip0_2489_484">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 687 B

View file

@ -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"
}
},
{

View file

@ -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"
}
},
{

View file

@ -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 {

View file

@ -87,6 +87,8 @@ actions!(
Follow,
ResetTrialUpsell,
ResetTrialEndUpsell,
ContinueThread,
ContinueWithBurnMode,
]
);

View file

@ -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<Self>) {
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<Entity<ContextEditor>> {
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<Self>) -> Option<AnyElement> {
fn render_tool_use_limit_reached(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
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)),

View file

@ -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)),
)

View file

@ -115,6 +115,7 @@ pub struct Message {
pub segments: Vec<MessageSegment>,
pub loaded_context: LoadedContext,
pub creases: Vec<MessageCrease>,
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<Self>) -> 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<MessageSegment>,
@ -976,6 +993,7 @@ impl Thread {
segments,
LoadedContext::default(),
Vec::new(),
false,
cx,
)
}
@ -986,6 +1004,7 @@ impl Thread {
segments: Vec<MessageSegment>,
loaded_context: LoadedContext,
creases: Vec<MessageCrease>,
is_hidden: bool,
cx: &mut Context<Self>,
) -> 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

View file

@ -676,6 +676,8 @@ pub struct SerializedThread {
pub model: Option<SerializedLanguageModel>,
#[serde(default)]
pub completion_mode: Option<CompletionMode>,
#[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<SerializedCrease>,
#[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,
}
}
}

View file

@ -18,18 +18,24 @@ impl MaxModeTooltip {
impl Render for MaxModeTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
)

View file

@ -256,7 +256,8 @@ pub enum IconName {
XCircle,
ZedAssistant,
ZedAssistantFilled,
ZedMaxMode,
ZedBurnMode,
ZedBurnModeOn,
ZedPredict,
ZedPredictDisabled,
ZedPredictDown,

View file

@ -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,