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

View File

@ -247,6 +247,17 @@
"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",
"use_key_equivalents": true,

View File

@ -765,7 +765,6 @@ impl ActiveThread {
.unwrap()
}
});
let mut this = Self {
language_registry,
thread_store,
@ -954,6 +953,9 @@ impl ActiveThread {
ThreadEvent::UsageUpdated(usage) => {
self.last_usage = Some(*usage);
}
ThreadEvent::NewRequest | ThreadEvent::CompletionCanceled => {
cx.notify();
}
ThreadEvent::StreamedCompletion
| ThreadEvent::SummaryGenerated
| 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::thread::{Message, MessageSegment, Thread, ThreadEvent};
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 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 crate::active_thread::{ActiveThread, ActiveThreadEvent};
use crate::agent_diff::AgentDiff;
use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent};
use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry};
use crate::message_editor::{MessageEditor, MessageEditorEvent};
@ -51,9 +52,9 @@ use crate::thread_history::{PastContext, PastThread, ThreadHistory};
use crate::thread_store::ThreadStore;
use crate::ui::UsageBanner;
use crate::{
AddContextServer, AgentDiff, DeleteRecentlyOpenThread, ExpandMessageEditor, InlineAssistant,
NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent,
ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor,
InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
OpenHistory, ThreadEvent, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
};
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) {
workspace.focus_panel::<AssistantPanel>(window, cx);
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| {
@ -474,6 +475,7 @@ impl AssistantPanel {
cx,
)
});
AgentDiff::set_active_thread(&workspace, &thread, cx);
let active_thread_subscription =
cx.subscribe(&active_thread, |_, _, event, cx| match &event {
@ -717,6 +719,7 @@ impl AssistantPanel {
cx,
)
});
AgentDiff::set_active_thread(&self.workspace, &thread, cx);
let active_thread_subscription =
cx.subscribe(&self.thread, |_, _, event, cx| match &event {
@ -914,6 +917,7 @@ impl AssistantPanel {
cx,
)
});
AgentDiff::set_active_thread(&self.workspace, &thread, cx);
let active_thread_subscription =
cx.subscribe(&self.thread, |_, _, event, cx| match &event {
@ -989,7 +993,7 @@ impl AssistantPanel {
let thread = self.thread.read(cx).thread().clone();
self.workspace
.update(cx, |workspace, cx| {
AgentDiff::deploy_in_workspace(thread, workspace, window, cx)
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx)
})
.log_err();
}

View File

@ -42,8 +42,8 @@ use crate::profile_selector::ProfileSelector;
use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
use crate::thread_store::ThreadStore;
use crate::{
ActiveThread, AgentDiff, Chat, ExpandMessageEditor, NewThread, OpenAgentDiff, RemoveAllContext,
ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
ActiveThread, AgentDiffPane, Chat, ExpandMessageEditor, NewThread, OpenAgentDiff,
RemoveAllContext, ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
};
#[derive(RegisterComponent)]
@ -168,6 +168,9 @@ impl MessageEditor {
// When context changes, reload it for token counting.
let _ = this.reload_context(cx);
}),
cx.observe(&thread.read(cx).action_log().clone(), |_, _, cx| {
cx.notify()
}),
];
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>) {
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();
}
@ -414,7 +417,8 @@ impl MessageEditor {
window: &mut Window,
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);
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 current_token_usage = TokenUsage::default();
if let Some(usage) = usage {
thread
.update(cx, |_thread, cx| {
thread
.update(cx, |_thread, cx| {
if let Some(usage) = usage {
cx.emit(ThreadEvent::UsageUpdated(usage));
})
.ok();
}
}
cx.emit(ThreadEvent::NewRequest);
})
.ok();
let mut request_assistant_message_id = None;
@ -1962,6 +1963,11 @@ impl Thread {
}
self.finalize_pending_checkpoint(cx);
if canceled {
cx.emit(ThreadEvent::CompletionCanceled);
}
canceled
}
@ -2463,6 +2469,7 @@ pub enum ThreadEvent {
UsageUpdated(RequestUsage),
StreamedCompletion,
ReceivedTextChunk,
NewRequest,
StreamedAssistantText(MessageId, String),
StreamedAssistantThinking(MessageId, String),
StreamedToolUse {
@ -2493,6 +2500,7 @@ pub enum ThreadEvent {
CheckpointChanged,
ToolConfirmationNeeded,
CancelEditing,
CompletionCanceled,
}
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 enabled: bool,
pub button: bool,
@ -88,6 +88,32 @@ pub struct AssistantSettings {
pub always_allow_tool_actions: bool,
pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
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 {
@ -224,6 +250,7 @@ impl AssistantSettingsContent {
always_allow_tool_actions: None,
notify_when_agent_waiting: None,
stream_edits: None,
single_file_review: None,
},
VersionedAssistantSettingsContent::V2(ref settings) => settings.clone(),
},
@ -252,6 +279,7 @@ impl AssistantSettingsContent {
always_allow_tool_actions: None,
notify_when_agent_waiting: None,
stream_edits: None,
single_file_review: None,
},
None => AssistantSettingsContentV2::default(),
}
@ -503,6 +531,7 @@ impl Default for VersionedAssistantSettingsContent {
always_allow_tool_actions: None,
notify_when_agent_waiting: None,
stream_edits: None,
single_file_review: None,
})
}
}
@ -562,6 +591,10 @@ pub struct AssistantSettingsContentV2 {
///
/// Default: false
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)]
@ -725,6 +758,7 @@ impl Settings for AssistantSettings {
value.notify_when_agent_waiting,
);
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);
if let Some(profiles) = value.profiles {
@ -857,6 +891,7 @@ mod tests {
always_allow_tool_actions: None,
notify_when_agent_waiting: None,
stream_edits: None,
single_file_review: None,
},
)),
}

View File

@ -981,6 +981,8 @@ pub struct Editor {
addons: HashMap<TypeId, Box<dyn Addon>>,
registered_buffers: HashMap<BufferId, OpenLspBufferHandle>,
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,
toggle_fold_multiple_buffers: Task<()>,
_scroll_cursor_center_top_bottom_task: Task<()>,
@ -1626,7 +1628,8 @@ impl Editor {
let mut load_uncommitted_diff = None;
if let Some(project) = project.clone() {
load_uncommitted_diff = Some(
get_uncommitted_diff_for_buffer(
update_uncommitted_diff_for_buffer(
cx.entity(),
&project,
buffer.read(cx).all_buffers(),
buffer.clone(),
@ -1802,6 +1805,7 @@ impl Editor {
serialize_folds: Task::ready(()),
text_style_refinement: None,
load_diff_task: load_uncommitted_diff,
temporary_diff_override: false,
mouse_cursor_hidden: false,
hide_mouse_mode: EditorSettings::get_global(cx)
.hide_mouse
@ -1941,7 +1945,7 @@ impl Editor {
.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)
}
@ -13649,7 +13653,7 @@ impl Editor {
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);
let snapshot = self.snapshot(window, cx);
let selection = self.selections.newest::<Point>(cx);
@ -17820,7 +17824,8 @@ impl Editor {
let buffer_id = buffer.read(cx).remote_id();
if self.buffer.read(cx).diff_for(buffer_id).is_none() {
if let Some(project) = &self.project {
get_uncommitted_diff_for_buffer(
update_uncommitted_diff_for_buffer(
cx.entity(),
project,
[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(
&mut self,
_: Entity<DisplayMap>,
@ -18875,7 +18906,8 @@ fn insert_extra_newline_tree_sitter(buffer: &MultiBufferSnapshot, range: Range<u
.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>,
buffers: impl IntoIterator<Item = Entity<Buffer>>,
buffer: Entity<MultiBuffer>,
@ -18891,6 +18923,13 @@ fn get_uncommitted_diff_for_buffer(
});
cx.spawn(async move |cx| {
let diffs = future::join_all(tasks).await;
if editor
.read_with(cx, |editor, _cx| editor.temporary_diff_override)
.unwrap_or(false)
{
return;
}
buffer
.update(cx, |buffer, cx| {
for diff in diffs.into_iter().flatten() {

View File

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

View File

@ -2633,6 +2633,11 @@ impl MultiBuffer {
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 {
self.read(cx)
.diff_hunks_in_range(Anchor::min()..Anchor::max())

View File

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