Add the ability to follow the agent as it makes edits (#29839)

Nathan here: I also tacked on a bunch of UI refinement.

Release Notes:

- Introduced the ability to follow the agent around as it reads and
edits files.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
Antonio Scandurra 2025-05-04 10:28:39 +02:00 committed by GitHub
parent 425f32e068
commit 545ae27079
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1255 additions and 567 deletions

View file

@ -77,7 +77,8 @@ actions!(
Keep,
Reject,
RejectAll,
KeepAll
KeepAll,
Follow
]
);

View file

@ -104,10 +104,9 @@ impl Render for AssistantModelSelector {
let focus_handle = self.focus_handle.clone();
let model = self.selector.read(cx).active_model(cx);
let (model_name, model_icon) = match model {
Some(model) => (model.model.name().0, Some(model.provider.icon())),
_ => (SharedString::from("No model selected"), None),
};
let model_name = model
.map(|model| model.model.name().0)
.unwrap_or_else(|| SharedString::from("No model selected"));
LanguageModelSelectorPopoverMenu::new(
self.selector.clone(),
@ -116,11 +115,6 @@ impl Render for AssistantModelSelector {
.child(
h_flex()
.gap_0p5()
.children(
model_icon.map(|icon| {
Icon::new(icon).color(Color::Muted).size(IconSize::Small)
}),
)
.child(
Label::new(model_name)
.size(LabelSize::Small)

View file

@ -37,8 +37,8 @@ use ui::{
Banner, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*,
};
use util::ResultExt as _;
use workspace::Workspace;
use workspace::dock::{DockPosition, Panel, PanelEvent};
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::OpenConfiguration;
use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
@ -52,7 +52,7 @@ use crate::thread_history::{PastContext, PastThread, ThreadHistory};
use crate::thread_store::ThreadStore;
use crate::ui::UsageBanner;
use crate::{
AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor,
AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow,
InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
OpenHistory, ThreadEvent, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
};
@ -107,6 +107,9 @@ pub fn init(cx: &mut App) {
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
}
})
.register_action(|workspace, _: &Follow, window, cx| {
workspace.follow(CollaboratorId::Agent, window, cx);
})
.register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
workspace.focus_panel::<AssistantPanel>(window, cx);

View file

@ -11,7 +11,7 @@ use gpui::{
use itertools::Itertools;
use language::Buffer;
use project::ProjectItem;
use ui::{KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
use ui::{PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
use workspace::Workspace;
use crate::context::{AgentContextHandle, ContextKind};
@ -357,7 +357,7 @@ impl Focusable for ContextStrip {
}
impl Render for ContextStrip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let context_picker = self.context_picker.clone();
let focus_handle = self.focus_handle.clone();
@ -434,30 +434,6 @@ impl Render for ContextStrip {
})
.with_handle(self.context_picker_menu_handle.clone()),
)
.when(no_added_context && suggested_context.is_none(), {
|parent| {
parent.child(
h_flex()
.ml_1p5()
.gap_2()
.child(
Label::new("Add Context")
.size(LabelSize::Small)
.color(Color::Muted),
)
.opacity(0.5)
.children(
KeyBinding::for_action_in(
&ToggleContextPicker,
&focus_handle,
window,
cx,
)
.map(|binding| binding.into_any_element()),
),
)
}
})
.children(
added_contexts
.into_iter()

View file

@ -32,7 +32,7 @@ use std::time::Duration;
use theme::ThemeSettings;
use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
use util::ResultExt as _;
use workspace::Workspace;
use workspace::{CollaboratorId, Workspace};
use zed_llm_client::CompletionMode;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
@ -42,7 +42,7 @@ use crate::profile_selector::ProfileSelector;
use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
use crate::thread_store::ThreadStore;
use crate::{
ActiveThread, AgentDiffPane, Chat, ExpandMessageEditor, NewThread, OpenAgentDiff,
ActiveThread, AgentDiffPane, Chat, ExpandMessageEditor, Follow, NewThread, OpenAgentDiff,
RemoveAllContext, ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
};
@ -97,7 +97,7 @@ pub(crate) fn create_editor(
window,
cx,
);
editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
editor.set_placeholder_text("Message the agent @ to include context", cx);
editor.set_show_indent_guides(false, cx);
editor.set_soft_wrap();
editor.set_context_menu_options(ContextMenuOptions {
@ -200,8 +200,7 @@ impl MessageEditor {
model_selector,
edits_expanded: false,
editor_is_expanded: false,
profile_selector: cx
.new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
profile_selector: cx.new(|cx| ProfileSelector::new(fs, thread_store, cx)),
last_estimated_token_count: None,
update_token_count_task: None,
_subscriptions: subscriptions,
@ -457,6 +456,44 @@ impl MessageEditor {
)
}
fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
let following = self
.workspace
.read_with(cx, |workspace, _| {
workspace.is_being_followed(CollaboratorId::Agent)
})
.unwrap_or(false);
IconButton::new("follow-agent", IconName::Crosshair)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.toggle_state(following)
.selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
.tooltip(move |window, cx| {
if following {
Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
} else {
Tooltip::with_meta(
"Follow Agent",
Some(&Follow),
"Track the agent's location as it reads and edits files.",
window,
cx,
)
}
})
.on_click(cx.listener(move |this, _, window, cx| {
this.workspace
.update(cx, |workspace, cx| {
if following {
workspace.unfollow(CollaboratorId::Agent, window, cx);
} else {
workspace.follow(CollaboratorId::Agent, window, cx);
}
})
.ok();
}))
}
fn render_editor(
&self,
font_size: Rems,
@ -522,34 +559,39 @@ impl MessageEditor {
.items_start()
.justify_between()
.child(self.context_strip.clone())
.when(focus_handle.is_focused(window), |this| {
this.child(
IconButton::new("toggle-height", expand_icon)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
let expand_label = if is_editor_expanded {
"Minimize Message Editor".to_string()
} else {
"Expand Message Editor".to_string()
};
.child(
h_flex()
.gap_1()
.when(focus_handle.is_focused(window), |this| {
this.child(
IconButton::new("toggle-height", expand_icon)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
let expand_label = if is_editor_expanded {
"Minimize Message Editor".to_string()
} else {
"Expand Message Editor".to_string()
};
Tooltip::for_action_in(
expand_label,
&ExpandMessageEditor,
&focus_handle,
window,
cx,
)
}
})
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(Box::new(ExpandMessageEditor), cx);
})),
)
}),
Tooltip::for_action_in(
expand_label,
&ExpandMessageEditor,
&focus_handle,
window,
cx,
)
}
})
.on_click(cx.listener(|_, _, window, cx| {
window
.dispatch_action(Box::new(ExpandMessageEditor), cx);
})),
)
}),
),
)
.child(
v_flex()
@ -592,7 +634,12 @@ impl MessageEditor {
h_flex()
.flex_none()
.justify_between()
.child(h_flex().gap_2().child(self.profile_selector.clone()))
.child(
h_flex()
.gap_1()
.child(self.render_follow_toggle(cx))
.child(self.profile_selector.clone()),
)
.child(
h_flex()
.gap_1()

View file

@ -4,22 +4,20 @@ use assistant_settings::{
AgentProfile, AgentProfileId, AssistantSettings, GroupedAgentProfiles, builtin_profiles,
};
use fs::Fs;
use gpui::{Action, Entity, FocusHandle, Subscription, WeakEntity, prelude::*};
use gpui::{Action, Entity, Subscription, WeakEntity, prelude::*};
use language_model::LanguageModelRegistry;
use settings::{Settings as _, SettingsStore, update_settings_file};
use ui::{
ButtonLike, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip,
prelude::*,
ButtonLike, ContextMenu, ContextMenuEntry, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
};
use util::ResultExt as _;
use crate::{ManageProfiles, ThreadStore, ToggleProfileSelector};
use crate::{ManageProfiles, ThreadStore};
pub struct ProfileSelector {
profiles: GroupedAgentProfiles,
fs: Arc<dyn Fs>,
thread_store: WeakEntity<ThreadStore>,
focus_handle: FocusHandle,
menu_handle: PopoverMenuHandle<ContextMenu>,
_subscriptions: Vec<Subscription>,
}
@ -28,7 +26,6 @@ impl ProfileSelector {
pub fn new(
fs: Arc<dyn Fs>,
thread_store: WeakEntity<ThreadStore>,
focus_handle: FocusHandle,
cx: &mut Context<Self>,
) -> Self {
let settings_subscription = cx.observe_global::<SettingsStore>(move |this, cx| {
@ -39,7 +36,6 @@ impl ProfileSelector {
profiles: GroupedAgentProfiles::from_settings(AssistantSettings::get_global(cx)),
fs,
thread_store,
focus_handle,
menu_handle: PopoverMenuHandle::default(),
_subscriptions: vec![settings_subscription],
}
@ -132,7 +128,7 @@ impl ProfileSelector {
}
impl Render for ProfileSelector {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = AssistantSettings::get_global(cx);
let profile_id = &settings.default_profile;
let profile = settings.profiles.get(profile_id);
@ -146,15 +142,7 @@ impl Render for ProfileSelector {
.default_model()
.map_or(false, |default| default.model.supports_tools());
let icon = match profile_id.as_str() {
builtin_profiles::WRITE => IconName::Pencil,
builtin_profiles::ASK => IconName::MessageBubbles,
builtin_profiles::MANUAL => IconName::MessageBubbleDashed,
_ => IconName::UserRoundPen,
};
let this = cx.entity().clone();
let focus_handle = self.focus_handle.clone();
PopoverMenu::new("profile-selector")
.menu(move |window, cx| {
@ -164,7 +152,6 @@ impl Render for ProfileSelector {
ButtonLike::new("profile-selector-button").child(
h_flex()
.gap_1()
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
.child(
Label::new(selected_profile)
.size(LabelSize::Small)
@ -174,17 +161,7 @@ impl Render for ProfileSelector {
Icon::new(IconName::ChevronDown)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(div().opacity(0.5).children({
let focus_handle = focus_handle.clone();
KeyBinding::for_action_in(
&ToggleProfileSelector,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.)))
})),
),
)
} else {
ButtonLike::new("tools-not-supported-button")

View file

@ -1582,10 +1582,17 @@ impl Thread {
let tool_uses = thread.use_pending_tools(window, cx, model.clone());
cx.emit(ThreadEvent::UsePendingTools { tool_uses });
}
StopReason::EndTurn => {}
StopReason::MaxTokens => {}
StopReason::EndTurn | StopReason::MaxTokens => {
thread.project.update(cx, |project, cx| {
project.set_agent_location(None, cx);
});
}
},
Err(error) => {
thread.project.update(cx, |project, cx| {
project.set_agent_location(None, cx);
});
if error.is::<PaymentRequiredError>() {
cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired));
} else if error.is::<MaxMonthlySpendReachedError>() {