agent: Review edits in single-file editors (#29820)

Enables reviewing agent edits from single-file editors in addition to
the multibuffer experience we already had.


https://github.com/user-attachments/assets/a2c287f0-51d6-43a1-8537-821498b91983


This feature can be turned off by setting `assistant.single_file_review:
false`.

Release Notes:

- agent: Review edits in single-file editors
This commit is contained in:
Agus Zubiaga 2025-05-02 17:57:16 -03:00 committed by Joseph T. Lyons
parent 02f9df3c7b
commit 550c3fb506
13 changed files with 1396 additions and 254 deletions

View File

@ -194,6 +194,16 @@
"alt-shift-y": "git::UnstageAndNext" "alt-shift-y": "git::UnstageAndNext"
} }
}, },
{
"context": "Editor && editor_agent_diff",
"bindings": {
"ctrl-y": "agent::Keep",
"ctrl-n": "agent::Reject",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-ctrl-r": "agent::OpenAgentDiff"
}
},
{ {
"context": "AgentDiff", "context": "AgentDiff",
"bindings": { "bindings": {

View File

@ -247,6 +247,17 @@
"cmd-shift-n": "agent::RejectAll" "cmd-shift-n": "agent::RejectAll"
} }
}, },
{
"context": "Editor && editor_agent_diff",
"use_key_equivalents": true,
"bindings": {
"cmd-y": "agent::Keep",
"cmd-n": "agent::Reject",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-ctrl-r": "agent::OpenAgentDiff"
}
},
{ {
"context": "AssistantPanel", "context": "AssistantPanel",
"use_key_equivalents": true, "use_key_equivalents": true,

View File

@ -765,7 +765,6 @@ impl ActiveThread {
.unwrap() .unwrap()
} }
}); });
let mut this = Self { let mut this = Self {
language_registry, language_registry,
thread_store, thread_store,
@ -954,6 +953,9 @@ impl ActiveThread {
ThreadEvent::UsageUpdated(usage) => { ThreadEvent::UsageUpdated(usage) => {
self.last_usage = Some(*usage); self.last_usage = Some(*usage);
} }
ThreadEvent::NewRequest | ThreadEvent::CompletionCanceled => {
cx.notify();
}
ThreadEvent::StreamedCompletion ThreadEvent::StreamedCompletion
| ThreadEvent::SummaryGenerated | ThreadEvent::SummaryGenerated
| ThreadEvent::SummaryChanged => { | ThreadEvent::SummaryChanged => {

File diff suppressed because it is too large Load Diff

View File

@ -45,7 +45,7 @@ pub use crate::context::{ContextLoadResult, LoadedContext};
pub use crate::inline_assistant::InlineAssistant; pub use crate::inline_assistant::InlineAssistant;
pub use crate::thread::{Message, MessageSegment, Thread, ThreadEvent}; pub use crate::thread::{Message, MessageSegment, Thread, ThreadEvent};
pub use crate::thread_store::ThreadStore; pub use crate::thread_store::ThreadStore;
pub use agent_diff::{AgentDiff, AgentDiffToolbar}; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use context_store::ContextStore; pub use context_store::ContextStore;
pub use ui::{all_agent_previews, get_agent_preview}; pub use ui::{all_agent_previews, get_agent_preview};

View File

@ -43,6 +43,7 @@ use zed_actions::agent::OpenConfiguration;
use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus}; use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
use crate::active_thread::{ActiveThread, ActiveThreadEvent}; use crate::active_thread::{ActiveThread, ActiveThreadEvent};
use crate::agent_diff::AgentDiff;
use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent}; use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent};
use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry}; use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry};
use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent};
@ -51,9 +52,9 @@ use crate::thread_history::{PastContext, PastThread, ThreadHistory};
use crate::thread_store::ThreadStore; use crate::thread_store::ThreadStore;
use crate::ui::UsageBanner; use crate::ui::UsageBanner;
use crate::{ use crate::{
AddContextServer, AgentDiff, DeleteRecentlyOpenThread, ExpandMessageEditor, InlineAssistant, AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor,
NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, OpenHistory, ThreadEvent, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
}; };
const AGENT_PANEL_KEY: &str = "agent_panel"; const AGENT_PANEL_KEY: &str = "agent_panel";
@ -103,7 +104,7 @@ pub fn init(cx: &mut App) {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) { if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
workspace.focus_panel::<AssistantPanel>(window, cx); workspace.focus_panel::<AssistantPanel>(window, cx);
let thread = panel.read(cx).thread.read(cx).thread().clone(); let thread = panel.read(cx).thread.read(cx).thread().clone();
AgentDiff::deploy_in_workspace(thread, workspace, window, cx); AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
} }
}) })
.register_action(|workspace, _: &ExpandMessageEditor, window, cx| { .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
@ -474,6 +475,7 @@ impl AssistantPanel {
cx, cx,
) )
}); });
AgentDiff::set_active_thread(&workspace, &thread, cx);
let active_thread_subscription = let active_thread_subscription =
cx.subscribe(&active_thread, |_, _, event, cx| match &event { cx.subscribe(&active_thread, |_, _, event, cx| match &event {
@ -717,6 +719,7 @@ impl AssistantPanel {
cx, cx,
) )
}); });
AgentDiff::set_active_thread(&self.workspace, &thread, cx);
let active_thread_subscription = let active_thread_subscription =
cx.subscribe(&self.thread, |_, _, event, cx| match &event { cx.subscribe(&self.thread, |_, _, event, cx| match &event {
@ -914,6 +917,7 @@ impl AssistantPanel {
cx, cx,
) )
}); });
AgentDiff::set_active_thread(&self.workspace, &thread, cx);
let active_thread_subscription = let active_thread_subscription =
cx.subscribe(&self.thread, |_, _, event, cx| match &event { cx.subscribe(&self.thread, |_, _, event, cx| match &event {
@ -989,7 +993,7 @@ impl AssistantPanel {
let thread = self.thread.read(cx).thread().clone(); let thread = self.thread.read(cx).thread().clone();
self.workspace self.workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
AgentDiff::deploy_in_workspace(thread, workspace, window, cx) AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx)
}) })
.log_err(); .log_err();
} }

View File

@ -42,8 +42,8 @@ use crate::profile_selector::ProfileSelector;
use crate::thread::{MessageCrease, Thread, TokenUsageRatio}; use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
use crate::thread_store::ThreadStore; use crate::thread_store::ThreadStore;
use crate::{ use crate::{
ActiveThread, AgentDiff, Chat, ExpandMessageEditor, NewThread, OpenAgentDiff, RemoveAllContext, ActiveThread, AgentDiffPane, Chat, ExpandMessageEditor, NewThread, OpenAgentDiff,
ToggleContextPicker, ToggleProfileSelector, register_agent_preview, RemoveAllContext, ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
}; };
#[derive(RegisterComponent)] #[derive(RegisterComponent)]
@ -168,6 +168,9 @@ impl MessageEditor {
// When context changes, reload it for token counting. // When context changes, reload it for token counting.
let _ = this.reload_context(cx); let _ = this.reload_context(cx);
}), }),
cx.observe(&thread.read(cx).action_log().clone(), |_, _, cx| {
cx.notify()
}),
]; ];
let model_selector = cx.new(|cx| { let model_selector = cx.new(|cx| {
@ -404,7 +407,7 @@ impl MessageEditor {
fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.edits_expanded = true; self.edits_expanded = true;
AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err(); AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
cx.notify(); cx.notify();
} }
@ -414,7 +417,8 @@ impl MessageEditor {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if let Ok(diff) = AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx) if let Ok(diff) =
AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx)
{ {
let path_key = multi_buffer::PathKey::for_buffer(&buffer, cx); let path_key = multi_buffer::PathKey::for_buffer(&buffer, cx);
diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx)); diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx));

View File

@ -1325,13 +1325,14 @@ impl Thread {
let mut stop_reason = StopReason::EndTurn; let mut stop_reason = StopReason::EndTurn;
let mut current_token_usage = TokenUsage::default(); let mut current_token_usage = TokenUsage::default();
if let Some(usage) = usage { thread
thread .update(cx, |_thread, cx| {
.update(cx, |_thread, cx| { if let Some(usage) = usage {
cx.emit(ThreadEvent::UsageUpdated(usage)); cx.emit(ThreadEvent::UsageUpdated(usage));
}) }
.ok(); cx.emit(ThreadEvent::NewRequest);
} })
.ok();
let mut request_assistant_message_id = None; let mut request_assistant_message_id = None;
@ -1962,6 +1963,11 @@ impl Thread {
} }
self.finalize_pending_checkpoint(cx); self.finalize_pending_checkpoint(cx);
if canceled {
cx.emit(ThreadEvent::CompletionCanceled);
}
canceled canceled
} }
@ -2463,6 +2469,7 @@ pub enum ThreadEvent {
UsageUpdated(RequestUsage), UsageUpdated(RequestUsage),
StreamedCompletion, StreamedCompletion,
ReceivedTextChunk, ReceivedTextChunk,
NewRequest,
StreamedAssistantText(MessageId, String), StreamedAssistantText(MessageId, String),
StreamedAssistantThinking(MessageId, String), StreamedAssistantThinking(MessageId, String),
StreamedToolUse { StreamedToolUse {
@ -2493,6 +2500,7 @@ pub enum ThreadEvent {
CheckpointChanged, CheckpointChanged,
ToolConfirmationNeeded, ToolConfirmationNeeded,
CancelEditing, CancelEditing,
CompletionCanceled,
} }
impl EventEmitter<ThreadEvent> for Thread {} impl EventEmitter<ThreadEvent> for Thread {}

View File

@ -69,7 +69,7 @@ pub enum AssistantProviderContentV1 {
}, },
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug)]
pub struct AssistantSettings { pub struct AssistantSettings {
pub enabled: bool, pub enabled: bool,
pub button: bool, pub button: bool,
@ -88,6 +88,32 @@ pub struct AssistantSettings {
pub always_allow_tool_actions: bool, pub always_allow_tool_actions: bool,
pub notify_when_agent_waiting: NotifyWhenAgentWaiting, pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
pub stream_edits: bool, pub stream_edits: bool,
pub single_file_review: bool,
}
impl Default for AssistantSettings {
fn default() -> Self {
Self {
enabled: Default::default(),
button: Default::default(),
dock: Default::default(),
default_width: Default::default(),
default_height: Default::default(),
default_model: Default::default(),
inline_assistant_model: Default::default(),
commit_message_model: Default::default(),
thread_summary_model: Default::default(),
inline_alternatives: Default::default(),
using_outdated_settings_version: Default::default(),
enable_experimental_live_diffs: Default::default(),
default_profile: Default::default(),
profiles: Default::default(),
always_allow_tool_actions: Default::default(),
notify_when_agent_waiting: Default::default(),
stream_edits: Default::default(),
single_file_review: true,
}
}
} }
impl AssistantSettings { impl AssistantSettings {
@ -224,6 +250,7 @@ impl AssistantSettingsContent {
always_allow_tool_actions: None, always_allow_tool_actions: None,
notify_when_agent_waiting: None, notify_when_agent_waiting: None,
stream_edits: None, stream_edits: None,
single_file_review: None,
}, },
VersionedAssistantSettingsContent::V2(ref settings) => settings.clone(), VersionedAssistantSettingsContent::V2(ref settings) => settings.clone(),
}, },
@ -252,6 +279,7 @@ impl AssistantSettingsContent {
always_allow_tool_actions: None, always_allow_tool_actions: None,
notify_when_agent_waiting: None, notify_when_agent_waiting: None,
stream_edits: None, stream_edits: None,
single_file_review: None,
}, },
None => AssistantSettingsContentV2::default(), None => AssistantSettingsContentV2::default(),
} }
@ -503,6 +531,7 @@ impl Default for VersionedAssistantSettingsContent {
always_allow_tool_actions: None, always_allow_tool_actions: None,
notify_when_agent_waiting: None, notify_when_agent_waiting: None,
stream_edits: None, stream_edits: None,
single_file_review: None,
}) })
} }
} }
@ -562,6 +591,10 @@ pub struct AssistantSettingsContentV2 {
/// ///
/// Default: false /// Default: false
stream_edits: Option<bool>, stream_edits: Option<bool>,
/// Whether to display agent edits in single-file editors in addition to the review multibuffer pane.
///
/// Default: true
single_file_review: Option<bool>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@ -725,6 +758,7 @@ impl Settings for AssistantSettings {
value.notify_when_agent_waiting, value.notify_when_agent_waiting,
); );
merge(&mut settings.stream_edits, value.stream_edits); merge(&mut settings.stream_edits, value.stream_edits);
merge(&mut settings.single_file_review, value.single_file_review);
merge(&mut settings.default_profile, value.default_profile); merge(&mut settings.default_profile, value.default_profile);
if let Some(profiles) = value.profiles { if let Some(profiles) = value.profiles {
@ -857,6 +891,7 @@ mod tests {
always_allow_tool_actions: None, always_allow_tool_actions: None,
notify_when_agent_waiting: None, notify_when_agent_waiting: None,
stream_edits: None, stream_edits: None,
single_file_review: None,
}, },
)), )),
} }

View File

@ -981,6 +981,8 @@ pub struct Editor {
addons: HashMap<TypeId, Box<dyn Addon>>, addons: HashMap<TypeId, Box<dyn Addon>>,
registered_buffers: HashMap<BufferId, OpenLspBufferHandle>, registered_buffers: HashMap<BufferId, OpenLspBufferHandle>,
load_diff_task: Option<Shared<Task<()>>>, load_diff_task: Option<Shared<Task<()>>>,
/// Whether we are temporarily displaying a diff other than git's
temporary_diff_override: bool,
selection_mark_mode: bool, selection_mark_mode: bool,
toggle_fold_multiple_buffers: Task<()>, toggle_fold_multiple_buffers: Task<()>,
_scroll_cursor_center_top_bottom_task: Task<()>, _scroll_cursor_center_top_bottom_task: Task<()>,
@ -1626,7 +1628,8 @@ impl Editor {
let mut load_uncommitted_diff = None; let mut load_uncommitted_diff = None;
if let Some(project) = project.clone() { if let Some(project) = project.clone() {
load_uncommitted_diff = Some( load_uncommitted_diff = Some(
get_uncommitted_diff_for_buffer( update_uncommitted_diff_for_buffer(
cx.entity(),
&project, &project,
buffer.read(cx).all_buffers(), buffer.read(cx).all_buffers(),
buffer.clone(), buffer.clone(),
@ -1802,6 +1805,7 @@ impl Editor {
serialize_folds: Task::ready(()), serialize_folds: Task::ready(()),
text_style_refinement: None, text_style_refinement: None,
load_diff_task: load_uncommitted_diff, load_diff_task: load_uncommitted_diff,
temporary_diff_override: false,
mouse_cursor_hidden: false, mouse_cursor_hidden: false,
hide_mouse_mode: EditorSettings::get_global(cx) hide_mouse_mode: EditorSettings::get_global(cx)
.hide_mouse .hide_mouse
@ -1941,7 +1945,7 @@ impl Editor {
.is_some_and(|menu| menu.context_menu.focus_handle(cx).is_focused(window)) .is_some_and(|menu| menu.context_menu.focus_handle(cx).is_focused(window))
} }
fn key_context(&self, window: &Window, cx: &App) -> KeyContext { pub fn key_context(&self, window: &Window, cx: &App) -> KeyContext {
self.key_context_internal(self.has_active_inline_completion(), window, cx) self.key_context_internal(self.has_active_inline_completion(), window, cx)
} }
@ -13649,7 +13653,7 @@ impl Editor {
self.refresh_inline_completion(false, true, window, cx); self.refresh_inline_completion(false, true, window, cx);
} }
fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context<Self>) { pub fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context<Self>) {
self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction);
let snapshot = self.snapshot(window, cx); let snapshot = self.snapshot(window, cx);
let selection = self.selections.newest::<Point>(cx); let selection = self.selections.newest::<Point>(cx);
@ -17820,7 +17824,8 @@ impl Editor {
let buffer_id = buffer.read(cx).remote_id(); let buffer_id = buffer.read(cx).remote_id();
if self.buffer.read(cx).diff_for(buffer_id).is_none() { if self.buffer.read(cx).diff_for(buffer_id).is_none() {
if let Some(project) = &self.project { if let Some(project) = &self.project {
get_uncommitted_diff_for_buffer( update_uncommitted_diff_for_buffer(
cx.entity(),
project, project,
[buffer.clone()], [buffer.clone()],
self.buffer.clone(), self.buffer.clone(),
@ -17896,6 +17901,32 @@ impl Editor {
}; };
} }
pub fn start_temporary_diff_override(&mut self) {
self.load_diff_task.take();
self.temporary_diff_override = true;
}
pub fn end_temporary_diff_override(&mut self, cx: &mut Context<Self>) {
self.temporary_diff_override = false;
self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx);
self.buffer.update(cx, |buffer, cx| {
buffer.set_all_diff_hunks_collapsed(cx);
});
if let Some(project) = self.project.clone() {
self.load_diff_task = Some(
update_uncommitted_diff_for_buffer(
cx.entity(),
&project,
self.buffer.read(cx).all_buffers(),
self.buffer.clone(),
cx,
)
.shared(),
);
}
}
fn on_display_map_changed( fn on_display_map_changed(
&mut self, &mut self,
_: Entity<DisplayMap>, _: Entity<DisplayMap>,
@ -18875,7 +18906,8 @@ fn insert_extra_newline_tree_sitter(buffer: &MultiBufferSnapshot, range: Range<u
.all(|c| c.is_whitespace() && c != '\n') .all(|c| c.is_whitespace() && c != '\n')
} }
fn get_uncommitted_diff_for_buffer( fn update_uncommitted_diff_for_buffer(
editor: Entity<Editor>,
project: &Entity<Project>, project: &Entity<Project>,
buffers: impl IntoIterator<Item = Entity<Buffer>>, buffers: impl IntoIterator<Item = Entity<Buffer>>,
buffer: Entity<MultiBuffer>, buffer: Entity<MultiBuffer>,
@ -18891,6 +18923,13 @@ fn get_uncommitted_diff_for_buffer(
}); });
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let diffs = future::join_all(tasks).await; let diffs = future::join_all(tasks).await;
if editor
.read_with(cx, |editor, _cx| editor.temporary_diff_override)
.unwrap_or(false)
{
return;
}
buffer buffer
.update(cx, |buffer, cx| { .update(cx, |buffer, cx| {
for diff in diffs.into_iter().flatten() { for diff in diffs.into_iter().flatten() {

View File

@ -234,9 +234,11 @@ impl ExampleContext {
tx.try_send(Err(anyhow!(err.clone()))).ok(); tx.try_send(Err(anyhow!(err.clone()))).ok();
} }
}, },
ThreadEvent::StreamedAssistantText(_, _) ThreadEvent::NewRequest
| ThreadEvent::StreamedAssistantText(_, _)
| ThreadEvent::StreamedAssistantThinking(_, _) | ThreadEvent::StreamedAssistantThinking(_, _)
| ThreadEvent::UsePendingTools { .. } => {} | ThreadEvent::UsePendingTools { .. }
| ThreadEvent::CompletionCanceled => {}
ThreadEvent::ToolFinished { ThreadEvent::ToolFinished {
tool_use_id, tool_use_id,
pending_tool_use, pending_tool_use,

View File

@ -2633,6 +2633,11 @@ impl MultiBuffer {
self.snapshot.borrow().all_diff_hunks_expanded self.snapshot.borrow().all_diff_hunks_expanded
} }
pub fn set_all_diff_hunks_collapsed(&mut self, cx: &mut Context<Self>) {
self.snapshot.borrow_mut().all_diff_hunks_expanded = false;
self.expand_or_collapse_diff_hunks(vec![Anchor::min()..Anchor::max()], false, cx);
}
pub fn has_multiple_hunks(&self, cx: &App) -> bool { pub fn has_multiple_hunks(&self, cx: &App) -> bool {
self.read(cx) self.read(cx)
.diff_hunks_in_range(Anchor::min()..Anchor::max()) .diff_hunks_in_range(Anchor::min()..Anchor::max())

View File

@ -111,6 +111,7 @@ impl Render for Toolbar {
}) })
.border_b_1() .border_b_1()
.border_color(cx.theme().colors().border_variant) .border_color(cx.theme().colors().border_variant)
.relative()
.bg(cx.theme().colors().toolbar_background) .bg(cx.theme().colors().toolbar_background)
.when(has_left_items || has_right_items, |this| { .when(has_left_items || has_right_items, |this| {
this.child( this.child(