From 545ae27079abca8fc42f4cb690f79979a5f88302 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Sun, 4 May 2025 10:28:39 +0200 Subject: [PATCH] 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 Co-authored-by: Max Brunsfeld --- Cargo.lock | 1 - assets/icons/crosshair.svg | 1 + crates/agent/src/assistant.rs | 3 +- crates/agent/src/assistant_model_selector.rs | 12 +- crates/agent/src/assistant_panel.rs | 7 +- crates/agent/src/context_strip.rs | 28 +- crates/agent/src/message_editor.rs | 113 +++- crates/agent/src/profile_selector.rs | 33 +- crates/agent/src/thread.rs | 11 +- .../src/context_editor.rs | 14 +- crates/assistant_tool/src/action_log.rs | 4 + crates/assistant_tools/src/edit_agent.rs | 338 ++++++++-- .../assistant_tools/src/edit_agent/evals.rs | 6 +- crates/assistant_tools/src/edit_file_tool.rs | 31 +- crates/assistant_tools/src/read_file_tool.rs | 47 +- .../src/streaming_edit_file_tool.rs | 2 +- crates/clock/src/clock.rs | 1 + .../collab/src/tests/channel_buffer_tests.rs | 12 +- crates/collab/src/tests/following_tests.rs | 36 +- crates/collab_ui/src/channel_view.rs | 11 +- crates/debugger_ui/src/session.rs | 8 +- crates/editor/src/editor.rs | 60 +- crates/editor/src/editor_tests.rs | 4 +- crates/editor/src/element.rs | 61 +- crates/editor/src/items.rs | 40 +- crates/icons/src/icons.rs | 1 + crates/image_viewer/src/image_viewer.rs | 2 +- crates/language/src/buffer.rs | 27 +- crates/project/src/project.rs | 62 +- .../project_panel/src/project_panel_tests.rs | 2 +- crates/repl/src/notebook/notebook_ui.rs | 2 +- crates/theme/src/styles/players.rs | 4 + crates/vim/src/vim.rs | 4 +- crates/workspace/Cargo.toml | 1 - crates/workspace/src/item.rs | 79 ++- crates/workspace/src/pane_group.rs | 168 ++--- crates/workspace/src/workspace.rs | 586 ++++++++++++------ 37 files changed, 1255 insertions(+), 567 deletions(-) create mode 100644 assets/icons/crosshair.svg diff --git a/Cargo.lock b/Cargo.lock index 6a76f96cd9..d3cbc2afb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18092,7 +18092,6 @@ dependencies = [ "component", "dap", "db", - "derive_more", "env_logger 0.11.8", "fs", "futures 0.3.31", diff --git a/assets/icons/crosshair.svg b/assets/icons/crosshair.svg new file mode 100644 index 0000000000..cd8c40ed03 --- /dev/null +++ b/assets/icons/crosshair.svg @@ -0,0 +1 @@ + diff --git a/crates/agent/src/assistant.rs b/crates/agent/src/assistant.rs index 8f45ca042b..d454f04343 100644 --- a/crates/agent/src/assistant.rs +++ b/crates/agent/src/assistant.rs @@ -77,7 +77,8 @@ actions!( Keep, Reject, RejectAll, - KeepAll + KeepAll, + Follow ] ); diff --git a/crates/agent/src/assistant_model_selector.rs b/crates/agent/src/assistant_model_selector.rs index fc8d035293..47bb62be9c 100644 --- a/crates/agent/src/assistant_model_selector.rs +++ b/crates/agent/src/assistant_model_selector.rs @@ -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) diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 2d93f76b85..0e8836ebaa 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -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::(cx) { workspace.focus_panel::(window, cx); diff --git a/crates/agent/src/context_strip.rs b/crates/agent/src/context_strip.rs index e8184d9055..b06446483c 100644 --- a/crates/agent/src/context_strip.rs +++ b/crates/agent/src/context_strip.rs @@ -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) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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() diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 30e3ada988..2ac2d9f92d 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -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) -> 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() diff --git a/crates/agent/src/profile_selector.rs b/crates/agent/src/profile_selector.rs index fa4032d28c..db08e93577 100644 --- a/crates/agent/src/profile_selector.rs +++ b/crates/agent/src/profile_selector.rs @@ -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, thread_store: WeakEntity, - focus_handle: FocusHandle, menu_handle: PopoverMenuHandle, _subscriptions: Vec, } @@ -28,7 +26,6 @@ impl ProfileSelector { pub fn new( fs: Arc, thread_store: WeakEntity, - focus_handle: FocusHandle, cx: &mut Context, ) -> Self { let settings_subscription = cx.observe_global::(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) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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") diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 4a90916888..02fee57a47 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -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::() { cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired)); } else if error.is::() { diff --git a/crates/assistant_context_editor/src/context_editor.rs b/crates/assistant_context_editor/src/context_editor.rs index b2a9b884b7..894da1b00b 100644 --- a/crates/assistant_context_editor/src/context_editor.rs +++ b/crates/assistant_context_editor/src/context_editor.rs @@ -62,7 +62,10 @@ use ui::{ prelude::*, }; use util::{ResultExt, maybe}; -use workspace::searchable::{Direction, SearchableItemHandle}; +use workspace::{ + CollaboratorId, + searchable::{Direction, SearchableItemHandle}, +}; use workspace::{ Save, Toast, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::{self, FollowableItem, Item, ItemHandle}, @@ -3417,15 +3420,14 @@ impl FollowableItem for ContextEditor { true } - fn set_leader_peer_id( + fn set_leader_id( &mut self, - leader_peer_id: Option, + leader_id: Option, window: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.set_leader_peer_id(leader_peer_id, window, cx) - }) + self.editor + .update(cx, |editor, cx| editor.set_leader_id(leader_id, window, cx)) } fn dedup(&self, existing: &Self, _window: &Window, cx: &App) -> Option { diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs index 61fc983b9e..de6263b5a7 100644 --- a/crates/assistant_tool/src/action_log.rs +++ b/crates/assistant_tool/src/action_log.rs @@ -29,6 +29,10 @@ impl ActionLog { } } + pub fn project(&self) -> &Entity { + &self.project + } + /// Notifies a diagnostics check pub fn checked_project_diagnostics(&mut self) { self.edited_since_project_diagnostics_check = false; diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/assistant_tools/src/edit_agent.rs index 8925f9b7fc..6495bbe1fc 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/assistant_tools/src/edit_agent.rs @@ -19,6 +19,7 @@ use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, }; +use project::{AgentLocation, Project}; use serde::Serialize; use std::{cmp, iter, mem, ops::Range, path::PathBuf, sync::Arc, task::Poll}; use streaming_diff::{CharOperation, StreamingDiff}; @@ -59,17 +60,20 @@ pub struct EditAgentOutput { pub struct EditAgent { model: Arc, action_log: Entity, + project: Entity, templates: Arc, } impl EditAgent { pub fn new( model: Arc, + project: Entity, action_log: Entity, templates: Arc, ) -> Self { EditAgent { model, + project, action_log, templates, } @@ -118,39 +122,74 @@ impl EditAgent { let (output_events_tx, output_events_rx) = mpsc::unbounded(); let this = self.clone(); let task = cx.spawn(async move |cx| { - // Ensure the buffer is tracked by the action log. this.action_log .update(cx, |log, cx| log.track_buffer(buffer.clone(), cx))?; - - cx.update(|cx| { - buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); - this.action_log - .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - })?; - - let mut raw_edits = String::new(); - pin_mut!(edit_chunks); - while let Some(chunk) = edit_chunks.next().await { - let chunk = chunk?; - raw_edits.push_str(&chunk); - cx.update(|cx| { - buffer.update(cx, |buffer, cx| buffer.append(chunk, cx)); - this.action_log - .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - })?; - output_events_tx - .unbounded_send(EditAgentOutputEvent::Edited) - .ok(); - } - - Ok(EditAgentOutput { - _raw_edits: raw_edits, - _parser_metrics: EditParserMetrics::default(), - }) + let output = this + .replace_text_with_chunks_internal(buffer, edit_chunks, output_events_tx, cx) + .await; + this.project + .update(cx, |project, cx| project.set_agent_location(None, cx))?; + output }); (task, output_events_rx) } + async fn replace_text_with_chunks_internal( + &self, + buffer: Entity, + edit_chunks: impl 'static + Send + Stream>, + output_events_tx: mpsc::UnboundedSender, + cx: &mut AsyncApp, + ) -> Result { + cx.update(|cx| { + buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); + self.action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + }); + self.project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MAX, + }), + cx, + ) + }); + output_events_tx + .unbounded_send(EditAgentOutputEvent::Edited) + .ok(); + })?; + + let mut raw_edits = String::new(); + pin_mut!(edit_chunks); + while let Some(chunk) = edit_chunks.next().await { + let chunk = chunk?; + raw_edits.push_str(&chunk); + cx.update(|cx| { + buffer.update(cx, |buffer, cx| buffer.append(chunk, cx)); + self.action_log + .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + self.project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MAX, + }), + cx, + ) + }); + })?; + output_events_tx + .unbounded_send(EditAgentOutputEvent::Edited) + .ok(); + } + + Ok(EditAgentOutput { + _raw_edits: raw_edits, + _parser_metrics: EditParserMetrics::default(), + }) + } + pub fn edit( &self, buffer: Entity, @@ -161,6 +200,18 @@ impl EditAgent { Task>, mpsc::UnboundedReceiver, ) { + self.project + .update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MIN, + }), + cx, + ); + }) + .ok(); + let this = self.clone(); let (events_tx, events_rx) = mpsc::unbounded(); let output = cx.spawn(async move |cx| { @@ -194,8 +245,14 @@ impl EditAgent { let (output_events_tx, output_events_rx) = mpsc::unbounded(); let this = self.clone(); let task = cx.spawn(async move |mut cx| { - this.apply_edits_internal(buffer, edit_chunks, output_events_tx, &mut cx) - .await + this.action_log + .update(cx, |log, cx| log.track_buffer(buffer.clone(), cx))?; + let output = this + .apply_edits_internal(buffer, edit_chunks, output_events_tx, &mut cx) + .await; + this.project + .update(cx, |project, cx| project.set_agent_location(None, cx))?; + output }); (task, output_events_rx) } @@ -207,10 +264,6 @@ impl EditAgent { output_events: mpsc::UnboundedSender, cx: &mut AsyncApp, ) -> Result { - // Ensure the buffer is tracked by the action log. - self.action_log - .update(cx, |log, cx| log.track_buffer(buffer.clone(), cx))?; - let (output, mut edit_events) = Self::parse_edit_chunks(edit_chunks, cx); while let Some(edit_event) = edit_events.next().await { let EditParserEvent::OldText(old_text_query) = edit_event? else { @@ -275,14 +328,15 @@ impl EditAgent { match op { CharOperation::Insert { text } => { let edit_start = snapshot.anchor_after(edit_start); - edits_tx.unbounded_send((edit_start..edit_start, text))?; + edits_tx + .unbounded_send((edit_start..edit_start, Arc::from(text)))?; } CharOperation::Delete { bytes } => { let edit_end = edit_start + bytes; let edit_range = snapshot.anchor_after(edit_start) ..snapshot.anchor_before(edit_end); edit_start = edit_end; - edits_tx.unbounded_send((edit_range, String::new()))?; + edits_tx.unbounded_send((edit_range, Arc::from("")))?; } CharOperation::Keep { bytes } => edit_start += bytes, } @@ -296,13 +350,35 @@ impl EditAgent { // TODO: group all edits into one transaction let mut edits_rx = edits_rx.ready_chunks(32); while let Some(edits) = edits_rx.next().await { + if edits.is_empty() { + continue; + } + // Edit the buffer and report edits to the action log as part of the // same effect cycle, otherwise the edit will be reported as if the // user made it. cx.update(|cx| { - buffer.update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + let max_edit_end = buffer.update(cx, |buffer, cx| { + buffer.edit(edits.iter().cloned(), None, cx); + let max_edit_end = buffer + .summaries_for_anchors::( + edits.iter().map(|(range, _)| &range.end), + ) + .max() + .unwrap(); + buffer.anchor_before(max_edit_end) + }); self.action_log - .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)) + .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + self.project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: max_edit_end, + }), + cx, + ); + }); })?; output_events .unbounded_send(EditAgentOutputEvent::Edited) @@ -657,7 +733,7 @@ mod tests { use gpui::{App, AppContext, TestAppContext}; use indoc::indoc; use language_model::fake_provider::FakeLanguageModel; - use project::Project; + use project::{AgentLocation, Project}; use rand::prelude::*; use rand::rngs::StdRng; use std::cmp; @@ -775,8 +851,11 @@ mod tests { } #[gpui::test] - async fn test_events(cx: &mut TestAppContext) { + async fn test_edit_events(cx: &mut TestAppContext) { let agent = init_test(cx).await; + let project = agent + .action_log + .read_with(cx, |log, _| log.project().clone()); let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx)); let (chunks_tx, chunks_rx) = mpsc::unbounded(); let (apply, mut events) = agent.apply_edit_chunks( @@ -792,6 +871,10 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abc\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + None + ); chunks_tx.unbounded_send("bc").unwrap(); cx.run_until_parked(); @@ -800,6 +883,10 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abc\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + None + ); chunks_tx.unbounded_send("abX").unwrap(); cx.run_until_parked(); @@ -808,6 +895,13 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXc\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 3))) + }) + ); chunks_tx.unbounded_send("cY").unwrap(); cx.run_until_parked(); @@ -816,6 +910,13 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXcY\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) + }) + ); chunks_tx.unbounded_send("").unwrap(); chunks_tx.unbounded_send("hall").unwrap(); @@ -825,6 +926,13 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXcY\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) + }) + ); chunks_tx.unbounded_send("ucinated old").unwrap(); chunks_tx.unbounded_send("").unwrap(); @@ -839,6 +947,13 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXcY\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) + }) + ); chunks_tx.unbounded_send("hallucinated new").unwrap(); @@ -848,6 +963,13 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXcY\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) + }) + ); chunks_tx.unbounded_send("gh").unwrap(); chunks_tx.unbounded_send("i").unwrap(); @@ -858,6 +980,13 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXcY\ndef\nghi" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 5))) + }) + ); chunks_tx.unbounded_send("GHI").unwrap(); cx.run_until_parked(); @@ -869,6 +998,13 @@ mod tests { buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), "abXcY\ndef\nGHI" ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(2, 3))) + }) + ); drop(chunks_tx); apply.await.unwrap(); @@ -877,16 +1013,108 @@ mod tests { "abXcY\ndef\nGHI" ); assert_eq!(drain_events(&mut events), vec![]); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + None + ); + } - fn drain_events( - stream: &mut UnboundedReceiver, - ) -> Vec { - let mut events = Vec::new(); - while let Ok(Some(event)) = stream.try_next() { - events.push(event); - } - events - } + #[gpui::test] + async fn test_overwrite_events(cx: &mut TestAppContext) { + let agent = init_test(cx).await; + let project = agent + .action_log + .read_with(cx, |log, _| log.project().clone()); + let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx)); + let (chunks_tx, chunks_rx) = mpsc::unbounded(); + let (apply, mut events) = agent.replace_text_with_chunks( + buffer.clone(), + chunks_rx.map(|chunk: &str| Ok(chunk.to_string())), + &mut cx.to_async(), + ); + + cx.run_until_parked(); + assert_eq!( + drain_events(&mut events), + vec![EditAgentOutputEvent::Edited] + ); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), + "" + ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MAX + }) + ); + + chunks_tx.unbounded_send("jkl\n").unwrap(); + cx.run_until_parked(); + assert_eq!( + drain_events(&mut events), + vec![EditAgentOutputEvent::Edited] + ); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), + "jkl\n" + ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MAX + }) + ); + + chunks_tx.unbounded_send("mno\n").unwrap(); + cx.run_until_parked(); + assert_eq!( + drain_events(&mut events), + vec![EditAgentOutputEvent::Edited] + ); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), + "jkl\nmno\n" + ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MAX + }) + ); + + chunks_tx.unbounded_send("pqr").unwrap(); + cx.run_until_parked(); + assert_eq!( + drain_events(&mut events), + vec![EditAgentOutputEvent::Edited] + ); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), + "jkl\nmno\npqr" + ); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MAX + }) + ); + + drop(chunks_tx); + apply.await.unwrap(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), + "jkl\nmno\npqr" + ); + assert_eq!(drain_events(&mut events), vec![]); + assert_eq!( + project.read_with(cx, |project, _| project.agent_location()), + None + ); } #[gpui::test] @@ -1173,7 +1401,17 @@ mod tests { cx.update(Project::init_settings); let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; let model = Arc::new(FakeLanguageModel::default()); - let action_log = cx.new(|_| ActionLog::new(project)); - EditAgent::new(model, action_log, Templates::new()) + let action_log = cx.new(|_| ActionLog::new(project.clone())); + EditAgent::new(model, project, action_log, Templates::new()) + } + + fn drain_events( + stream: &mut UnboundedReceiver, + ) -> Vec { + let mut events = Vec::new(); + while let Ok(Some(event)) = stream.try_next() { + events.push(event); + } + events } } diff --git a/crates/assistant_tools/src/edit_agent/evals.rs b/crates/assistant_tools/src/edit_agent/evals.rs index dfdc60fd2d..76099c8fa8 100644 --- a/crates/assistant_tools/src/edit_agent/evals.rs +++ b/crates/assistant_tools/src/edit_agent/evals.rs @@ -517,7 +517,7 @@ fn eval_from_pixels_constructor() { input_path: input_file_path.into(), input_content: Some(input_file_content.into()), edit_description: edit_description.into(), - assertion: EvalAssertion::assert_eq(indoc! {" + assertion: EvalAssertion::judge_diff(indoc! {" - The diff contains a new `from_pixels` constructor - The diff contains new tests for the `from_pixels` constructor "}), @@ -957,7 +957,7 @@ impl EditAgentTest { cx.spawn(async move |cx| { let agent_model = - Self::load_model("anthropic", "claude-3-7-sonnet-latest", cx).await; + Self::load_model("google", "gemini-2.5-pro-preview-03-25", cx).await; let judge_model = Self::load_model("anthropic", "claude-3-7-sonnet-latest", cx).await; (agent_model.unwrap(), judge_model.unwrap()) @@ -967,7 +967,7 @@ impl EditAgentTest { let action_log = cx.new(|_| ActionLog::new(project.clone())); Self { - agent: EditAgent::new(agent_model, action_log, Templates::new()), + agent: EditAgent::new(agent_model, project.clone(), action_log, Templates::new()), project, judge_model, } diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index 76d42d3812..e22502ef9b 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -15,7 +15,7 @@ use language::{ language_settings::SoftWrap, }; use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; -use project::Project; +use project::{AgentLocation, Project}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ @@ -164,6 +164,19 @@ impl Tool for EditFileTool { })? .await?; + // Set the agent's location to the top of the file + project + .update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::MIN, + }), + cx, + ); + }) + .ok(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; if input.old_string.is_empty() { @@ -226,6 +239,7 @@ impl Tool for EditFileTool { let snapshot = cx.update(|cx| { action_log.update(cx, |log, cx| log.track_buffer(buffer.clone(), cx)); + let base_version = diff.base_version.clone(); let snapshot = buffer.update(cx, |buffer, cx| { buffer.finalize_last_transaction(); buffer.apply_diff(diff, cx); @@ -233,6 +247,21 @@ impl Tool for EditFileTool { buffer.snapshot() }); action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + + // Set the agent's location to the position of the first edit + if let Some(first_edit) = snapshot.edits_since::(&base_version).next() { + let position = snapshot.anchor_before(first_edit.new.start); + project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position, + }), + cx, + ); + }) + } + snapshot })?; diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index a7f4f2ef2c..942bffd41b 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -6,8 +6,9 @@ use gpui::{AnyWindowHandle, App, Entity, Task}; use indoc::formatdoc; use itertools::Itertools; +use language::{Anchor, Point}; use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; -use project::Project; +use project::{AgentLocation, Project}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -35,11 +36,11 @@ pub struct ReadFileToolInput { /// Optional line number to start reading on (1-based index) #[serde(default)] - pub start_line: Option, + pub start_line: Option, /// Optional line number to end reading on (1-based index, inclusive) #[serde(default)] - pub end_line: Option, + pub end_line: Option, } pub struct ReadFileTool; @@ -109,7 +110,7 @@ impl Tool for ReadFileTool { let file_path = input.path.clone(); cx.spawn(async move |cx| { if !exists.await? { - return Err(anyhow!("{} not found", file_path)) + return Err(anyhow!("{} not found", file_path)); } let buffer = cx @@ -118,25 +119,54 @@ impl Tool for ReadFileTool { })? .await?; + project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: Anchor::MIN, + }), + cx, + ); + })?; + // Check if specific line ranges are provided if input.start_line.is_some() || input.end_line.is_some() { + let mut anchor = None; let result = buffer.read_with(cx, |buffer, _cx| { let text = buffer.text(); // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0. let start = input.start_line.unwrap_or(1).max(1); - let lines = text.split('\n').skip(start - 1); + let start_row = start - 1; + if start_row <= buffer.max_point().row { + let column = buffer.line_indent_for_row(start_row).raw_len(); + anchor = Some(buffer.anchor_before(Point::new(start_row, column))); + } + + let lines = text.split('\n').skip(start_row as usize); if let Some(end) = input.end_line { let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line - Itertools::intersperse(lines.take(count), "\n").collect() + Itertools::intersperse(lines.take(count as usize), "\n").collect() } else { Itertools::intersperse(lines, "\n").collect() } })?; action_log.update(cx, |log, cx| { - log.track_buffer(buffer, cx); + log.track_buffer(buffer.clone(), cx); })?; + if let Some(anchor) = anchor { + project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: anchor, + }), + cx, + ); + })?; + } + Ok(result) } else { // No line ranges specified, so check file size to see if it's too big. @@ -165,7 +195,8 @@ impl Tool for ReadFileTool { }) } } - }).into() + }) + .into() } } diff --git a/crates/assistant_tools/src/streaming_edit_file_tool.rs b/crates/assistant_tools/src/streaming_edit_file_tool.rs index f99ea60072..356899bad5 100644 --- a/crates/assistant_tools/src/streaming_edit_file_tool.rs +++ b/crates/assistant_tools/src/streaming_edit_file_tool.rs @@ -170,7 +170,7 @@ impl Tool for StreamingEditFileTool { .update(|cx| LanguageModelRegistry::read_global(cx).default_model())? .context("default model not set")? .model; - let edit_agent = EditAgent::new(model, action_log, Templates::new()); + let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new()); let buffer = project .update(cx, |project, cx| { diff --git a/crates/clock/src/clock.rs b/crates/clock/src/clock.rs index acbde90dc1..b4f57116d2 100644 --- a/crates/clock/src/clock.rs +++ b/crates/clock/src/clock.rs @@ -10,6 +10,7 @@ use std::{ pub use system_clock::*; pub const LOCAL_BRANCH_REPLICA_ID: u16 = u16::MAX; +pub const AGENT_REPLICA_ID: u16 = u16::MAX - 1; /// A unique identifier for each distributed node. pub type ReplicaId = u16; diff --git a/crates/collab/src/tests/channel_buffer_tests.rs b/crates/collab/src/tests/channel_buffer_tests.rs index 4ed2745aa5..4069f61f90 100644 --- a/crates/collab/src/tests/channel_buffer_tests.rs +++ b/crates/collab/src/tests/channel_buffer_tests.rs @@ -13,6 +13,7 @@ use gpui::{BackgroundExecutor, Context, Entity, TestAppContext, Window}; use rpc::{RECEIVE_TIMEOUT, proto::PeerId}; use serde_json::json; use std::ops::Range; +use workspace::CollaboratorId; #[gpui::test] async fn test_core_channel_buffers( @@ -300,13 +301,20 @@ fn assert_remote_selections( cx: &mut Context, ) { let snapshot = editor.snapshot(window, cx); + let hub = editor.collaboration_hub().unwrap(); + let collaborators = hub.collaborators(cx); let range = Anchor::min()..Anchor::max(); let remote_selections = snapshot - .remote_selections_in_range(&range, editor.collaboration_hub().unwrap(), cx) + .remote_selections_in_range(&range, hub, cx) .map(|s| { + let CollaboratorId::PeerId(peer_id) = s.collaborator_id else { + panic!("unexpected collaborator id"); + }; let start = s.selection.start.to_offset(&snapshot.buffer_snapshot); let end = s.selection.end.to_offset(&snapshot.buffer_snapshot); - (s.participant_index, start..end) + let user_id = collaborators.get(&peer_id).unwrap().user_id; + let participant_index = hub.user_participant_indices(cx).get(&user_id).copied(); + (participant_index, start..end) }) .collect::>(); assert_eq!( diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index 57494bd42b..0f5bdd5326 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -18,7 +18,7 @@ use serde_json::json; use settings::SettingsStore; use text::{Point, ToPoint}; use util::{path, test::sample_text}; -use workspace::{SplitDirection, Workspace, item::ItemHandle as _}; +use workspace::{CollaboratorId, SplitDirection, Workspace, item::ItemHandle as _}; use super::TestClient; @@ -425,7 +425,7 @@ async fn test_basic_following( executor.run_until_parked(); assert_eq!( workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - Some(peer_id_b) + Some(peer_id_b.into()) ); assert_eq!( workspace_a.update_in(cx_a, |workspace, _, cx| workspace @@ -1267,7 +1267,7 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont executor.run_until_parked(); assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) + Some(leader_id.into()) ); let editor_b2 = workspace_b.update(cx_b, |workspace, cx| { workspace @@ -1292,7 +1292,7 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont executor.run_until_parked(); assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) + Some(leader_id.into()) ); // When client B edits, it automatically stops following client A. @@ -1308,7 +1308,7 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont executor.run_until_parked(); assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) + Some(leader_id.into()) ); // When client B scrolls, it automatically stops following client A. @@ -1326,7 +1326,7 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont executor.run_until_parked(); assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) + Some(leader_id.into()) ); // When client B activates a different pane, it continues following client A in the original pane. @@ -1335,7 +1335,7 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont }); assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) + Some(leader_id.into()) ); workspace_b.update_in(cx_b, |workspace, window, cx| { @@ -1343,7 +1343,7 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont }); assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) + Some(leader_id.into()) ); // When client B activates a different item in the original pane, it automatically stops following client A. @@ -1406,13 +1406,13 @@ async fn test_peers_simultaneously_following_each_other( workspace_a.update(cx_a, |workspace, _| { assert_eq!( workspace.leader_for_pane(workspace.active_pane()), - Some(client_b_id) + Some(client_b_id.into()) ); }); workspace_b.update(cx_b, |workspace, _| { assert_eq!( workspace.leader_for_pane(workspace.active_pane()), - Some(client_a_id) + Some(client_a_id.into()) ); }); } @@ -1513,7 +1513,7 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut workspace_b_project_a.update(&mut cx_b2, |workspace, cx| { assert!(workspace.is_being_followed(client_a.peer_id().unwrap())); assert_eq!( - client_a.peer_id(), + client_a.peer_id().map(Into::into), workspace.leader_for_pane(workspace.active_pane()) ); let item = workspace.active_item(cx).unwrap(); @@ -1554,7 +1554,7 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut workspace_a.update(cx_a, |workspace, cx| { assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); assert_eq!( - client_b.peer_id(), + client_b.peer_id().map(Into::into), workspace.leader_for_pane(workspace.active_pane()) ); let item = workspace.active_pane().read(cx).active_item().unwrap(); @@ -1615,7 +1615,7 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id)); assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); assert_eq!( - client_b.peer_id(), + client_b.peer_id().map(Into::into), workspace.leader_for_pane(workspace.active_pane()) ); let item = workspace.active_item(cx).unwrap(); @@ -1866,7 +1866,11 @@ fn pane_summaries(workspace: &Entity, cx: &mut VisualTestContext) -> .panes() .iter() .map(|pane| { - let leader = workspace.leader_for_pane(pane); + let leader = match workspace.leader_for_pane(pane) { + Some(CollaboratorId::PeerId(peer_id)) => Some(peer_id), + Some(CollaboratorId::Agent) => unimplemented!(), + None => None, + }; let active = pane == active_pane; let pane = pane.read(cx); let active_ix = pane.active_item_index(); @@ -1985,7 +1989,7 @@ async fn test_following_to_channel_notes_without_a_shared_project( let channel_notes_1_b = workspace_b.update(cx_b, |workspace, cx| { assert_eq!( workspace.leader_for_pane(workspace.active_pane()), - Some(client_a.peer_id().unwrap()) + Some(client_a.peer_id().unwrap().into()) ); workspace .active_item(cx) @@ -2015,7 +2019,7 @@ async fn test_following_to_channel_notes_without_a_shared_project( let channel_notes_2_b = workspace_b.update(cx_b, |workspace, cx| { assert_eq!( workspace.leader_for_pane(workspace.active_pane()), - Some(client_a.peer_id().unwrap()) + Some(client_a.peer_id().unwrap().into()) ); workspace .active_item(cx) diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index acbb80c36a..ff1247f5b3 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -22,7 +22,7 @@ use std::{ }; use ui::prelude::*; use util::ResultExt; -use workspace::item::TabContentParams; +use workspace::{CollaboratorId, item::TabContentParams}; use workspace::{ ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId, item::{FollowableItem, Item, ItemEvent, ItemHandle}, @@ -654,15 +654,14 @@ impl FollowableItem for ChannelView { }) } - fn set_leader_peer_id( + fn set_leader_id( &mut self, - leader_peer_id: Option, + leader_id: Option, window: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.set_leader_peer_id(leader_peer_id, window, cx) - }) + self.editor + .update(cx, |editor, cx| editor.set_leader_id(leader_id, window, cx)) } fn is_project_item(&self, _window: &Window, _cx: &App) -> bool { diff --git a/crates/debugger_ui/src/session.rs b/crates/debugger_ui/src/session.rs index d34d4d3c44..aa06ea1961 100644 --- a/crates/debugger_ui/src/session.rs +++ b/crates/debugger_ui/src/session.rs @@ -7,11 +7,11 @@ use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task use project::Project; use project::debugger::session::Session; use project::worktree_store::WorktreeStore; -use rpc::proto::{self, PeerId}; +use rpc::proto; use running::RunningState; use ui::{Indicator, prelude::*}; use workspace::{ - FollowableItem, ViewId, Workspace, + CollaboratorId, FollowableItem, ViewId, Workspace, item::{self, Item}, }; @@ -189,9 +189,9 @@ impl FollowableItem for DebugSession { Task::ready(Ok(())) } - fn set_leader_peer_id( + fn set_leader_id( &mut self, - _leader_peer_id: Option, + _leader_id: Option, _window: &mut Window, _cx: &mut Context, ) { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 05c71d98aa..c49da98be4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -56,7 +56,7 @@ use anyhow::{Context as _, Result, anyhow}; use blink_manager::BlinkManager; use buffer_diff::DiffHunkStatus; use client::{Collaborator, ParticipantIndex}; -use clock::ReplicaId; +use clock::{AGENT_REPLICA_ID, ReplicaId}; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; use display_map::*; @@ -201,7 +201,7 @@ use ui::{ }; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; use workspace::{ - Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal, + CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal, RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast, ViewId, Workspace, WorkspaceId, WorkspaceSettings, item::{ItemHandle, PreviewTabsSettings}, @@ -914,7 +914,7 @@ pub struct Editor { input_enabled: bool, use_modal_editing: bool, read_only: bool, - leader_peer_id: Option, + leader_id: Option, remote_id: Option, pub hover_state: HoverState, pending_mouse_down: Option>>>, @@ -1059,10 +1059,10 @@ pub struct RemoteSelection { pub replica_id: ReplicaId, pub selection: Selection, pub cursor_shape: CursorShape, - pub peer_id: PeerId, + pub collaborator_id: CollaboratorId, pub line_mode: bool, - pub participant_index: Option, pub user_name: Option, + pub color: PlayerColor, } #[derive(Clone, Debug)] @@ -1723,7 +1723,7 @@ impl Editor { use_auto_surround: true, auto_replace_emoji_shortcode: false, jsx_tag_auto_close_enabled_in_any_buffer: false, - leader_peer_id: None, + leader_id: None, remote_id: None, hover_state: Default::default(), pending_mouse_down: None, @@ -2175,8 +2175,8 @@ impl Editor { }); } - pub fn leader_peer_id(&self) -> Option { - self.leader_peer_id + pub fn leader_id(&self) -> Option { + self.leader_id } pub fn buffer(&self) -> &Entity { @@ -2517,7 +2517,7 @@ impl Editor { } } - if self.focus_handle.is_focused(window) && self.leader_peer_id.is_none() { + if self.focus_handle.is_focused(window) && self.leader_id.is_none() { self.buffer.update(cx, |buffer, cx| { buffer.set_active_selections( &self.selections.disjoint_anchors(), @@ -18490,7 +18490,7 @@ impl Editor { self.show_cursor_names(window, cx); self.buffer.update(cx, |buffer, cx| { buffer.finalize_last_transaction(cx); - if self.leader_peer_id.is_none() { + if self.leader_id.is_none() { buffer.set_active_selections( &self.selections.disjoint_anchors(), self.selections.line_mode, @@ -19928,18 +19928,34 @@ impl EditorSnapshot { self.buffer_snapshot .selections_in_range(range, false) .filter_map(move |(replica_id, line_mode, cursor_shape, selection)| { - let collaborator = collaborators_by_replica_id.get(&replica_id)?; - let participant_index = participant_indices.get(&collaborator.user_id).copied(); - let user_name = participant_names.get(&collaborator.user_id).cloned(); - Some(RemoteSelection { - replica_id, - selection, - cursor_shape, - line_mode, - participant_index, - peer_id: collaborator.peer_id, - user_name, - }) + if replica_id == AGENT_REPLICA_ID { + Some(RemoteSelection { + replica_id, + selection, + cursor_shape, + line_mode, + collaborator_id: CollaboratorId::Agent, + user_name: Some("Agent".into()), + color: cx.theme().players().agent(), + }) + } else { + let collaborator = collaborators_by_replica_id.get(&replica_id)?; + let participant_index = participant_indices.get(&collaborator.user_id).copied(); + let user_name = participant_names.get(&collaborator.user_id).cloned(); + Some(RemoteSelection { + replica_id, + selection, + cursor_shape, + line_mode, + collaborator_id: CollaboratorId::PeerId(collaborator.peer_id), + user_name, + color: if let Some(index) = participant_index { + cx.theme().players().color_for_participant(index.0) + } else { + cx.theme().players().absent() + }, + }) + } }) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 1baf7c39cb..676233227b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -12650,7 +12650,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { Editor::from_state_proto( workspace_entity, ViewId { - creator: Default::default(), + creator: CollaboratorId::PeerId(PeerId::default()), id: 0, }, &mut state_message, @@ -12737,7 +12737,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { Editor::from_state_proto( workspace_entity, ViewId { - creator: Default::default(), + creator: CollaboratorId::PeerId(PeerId::default()), id: 0, }, &mut state_message, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7c8848d841..b77023ffce 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -28,7 +28,6 @@ use crate::{ scroll::scroll_amount::ScrollAmount, }; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; -use client::ParticipantIndex; use collections::{BTreeMap, HashMap}; use feature_flags::{DebuggerFeatureFlag, FeatureFlagAppExt}; use file_icons::FileIcons; @@ -82,7 +81,7 @@ use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*}; use unicode_segmentation::UnicodeSegmentation; use util::{RangeExt, ResultExt, debug_panic}; -use workspace::{Workspace, item::Item, notifications::NotifyTaskExt}; +use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt}; const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.; @@ -1126,7 +1125,7 @@ impl EditorElement { editor.cursor_shape, &snapshot.display_snapshot, is_newest, - editor.leader_peer_id.is_none(), + editor.leader_id.is_none(), None, ); if is_newest { @@ -1150,18 +1149,29 @@ impl EditorElement { if let Some(collaboration_hub) = &editor.collaboration_hub { // When following someone, render the local selections in their color. - if let Some(leader_id) = editor.leader_peer_id { - if let Some(collaborator) = collaboration_hub.collaborators(cx).get(&leader_id) - { - if let Some(participant_index) = collaboration_hub - .user_participant_indices(cx) - .get(&collaborator.user_id) - { + if let Some(leader_id) = editor.leader_id { + match leader_id { + CollaboratorId::PeerId(peer_id) => { + if let Some(collaborator) = + collaboration_hub.collaborators(cx).get(&peer_id) + { + if let Some(participant_index) = collaboration_hub + .user_participant_indices(cx) + .get(&collaborator.user_id) + { + if let Some((local_selection_style, _)) = selections.first_mut() + { + *local_selection_style = cx + .theme() + .players() + .color_for_participant(participant_index.0); + } + } + } + } + CollaboratorId::Agent => { if let Some((local_selection_style, _)) = selections.first_mut() { - *local_selection_style = cx - .theme() - .players() - .color_for_participant(participant_index.0); + *local_selection_style = cx.theme().players().agent(); } } } @@ -1173,12 +1183,9 @@ impl EditorElement { collaboration_hub.as_ref(), cx, ) { - let selection_style = - Self::get_participant_color(selection.participant_index, cx); - // Don't re-render the leader's selections, since the local selections // match theirs. - if Some(selection.peer_id) == editor.leader_peer_id { + if Some(selection.collaborator_id) == editor.leader_id { continue; } let key = HoveredCursor { @@ -1191,7 +1198,7 @@ impl EditorElement { remote_selections .entry(selection.replica_id) - .or_insert((selection_style, Vec::new())) + .or_insert((selection.color, Vec::new())) .1 .push(SelectionLayout::new( selection.selection, @@ -1246,9 +1253,11 @@ impl EditorElement { collaboration_hub.deref(), cx, ) { - let color = Self::get_participant_color(remote_selection.participant_index, cx); - add_cursor(remote_selection.selection.head(), color.cursor); - if Some(remote_selection.peer_id) == editor.leader_peer_id { + add_cursor( + remote_selection.selection.head(), + remote_selection.color.cursor, + ); + if Some(remote_selection.collaborator_id) == editor.leader_id { skip_local = true; } } @@ -2446,14 +2455,6 @@ impl EditorElement { Some(button) } - fn get_participant_color(participant_index: Option, cx: &App) -> PlayerColor { - if let Some(index) = participant_index { - cx.theme().players().color_for_participant(index.0) - } else { - cx.theme().players().absent() - } - } - fn calculate_relative_line_numbers( &self, snapshot: &EditorSnapshot, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index ca8cdbd788..0a482f5366 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -23,7 +23,7 @@ use project::{ Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, }; -use rpc::proto::{self, PeerId, update_view}; +use rpc::proto::{self, update_view}; use settings::Settings; use std::{ any::TypeId, @@ -39,7 +39,7 @@ use theme::{Theme, ThemeSettings}; use ui::{IconDecorationKind, prelude::*}; use util::{ResultExt, TryFutureExt, paths::PathExt}; use workspace::{ - ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, item::{FollowableItem, Item, ItemEvent, ProjectItem}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, }; @@ -170,14 +170,14 @@ impl FollowableItem for Editor { })) } - fn set_leader_peer_id( + fn set_leader_id( &mut self, - leader_peer_id: Option, + leader_id: Option, window: &mut Window, cx: &mut Context, ) { - self.leader_peer_id = leader_peer_id; - if self.leader_peer_id.is_some() { + self.leader_id = leader_id; + if self.leader_id.is_some() { self.buffer.update(cx, |buffer, cx| { buffer.remove_active_selections(cx); }); @@ -350,6 +350,30 @@ impl FollowableItem for Editor { None } } + + fn update_agent_location( + &mut self, + location: language::Anchor, + window: &mut Window, + cx: &mut Context, + ) { + let buffer = self.buffer.read(cx); + let buffer = buffer.read(cx); + let Some((excerpt_id, _, _)) = buffer.as_singleton() else { + return; + }; + let position = buffer.anchor_in_excerpt(*excerpt_id, location).unwrap(); + let selection = Selection { + id: 0, + reversed: false, + start: position, + end: position, + goal: SelectionGoal::None, + }; + drop(buffer); + self.set_selections_from_remote(vec![selection], None, window, cx); + self.request_autoscroll_remotely(Autoscroll::center(), cx); + } } async fn update_editor_from_message( @@ -1293,7 +1317,7 @@ impl ProjectItem for Editor { fn for_project_item( project: Entity, - pane: &Pane, + pane: Option<&Pane>, buffer: Entity, window: &mut Window, cx: &mut Context, @@ -1304,7 +1328,7 @@ impl ProjectItem for Editor { { if WorkspaceSettings::get(None, cx).restore_on_file_reopen { if let Some(restoration_data) = Self::project_item_kind() - .and_then(|kind| pane.project_item_restoration_data.get(&kind)) + .and_then(|kind| pane.as_ref()?.project_item_restoration_data.get(&kind)) .and_then(|data| data.downcast_ref::()) .and_then(|data| { let file = project::File::from_dyn(buffer.read(cx).file())?; diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index fc0d9fed3c..8d9d1ea8f0 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -72,6 +72,7 @@ pub enum IconName { CopilotInit, Copy, CountdownTimer, + Crosshair, CursorIBeam, Dash, DatabaseZap, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index ba735b356f..9ac8358a17 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -375,7 +375,7 @@ impl ProjectItem for ImageView { fn for_project_item( project: Entity, - _: &Pane, + _: Option<&Pane>, item: Entity, window: &mut Window, cx: &mut Context, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 4636e19b12..98b577ea39 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -19,8 +19,8 @@ pub use crate::{ }; use anyhow::{Context as _, Result, anyhow}; use async_watch as watch; -use clock::Lamport; pub use clock::ReplicaId; +use clock::{AGENT_REPLICA_ID, Lamport}; use collections::HashMap; use fs::MTime; use futures::channel::oneshot; @@ -2132,6 +2132,31 @@ impl Buffer { } } + pub fn set_agent_selections( + &mut self, + selections: Arc<[Selection]>, + line_mode: bool, + cursor_shape: CursorShape, + cx: &mut Context, + ) { + let lamport_timestamp = self.text.lamport_clock.tick(); + self.remote_selections.insert( + AGENT_REPLICA_ID, + SelectionSet { + selections: selections.clone(), + lamport_timestamp, + line_mode, + cursor_shape, + }, + ); + self.non_text_state_update_count += 1; + cx.notify(); + } + + pub fn remove_agent_selections(&mut self, cx: &mut Context) { + self.set_agent_selections(Arc::default(), false, Default::default(), cx); + } + /// Replaces the buffer's entire text. pub fn set_text(&mut self, text: T, cx: &mut Context) -> Option where diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index dc4aa48991..7b4be1ea26 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -68,9 +68,9 @@ use gpui::{ }; use itertools::Itertools; use language::{ - Buffer, BufferEvent, Capability, CodeLabel, Language, LanguageName, LanguageRegistry, - PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList, Transaction, Unclipped, - language_settings::InlayHintKind, proto::split_operations, + Buffer, BufferEvent, Capability, CodeLabel, CursorShape, Language, LanguageName, + LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList, Transaction, + Unclipped, language_settings::InlayHintKind, proto::split_operations, }; use lsp::{ CodeActionKind, CompletionContext, CompletionItemKind, DocumentHighlightKind, InsertTextMode, @@ -138,7 +138,7 @@ const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500; const MAX_SEARCH_RESULT_FILES: usize = 5_000; const MAX_SEARCH_RESULT_RANGES: usize = 10_000; -pub trait ProjectItem { +pub trait ProjectItem: 'static { fn try_open( project: &Entity, path: &ProjectPath, @@ -197,6 +197,13 @@ pub struct Project { environment: Entity, settings_observer: Entity, toolchain_store: Option>, + agent_location: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentLocation { + pub buffer: WeakEntity, + pub position: Anchor, } #[derive(Default)] @@ -304,8 +311,11 @@ pub enum Event { RevealInProjectPanel(ProjectEntryId), SnippetEdit(BufferId, Vec<(lsp::Range, Snippet)>), ExpandedAllForEntry(WorktreeId, ProjectEntryId), + AgentLocationChanged, } +pub struct AgentLocationChanged; + pub enum DebugAdapterClientState { Starting(Task>>), Running(Arc), @@ -986,6 +996,8 @@ impl Project { search_excluded_history: Self::new_search_history(), toolchain_store: Some(toolchain_store), + + agent_location: None, } }) } @@ -1142,6 +1154,7 @@ impl Project { search_excluded_history: Self::new_search_history(), toolchain_store: Some(toolchain_store), + agent_location: None, }; // ssh -> local machine handlers @@ -1381,6 +1394,7 @@ impl Project { environment, remotely_created_models: Arc::new(Mutex::new(RemotelyCreatedModels::default())), toolchain_store: None, + agent_location: None, }; this.set_role(role, cx); for worktree in worktrees { @@ -4875,6 +4889,46 @@ impl Project { pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { self.git_store.read(cx).status_for_buffer_id(buffer_id, cx) } + + pub fn set_agent_location( + &mut self, + new_location: Option, + cx: &mut Context, + ) { + if let Some(old_location) = self.agent_location.as_ref() { + old_location + .buffer + .update(cx, |buffer, cx| buffer.remove_agent_selections(cx)) + .ok(); + } + + if let Some(location) = new_location.as_ref() { + location + .buffer + .update(cx, |buffer, cx| { + buffer.set_agent_selections( + Arc::from([language::Selection { + id: 0, + start: location.position, + end: location.position, + reversed: false, + goal: language::SelectionGoal::None, + }]), + false, + CursorShape::Hollow, + cx, + ) + }) + .ok(); + } + + self.agent_location = new_location; + cx.emit(Event::AgentLocationChanged); + } + + pub fn agent_location(&self) -> Option { + self.agent_location.clone() + } } pub struct PathMatchCandidateSet { diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 8badba4738..60a423e752 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -5291,7 +5291,7 @@ impl ProjectItem for TestProjectItemView { fn for_project_item( _: Entity, - _: &Pane, + _: Option<&Pane>, project_item: Entity, _: &mut Window, cx: &mut Context, diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 07f3e63b24..037c62ed71 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -829,7 +829,7 @@ impl ProjectItem for NotebookEditor { fn for_project_item( project: Entity, - _: &Pane, + _: Option<&Pane>, item: Entity, window: &mut Window, cx: &mut Context, diff --git a/crates/theme/src/styles/players.rs b/crates/theme/src/styles/players.rs index 79369594b4..5ac098d3be 100644 --- a/crates/theme/src/styles/players.rs +++ b/crates/theme/src/styles/players.rs @@ -129,6 +129,10 @@ impl PlayerColors { *self.0.first().unwrap() } + pub fn agent(&self) -> PlayerColor { + *self.0.last().unwrap() + } + pub fn absent(&self) -> PlayerColor { *self.0.last().unwrap() } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 096fdab333..a85ad331e0 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1143,7 +1143,7 @@ impl Vim { && !newest_selection_empty && self.mode == Mode::Normal // When following someone, don't switch vim mode. - && editor.leader_peer_id().is_none() + && editor.leader_id().is_none() { if preserve_selection { self.switch_mode(Mode::Visual, true, window, cx); @@ -1468,7 +1468,7 @@ impl Vim { fn local_selections_changed(&mut self, window: &mut Window, cx: &mut Context) { let Some(editor) = self.editor() else { return }; - if editor.read(cx).leader_peer_id().is_some() { + if editor.read(cx).leader_id().is_some() { return; } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index a682a64522..fb575cdc64 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -36,7 +36,6 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true -derive_more.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 00aa340e66..ebc6123af9 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -1,16 +1,13 @@ use crate::{ - DelayedDebouncedEditAction, FollowableViewRegistry, ItemNavHistory, SerializableItemRegistry, - ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + CollaboratorId, DelayedDebouncedEditAction, FollowableViewRegistry, ItemNavHistory, + SerializableItemRegistry, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, pane::{self, Pane}, persistence::model::ItemId, searchable::SearchableItemHandle, workspace_settings::{AutosaveSetting, WorkspaceSettings}, }; use anyhow::Result; -use client::{ - Client, - proto::{self, PeerId}, -}; +use client::{Client, proto}; use futures::{StreamExt, channel::mpsc}; use gpui::{ Action, AnyElement, AnyView, App, Context, Entity, EntityId, EventEmitter, FocusHandle, @@ -770,7 +767,7 @@ impl ItemHandle for Entity { proto::UpdateView { id: item .remote_id(workspace.client(), window, cx) - .map(|id| id.to_proto()), + .and_then(|id| id.to_proto()), variant: pending_update.borrow_mut().take(), leader_id, }, @@ -810,13 +807,27 @@ impl ItemHandle for Entity { } if item.item_focus_handle(cx).contains_focused(window, cx) { - item.add_event_to_update_proto( - event, - &mut pending_update.borrow_mut(), - window, - cx, - ); - pending_update_tx.unbounded_send(leader_id).ok(); + match leader_id { + Some(CollaboratorId::Agent) => {} + Some(CollaboratorId::PeerId(leader_peer_id)) => { + item.add_event_to_update_proto( + event, + &mut pending_update.borrow_mut(), + window, + cx, + ); + pending_update_tx.unbounded_send(Some(leader_peer_id)).ok(); + } + None => { + item.add_event_to_update_proto( + event, + &mut pending_update.borrow_mut(), + window, + cx, + ); + pending_update_tx.unbounded_send(None).ok(); + } + } } } @@ -1081,7 +1092,7 @@ pub trait ProjectItem: Item { fn for_project_item( project: Entity, - pane: &Pane, + pane: Option<&Pane>, item: Entity, window: &mut Window, cx: &mut Context, @@ -1126,19 +1137,31 @@ pub trait FollowableItem: Item { cx: &mut Context, ) -> Task>; fn is_project_item(&self, window: &Window, cx: &App) -> bool; - fn set_leader_peer_id( + fn set_leader_id( &mut self, - leader_peer_id: Option, + leader_peer_id: Option, window: &mut Window, cx: &mut Context, ); fn dedup(&self, existing: &Self, window: &Window, cx: &App) -> Option; + fn update_agent_location( + &mut self, + _location: language::Anchor, + _window: &mut Window, + _cx: &mut Context, + ) { + } } pub trait FollowableItemHandle: ItemHandle { fn remote_id(&self, client: &Arc, window: &mut Window, cx: &mut App) -> Option; fn downgrade(&self) -> Box; - fn set_leader_peer_id(&self, leader_peer_id: Option, window: &mut Window, cx: &mut App); + fn set_leader_id( + &self, + leader_peer_id: Option, + window: &mut Window, + cx: &mut App, + ); fn to_state_proto(&self, window: &mut Window, cx: &mut App) -> Option; fn add_event_to_update_proto( &self, @@ -1162,13 +1185,14 @@ pub trait FollowableItemHandle: ItemHandle { window: &mut Window, cx: &mut App, ) -> Option; + fn update_agent_location(&self, location: language::Anchor, window: &mut Window, cx: &mut App); } impl FollowableItemHandle for Entity { fn remote_id(&self, client: &Arc, _: &mut Window, cx: &mut App) -> Option { self.read(cx).remote_id().or_else(|| { client.peer_id().map(|creator| ViewId { - creator, + creator: CollaboratorId::PeerId(creator), id: self.item_id().as_u64(), }) }) @@ -1178,15 +1202,8 @@ impl FollowableItemHandle for Entity { Box::new(self.downgrade()) } - fn set_leader_peer_id( - &self, - leader_peer_id: Option, - window: &mut Window, - cx: &mut App, - ) { - self.update(cx, |this, cx| { - this.set_leader_peer_id(leader_peer_id, window, cx) - }) + fn set_leader_id(&self, leader_id: Option, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.set_leader_id(leader_id, window, cx)) } fn to_state_proto(&self, window: &mut Window, cx: &mut App) -> Option { @@ -1237,6 +1254,12 @@ impl FollowableItemHandle for Entity { let existing = existing.to_any().downcast::().ok()?; self.read(cx).dedup(existing.read(cx), window, cx) } + + fn update_agent_location(&self, location: language::Anchor, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.update_agent_location(location, window, cx) + }) + } } pub trait WeakFollowableItemHandle: Send + Sync { diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 929087baca..9e0faf1e3c 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -1,11 +1,10 @@ use crate::{ - AppState, FollowerState, Pane, Workspace, WorkspaceSettings, + AppState, CollaboratorId, FollowerState, Pane, Workspace, WorkspaceSettings, pane_group::element::pane_axis, workspace_settings::{PaneSplitDirectionHorizontal, PaneSplitDirectionVertical}, }; use anyhow::{Result, anyhow}; use call::{ActiveCall, ParticipantLocation}; -use client::proto::PeerId; use collections::HashMap; use gpui::{ Along, AnyView, AnyWeakView, Axis, Bounds, Entity, Hsla, IntoElement, MouseButton, Pixels, @@ -188,7 +187,7 @@ pub enum Member { #[derive(Clone, Copy)] pub struct PaneRenderContext<'a> { pub project: &'a Entity, - pub follower_states: &'a HashMap, + pub follower_states: &'a HashMap, pub active_call: Option<&'a Entity>, pub active_pane: &'a Entity, pub app_state: &'a Arc, @@ -243,88 +242,104 @@ impl PaneLeaderDecorator for PaneRenderContext<'_> { None } }); - let leader = follower_state.as_ref().and_then(|(leader_id, _)| { - let room = self.active_call?.read(cx).room()?.read(cx); - room.remote_participant_for_peer_id(*leader_id) - }); - let Some(leader) = leader else { + let Some((leader_id, follower_state)) = follower_state else { return LeaderDecoration::default(); }; - let is_in_unshared_view = follower_state.as_ref().map_or(false, |(_, state)| { - state - .active_view_id - .is_some_and(|view_id| !state.items_by_leader_view_id.contains_key(&view_id)) - }); - let is_in_panel = follower_state - .as_ref() - .map_or(false, |(_, state)| state.dock_pane.is_some()); - let mut leader_join_data = None; - let leader_status_box = match leader.location { - ParticipantLocation::SharedProject { - project_id: leader_project_id, - } => { - if Some(leader_project_id) == self.project.read(cx).remote_id() { - is_in_unshared_view.then(|| { - Label::new(format!( - "{} is in an unshared pane", - leader.user.github_login - )) - }) - } else { - leader_join_data = Some((leader_project_id, leader.user.id)); - Some(Label::new(format!( - "Follow {} to their active project", - leader.user.github_login, - ))) - } + let mut leader_color; + let status_box; + match leader_id { + CollaboratorId::PeerId(peer_id) => { + let Some(leader) = self.active_call.as_ref().and_then(|call| { + let room = call.read(cx).room()?.read(cx); + room.remote_participant_for_peer_id(peer_id) + }) else { + return LeaderDecoration::default(); + }; + + let is_in_unshared_view = follower_state.active_view_id.is_some_and(|view_id| { + !follower_state + .items_by_leader_view_id + .contains_key(&view_id) + }); + + let mut leader_join_data = None; + let leader_status_box = match leader.location { + ParticipantLocation::SharedProject { + project_id: leader_project_id, + } => { + if Some(leader_project_id) == self.project.read(cx).remote_id() { + is_in_unshared_view.then(|| { + Label::new(format!( + "{} is in an unshared pane", + leader.user.github_login + )) + }) + } else { + leader_join_data = Some((leader_project_id, leader.user.id)); + Some(Label::new(format!( + "Follow {} to their active project", + leader.user.github_login, + ))) + } + } + ParticipantLocation::UnsharedProject => Some(Label::new(format!( + "{} is viewing an unshared Zed project", + leader.user.github_login + ))), + ParticipantLocation::External => Some(Label::new(format!( + "{} is viewing a window outside of Zed", + leader.user.github_login + ))), + }; + status_box = leader_status_box.map(|status| { + div() + .absolute() + .w_96() + .bottom_3() + .right_3() + .elevation_2(cx) + .p_1() + .child(status) + .when_some( + leader_join_data, + |this, (leader_project_id, leader_user_id)| { + let app_state = self.app_state.clone(); + this.cursor_pointer().on_mouse_down( + MouseButton::Left, + move |_, _, cx| { + crate::join_in_room_project( + leader_project_id, + leader_user_id, + app_state.clone(), + cx, + ) + .detach_and_log_err(cx); + }, + ) + }, + ) + .into_any_element() + }); + leader_color = cx + .theme() + .players() + .color_for_participant(leader.participant_index.0) + .cursor; } - ParticipantLocation::UnsharedProject => Some(Label::new(format!( - "{} is viewing an unshared Zed project", - leader.user.github_login - ))), - ParticipantLocation::External => Some(Label::new(format!( - "{} is viewing a window outside of Zed", - leader.user.github_login - ))), - }; - let mut leader_color = cx - .theme() - .players() - .color_for_participant(leader.participant_index.0) - .cursor; + CollaboratorId::Agent => { + status_box = None; + leader_color = cx.theme().players().agent().cursor; + } + } + + let is_in_panel = follower_state.dock_pane.is_some(); if is_in_panel { leader_color.fade_out(0.75); } else { leader_color.fade_out(0.3); } - let status_box = leader_status_box.map(|status| { - div() - .absolute() - .w_96() - .bottom_3() - .right_3() - .elevation_2(cx) - .p_1() - .child(status) - .when_some( - leader_join_data, - |this, (leader_project_id, leader_user_id)| { - let app_state = self.app_state.clone(); - this.cursor_pointer() - .on_mouse_down(MouseButton::Left, move |_, _, cx| { - crate::join_in_room_project( - leader_project_id, - leader_user_id, - app_state.clone(), - cx, - ) - .detach_and_log_err(cx); - }) - }, - ) - .into_any_element() - }); + LeaderDecoration { status_box, border: Some(leader_color), @@ -339,6 +354,7 @@ impl PaneLeaderDecorator for PaneRenderContext<'_> { self.workspace } } + impl Member { fn new_axis(old_pane: Entity, new_pane: Entity, direction: SplitDirection) -> Self { use Axis::*; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index bb52dd5db8..cc41355422 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -24,7 +24,6 @@ use client::{ proto::{self, ErrorCode, PanelId, PeerId}, }; use collections::{HashMap, HashSet, hash_map}; -use derive_more::{Deref, DerefMut}; pub use dock::Panel; use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE}; use futures::{ @@ -36,12 +35,12 @@ use futures::{ future::try_join_all, }; use gpui::{ - Action, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, CursorStyle, - Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, - Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, Point, PromptLevel, - Render, ResizeEdge, Size, Stateful, Subscription, Task, Tiling, WeakEntity, WindowBounds, - WindowHandle, WindowId, WindowOptions, action_as, actions, canvas, impl_action_as, - impl_actions, point, relative, size, transparent_black, + Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, + CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, + Focusable, Global, Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, + Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, Task, Tiling, WeakEntity, + WindowBounds, WindowHandle, WindowId, WindowOptions, action_as, actions, canvas, + impl_action_as, impl_actions, point, relative, size, transparent_black, }; pub use history_manager::*; pub use item::{ @@ -451,44 +450,97 @@ pub fn init(app_state: Arc, cx: &mut App) { }); } -#[derive(Clone, Default, Deref, DerefMut)] -struct ProjectItemOpeners(Vec); +type BuildProjectItemFn = + fn(AnyEntity, Entity, Option<&Pane>, &mut Window, &mut App) -> Box; -type ProjectItemOpener = fn( - &Entity, - &ProjectPath, - &mut Window, - &mut App, -) - -> Option, WorkspaceItemBuilder)>>>; +type BuildProjectItemForPathFn = + fn( + &Entity, + &ProjectPath, + &mut Window, + &mut App, + ) -> Option, WorkspaceItemBuilder)>>>; + +#[derive(Clone, Default)] +struct ProjectItemRegistry { + build_project_item_fns_by_type: HashMap, + build_project_item_for_path_fns: Vec, +} + +impl ProjectItemRegistry { + fn register(&mut self) { + self.build_project_item_fns_by_type.insert( + TypeId::of::(), + |item, project, pane, window, cx| { + let item = item.downcast().unwrap(); + Box::new(cx.new(|cx| T::for_project_item(project, pane, item, window, cx))) + as Box + }, + ); + self.build_project_item_for_path_fns + .push(|project, project_path, window, cx| { + let project_item = + ::try_open(project, project_path, cx)?; + let project = project.clone(); + Some(window.spawn(cx, async move |cx| { + let project_item = project_item.await?; + let project_entry_id: Option = + project_item.read_with(cx, project::ProjectItem::entry_id)?; + let build_workspace_item = Box::new( + |pane: &mut Pane, window: &mut Window, cx: &mut Context| { + Box::new(cx.new(|cx| { + T::for_project_item(project, Some(pane), project_item, window, cx) + })) as Box + }, + ) as Box<_>; + Ok((project_entry_id, build_workspace_item)) + })) + }); + } + + fn open_path( + &self, + project: &Entity, + path: &ProjectPath, + window: &mut Window, + cx: &mut App, + ) -> Task, WorkspaceItemBuilder)>> { + let Some(open_project_item) = self + .build_project_item_for_path_fns + .iter() + .rev() + .find_map(|open_project_item| open_project_item(&project, &path, window, cx)) + else { + return Task::ready(Err(anyhow!("cannot open file {:?}", path.path))); + }; + open_project_item + } + + fn build_item( + &self, + item: Entity, + project: Entity, + pane: Option<&Pane>, + window: &mut Window, + cx: &mut App, + ) -> Option> { + let build = self + .build_project_item_fns_by_type + .get(&TypeId::of::())?; + Some(build(item.into_any(), project, pane, window, cx)) + } +} type WorkspaceItemBuilder = Box) -> Box>; -impl Global for ProjectItemOpeners {} +impl Global for ProjectItemRegistry {} /// Registers a [ProjectItem] for the app. When opening a file, all the registered /// items will get a chance to open the file, starting from the project item that /// was added last. pub fn register_project_item(cx: &mut App) { - let builders = cx.default_global::(); - builders.push(|project, project_path, window, cx| { - let project_item = ::try_open(project, project_path, cx)?; - let project = project.clone(); - Some(window.spawn(cx, async move |cx| { - let project_item = project_item.await?; - let project_entry_id: Option = - project_item.read_with(cx, project::ProjectItem::entry_id)?; - let build_workspace_item = Box::new( - |pane: &mut Pane, window: &mut Window, cx: &mut Context| { - Box::new( - cx.new(|cx| I::for_project_item(project, pane, project_item, window, cx)), - ) as Box - }, - ) as Box<_>; - Ok((project_entry_id, build_workspace_item)) - })) - }); + cx.default_global::().register::(); } #[derive(Default)] @@ -666,6 +718,24 @@ pub struct WorkspaceStore { _subscriptions: Vec, } +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq, PartialOrd, Ord)] +pub enum CollaboratorId { + PeerId(PeerId), + Agent, +} + +impl From for CollaboratorId { + fn from(peer_id: PeerId) -> Self { + CollaboratorId::PeerId(peer_id) + } +} + +impl From<&PeerId> for CollaboratorId { + fn from(peer_id: &PeerId) -> Self { + CollaboratorId::PeerId(*peer_id) + } +} + #[derive(PartialEq, Eq, PartialOrd, Ord, Debug)] struct Follower { project_id: Option, @@ -852,8 +922,8 @@ pub struct Workspace { titlebar_item: Option, notifications: Notifications, project: Entity, - follower_states: HashMap, - last_leaders_by_pane: HashMap, PeerId>, + follower_states: HashMap, + last_leaders_by_pane: HashMap, CollaboratorId>, window_edited: bool, dirty_items: HashMap, active_call: Option<(Entity, Vec)>, @@ -883,7 +953,7 @@ impl EventEmitter for Workspace {} #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct ViewId { - pub creator: PeerId, + pub creator: CollaboratorId, pub id: u64, } @@ -984,6 +1054,10 @@ impl Workspace { ); } + project::Event::AgentLocationChanged => { + this.handle_agent_location_changed(window, cx) + } + _ => {} } cx.notify() @@ -3083,15 +3157,8 @@ impl Workspace { cx: &mut App, ) -> Task, WorkspaceItemBuilder)>> { let project = self.project().clone(); - let project_item_builders = cx.default_global::().clone(); - let Some(open_project_item) = project_item_builders - .iter() - .rev() - .find_map(|open_project_item| open_project_item(&project, &path, window, cx)) - else { - return Task::ready(Err(anyhow!("cannot open file {:?}", path.path))); - }; - open_project_item + let registry = cx.default_global::().clone(); + registry.open_path(&project, &path, window, cx) } pub fn find_project_item( @@ -3152,7 +3219,9 @@ impl Workspace { } let item = pane.update(cx, |pane, cx| { - cx.new(|cx| T::for_project_item(self.project().clone(), pane, project_item, window, cx)) + cx.new(|cx| { + T::for_project_item(self.project().clone(), Some(pane), project_item, window, cx) + }) }); let item_id = item.item_id(); let mut destination_index = None; @@ -3605,7 +3674,7 @@ impl Workspace { pane: &Entity, window: &mut Window, cx: &mut Context, - ) -> Option { + ) -> Option { let leader_id = self.leader_for_pane(pane)?; self.unfollow(leader_id, window, cx); Some(leader_id) @@ -3785,9 +3854,9 @@ impl Workspace { fn collaborator_left(&mut self, peer_id: PeerId, window: &mut Window, cx: &mut Context) { self.follower_states.retain(|leader_id, state| { - if *leader_id == peer_id { + if *leader_id == CollaboratorId::PeerId(peer_id) { for item in state.items_by_leader_view_id.values() { - item.view.set_leader_peer_id(None, window, cx); + item.view.set_leader_id(None, window, cx); } false } else { @@ -3799,10 +3868,11 @@ impl Workspace { pub fn start_following( &mut self, - leader_id: PeerId, + leader_id: impl Into, window: &mut Window, cx: &mut Context, ) -> Option>> { + let leader_id = leader_id.into(); let pane = self.active_pane().clone(); self.last_leaders_by_pane @@ -3820,35 +3890,43 @@ impl Workspace { ); cx.notify(); - let room_id = self.active_call()?.read(cx).room()?.read(cx).id(); - let project_id = self.project.read(cx).remote_id(); - let request = self.app_state.client.request(proto::Follow { - room_id, - project_id, - leader_id: Some(leader_id), - }); + match leader_id { + CollaboratorId::PeerId(leader_peer_id) => { + let room_id = self.active_call()?.read(cx).room()?.read(cx).id(); + let project_id = self.project.read(cx).remote_id(); + let request = self.app_state.client.request(proto::Follow { + room_id, + project_id, + leader_id: Some(leader_peer_id), + }); - Some(cx.spawn_in(window, async move |this, cx| { - let response = request.await?; - this.update(cx, |this, _| { - let state = this - .follower_states - .get_mut(&leader_id) - .ok_or_else(|| anyhow!("following interrupted"))?; - state.active_view_id = response - .active_view - .as_ref() - .and_then(|view| ViewId::from_proto(view.id.clone()?).ok()); - Ok::<_, anyhow::Error>(()) - })??; - if let Some(view) = response.active_view { - Self::add_view_from_leader(this.clone(), leader_id, &view, cx).await?; + Some(cx.spawn_in(window, async move |this, cx| { + let response = request.await?; + this.update(cx, |this, _| { + let state = this + .follower_states + .get_mut(&leader_id) + .ok_or_else(|| anyhow!("following interrupted"))?; + state.active_view_id = response + .active_view + .as_ref() + .and_then(|view| ViewId::from_proto(view.id.clone()?).ok()); + Ok::<_, anyhow::Error>(()) + })??; + if let Some(view) = response.active_view { + Self::add_view_from_leader(this.clone(), leader_peer_id, &view, cx).await?; + } + this.update_in(cx, |this, window, cx| { + this.leader_updated(leader_id, window, cx) + })?; + Ok(()) + })) } - this.update_in(cx, |this, window, cx| { - this.leader_updated(leader_id, window, cx) - })?; - Ok(()) - })) + CollaboratorId::Agent => { + self.leader_updated(leader_id, window, cx)?; + Some(Task::ready(Ok(()))) + } + } } pub fn follow_next_collaborator( @@ -3861,26 +3939,34 @@ impl Workspace { let next_leader_id = if let Some(leader_id) = self.leader_for_pane(&self.active_pane) { let mut collaborators = collaborators.keys().copied(); for peer_id in collaborators.by_ref() { - if peer_id == leader_id { + if CollaboratorId::PeerId(peer_id) == leader_id { break; } } - collaborators.next() + collaborators.next().map(CollaboratorId::PeerId) } else if let Some(last_leader_id) = self.last_leaders_by_pane.get(&self.active_pane.downgrade()) { - if collaborators.contains_key(last_leader_id) { - Some(*last_leader_id) - } else { - None + match last_leader_id { + CollaboratorId::PeerId(peer_id) => { + if collaborators.contains_key(peer_id) { + Some(*last_leader_id) + } else { + None + } + } + CollaboratorId::Agent => Some(CollaboratorId::Agent), } } else { None }; let pane = self.active_pane.clone(); - let Some(leader_id) = next_leader_id.or_else(|| collaborators.keys().copied().next()) - else { + let Some(leader_id) = next_leader_id.or_else(|| { + Some(CollaboratorId::PeerId( + collaborators.keys().copied().next()?, + )) + }) else { return; }; if self.unfollow_in_pane(&pane, window, cx) == Some(leader_id) { @@ -3891,34 +3977,43 @@ impl Workspace { } } - pub fn follow(&mut self, leader_id: PeerId, window: &mut Window, cx: &mut Context) { - let Some(room) = ActiveCall::global(cx).read(cx).room() else { - return; - }; - let room = room.read(cx); - let Some(remote_participant) = room.remote_participant_for_peer_id(leader_id) else { - return; - }; + pub fn follow( + &mut self, + leader_id: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let leader_id = leader_id.into(); - let project = self.project.read(cx); + if let CollaboratorId::PeerId(peer_id) = leader_id { + let Some(room) = ActiveCall::global(cx).read(cx).room() else { + return; + }; + let room = room.read(cx); + let Some(remote_participant) = room.remote_participant_for_peer_id(peer_id) else { + return; + }; - let other_project_id = match remote_participant.location { - call::ParticipantLocation::External => None, - call::ParticipantLocation::UnsharedProject => None, - call::ParticipantLocation::SharedProject { project_id } => { - if Some(project_id) == project.remote_id() { - None - } else { - Some(project_id) + let project = self.project.read(cx); + + let other_project_id = match remote_participant.location { + call::ParticipantLocation::External => None, + call::ParticipantLocation::UnsharedProject => None, + call::ParticipantLocation::SharedProject { project_id } => { + if Some(project_id) == project.remote_id() { + None + } else { + Some(project_id) + } } - } - }; + }; - // if they are active in another project, follow there. - if let Some(project_id) = other_project_id { - let app_state = self.app_state.clone(); - crate::join_in_room_project(project_id, remote_participant.user.id, app_state, cx) - .detach_and_log_err(cx); + // if they are active in another project, follow there. + if let Some(project_id) = other_project_id { + let app_state = self.app_state.clone(); + crate::join_in_room_project(project_id, remote_participant.user.id, app_state, cx) + .detach_and_log_err(cx); + } } // if you're already following, find the right pane and focus it. @@ -3936,32 +4031,36 @@ impl Workspace { pub fn unfollow( &mut self, - leader_id: PeerId, + leader_id: impl Into, window: &mut Window, cx: &mut Context, ) -> Option<()> { cx.notify(); + + let leader_id = leader_id.into(); let state = self.follower_states.remove(&leader_id)?; for (_, item) in state.items_by_leader_view_id { - item.view.set_leader_peer_id(None, window, cx); + item.view.set_leader_id(None, window, cx); } - let project_id = self.project.read(cx).remote_id(); - let room_id = self.active_call()?.read(cx).room()?.read(cx).id(); - self.app_state - .client - .send(proto::Unfollow { - room_id, - project_id, - leader_id: Some(leader_id), - }) - .log_err(); + if let CollaboratorId::PeerId(leader_peer_id) = leader_id { + let project_id = self.project.read(cx).remote_id(); + let room_id = self.active_call()?.read(cx).room()?.read(cx).id(); + self.app_state + .client + .send(proto::Unfollow { + room_id, + project_id, + leader_id: Some(leader_peer_id), + }) + .log_err(); + } Some(()) } - pub fn is_being_followed(&self, peer_id: PeerId) -> bool { - self.follower_states.contains_key(&peer_id) + pub fn is_being_followed(&self, id: impl Into) -> bool { + self.follower_states.contains_key(&id.into()) } fn active_item_path_changed(&mut self, window: &mut Window, cx: &mut Context) { @@ -4096,6 +4195,10 @@ impl Workspace { let leader_id = self .pane_for(&*item) .and_then(|pane| self.leader_for_pane(&pane)); + let leader_peer_id = match leader_id { + Some(CollaboratorId::PeerId(peer_id)) => Some(peer_id), + Some(CollaboratorId::Agent) | None => None, + }; let item_handle = item.to_followable_item_handle(cx)?; let id = item_handle.remote_id(&self.app_state.client, window, cx)?; @@ -4109,8 +4212,8 @@ impl Workspace { } Some(proto::View { - id: Some(id.to_proto()), - leader_id, + id: id.to_proto(), + leader_id: leader_peer_id, variant: Some(variant), panel_id: panel_id.map(|id| id as i32), }) @@ -4155,7 +4258,7 @@ impl Workspace { proto::update_followers::Variant::CreateView(view) => { let view_id = ViewId::from_proto(view.id.clone().context("invalid view id")?)?; let should_add_view = this.update(cx, |this, _| { - if let Some(state) = this.follower_states.get_mut(&leader_id) { + if let Some(state) = this.follower_states.get_mut(&leader_id.into()) { anyhow::Ok(!state.items_by_leader_view_id.contains_key(&view_id)) } else { anyhow::Ok(false) @@ -4168,7 +4271,7 @@ impl Workspace { } proto::update_followers::Variant::UpdateActiveView(update_active_view) => { let should_add_view = this.update(cx, |this, _| { - if let Some(state) = this.follower_states.get_mut(&leader_id) { + if let Some(state) = this.follower_states.get_mut(&leader_id.into()) { state.active_view_id = update_active_view .view .as_ref() @@ -4202,7 +4305,7 @@ impl Workspace { let mut tasks = Vec::new(); this.update_in(cx, |this, window, cx| { let project = this.project.clone(); - if let Some(state) = this.follower_states.get(&leader_id) { + if let Some(state) = this.follower_states.get(&leader_id.into()) { let view_id = ViewId::from_proto(id.clone())?; if let Some(item) = state.items_by_leader_view_id.get(&view_id) { tasks.push(item.view.apply_update_proto( @@ -4241,7 +4344,7 @@ impl Workspace { let pane = this.update(cx, |this, _cx| { let state = this .follower_states - .get(&leader_id) + .get(&leader_id.into()) .context("stopped following")?; anyhow::Ok(state.pane().clone()) })??; @@ -4304,8 +4407,8 @@ impl Workspace { }; this.update_in(cx, |this, window, cx| { - let state = this.follower_states.get_mut(&leader_id)?; - item.set_leader_peer_id(Some(leader_id), window, cx); + let state = this.follower_states.get_mut(&leader_id.into())?; + item.set_leader_id(Some(leader_id.into()), window, cx); state.items_by_leader_view_id.insert( id, FollowerView { @@ -4320,6 +4423,71 @@ impl Workspace { Ok(()) } + fn handle_agent_location_changed(&mut self, window: &mut Window, cx: &mut Context) { + let Some(follower_state) = self.follower_states.get_mut(&CollaboratorId::Agent) else { + return; + }; + + if let Some(agent_location) = self.project.read(cx).agent_location() { + let buffer_entity_id = agent_location.buffer.entity_id(); + let view_id = ViewId { + creator: CollaboratorId::Agent, + id: buffer_entity_id.as_u64(), + }; + follower_state.active_view_id = Some(view_id); + + let item = match follower_state.items_by_leader_view_id.entry(view_id) { + hash_map::Entry::Occupied(entry) => Some(entry.into_mut()), + hash_map::Entry::Vacant(entry) => { + let existing_view = + follower_state + .center_pane + .read(cx) + .items() + .find_map(|item| { + let item = item.to_followable_item_handle(cx)?; + if item.is_singleton(cx) + && item.project_item_model_ids(cx).as_slice() + == [buffer_entity_id] + { + Some(item) + } else { + None + } + }); + let view = existing_view.or_else(|| { + agent_location.buffer.upgrade().and_then(|buffer| { + cx.update_default_global(|registry: &mut ProjectItemRegistry, cx| { + registry.build_item(buffer, self.project.clone(), None, window, cx) + })? + .to_followable_item_handle(cx) + }) + }); + + if let Some(view) = view { + Some(entry.insert(FollowerView { + view, + location: None, + })) + } else { + None + } + } + }; + + if let Some(item) = item { + item.view + .set_leader_id(Some(CollaboratorId::Agent), window, cx); + item.view + .update_agent_location(agent_location.position, window, cx); + } + } else { + follower_state.active_view_id = None; + } + + self.leader_updated(CollaboratorId::Agent, window, cx); + } + pub fn update_active_view_for_followers(&mut self, window: &mut Window, cx: &mut App) { let mut is_project_item = true; let mut update = proto::UpdateActiveView::default(); @@ -4331,6 +4499,10 @@ impl Workspace { let leader_id = self .pane_for(&*item) .and_then(|pane| self.leader_for_pane(&pane)); + let leader_peer_id = match leader_id { + Some(CollaboratorId::PeerId(peer_id)) => Some(peer_id), + Some(CollaboratorId::Agent) | None => None, + }; if let Some(item) = item.to_followable_item_handle(cx) { let id = item @@ -4340,8 +4512,8 @@ impl Workspace { if let Some(id) = id.clone() { if let Some(variant) = item.to_state_proto(window, cx) { let view = Some(proto::View { - id: Some(id.clone()), - leader_id, + id: id.clone(), + leader_id: leader_peer_id, variant: Some(variant), panel_id: panel_id.map(|id| id as i32), }); @@ -4350,8 +4522,8 @@ impl Workspace { update = proto::UpdateActiveView { view, // TODO: Remove after version 0.145.x stabilizes. - id: Some(id.clone()), - leader_id, + id: id.clone(), + leader_id: leader_peer_id, }; } }; @@ -4420,7 +4592,7 @@ impl Workspace { }) } - pub fn leader_for_pane(&self, pane: &Entity) -> Option { + pub fn leader_for_pane(&self, pane: &Entity) -> Option { self.follower_states.iter().find_map(|(leader_id, state)| { if state.center_pane == *pane || state.dock_pane.as_ref() == Some(pane) { Some(*leader_id) @@ -4432,49 +4604,19 @@ impl Workspace { fn leader_updated( &mut self, - leader_id: PeerId, + leader_id: impl Into, window: &mut Window, cx: &mut Context, - ) -> Option<()> { + ) -> Option> { cx.notify(); - let call = self.active_call()?; - let room = call.read(cx).room()?.read(cx); - let participant = room.remote_participant_for_peer_id(leader_id)?; - - let leader_in_this_app; - let leader_in_this_project; - match participant.location { - call::ParticipantLocation::SharedProject { project_id } => { - leader_in_this_app = true; - leader_in_this_project = Some(project_id) == self.project.read(cx).remote_id(); - } - call::ParticipantLocation::UnsharedProject => { - leader_in_this_app = true; - leader_in_this_project = false; - } - call::ParticipantLocation::External => { - leader_in_this_app = false; - leader_in_this_project = false; - } + let leader_id = leader_id.into(); + let (panel_id, item) = match leader_id { + CollaboratorId::PeerId(peer_id) => self.active_item_for_peer(peer_id, window, cx)?, + CollaboratorId::Agent => (None, self.active_item_for_agent()?), }; let state = self.follower_states.get(&leader_id)?; - let mut item_to_activate = None; - if let (Some(active_view_id), true) = (state.active_view_id, leader_in_this_app) { - if let Some(item) = state.items_by_leader_view_id.get(&active_view_id) { - if leader_in_this_project || !item.view.is_project_item(window, cx) { - item_to_activate = Some((item.location, item.view.boxed_clone())); - } - } - } else if let Some(shared_screen) = - self.shared_screen_for_peer(leader_id, &state.center_pane, window, cx) - { - item_to_activate = Some((None, Box::new(shared_screen))); - } - - let (panel_id, item) = item_to_activate?; - let mut transfer_focus = state.center_pane.read(cx).has_focus(window, cx); let pane; if let Some(panel_id) = panel_id { @@ -4504,7 +4646,60 @@ impl Workspace { } }); - None + Some(item) + } + + fn active_item_for_agent(&self) -> Option> { + let state = self.follower_states.get(&CollaboratorId::Agent)?; + let active_view_id = state.active_view_id?; + Some( + state + .items_by_leader_view_id + .get(&active_view_id)? + .view + .boxed_clone(), + ) + } + + fn active_item_for_peer( + &self, + peer_id: PeerId, + window: &mut Window, + cx: &mut Context, + ) -> Option<(Option, Box)> { + let call = self.active_call()?; + let room = call.read(cx).room()?.read(cx); + let participant = room.remote_participant_for_peer_id(peer_id)?; + let leader_in_this_app; + let leader_in_this_project; + match participant.location { + call::ParticipantLocation::SharedProject { project_id } => { + leader_in_this_app = true; + leader_in_this_project = Some(project_id) == self.project.read(cx).remote_id(); + } + call::ParticipantLocation::UnsharedProject => { + leader_in_this_app = true; + leader_in_this_project = false; + } + call::ParticipantLocation::External => { + leader_in_this_app = false; + leader_in_this_project = false; + } + }; + let state = self.follower_states.get(&peer_id.into())?; + let mut item_to_activate = None; + if let (Some(active_view_id), true) = (state.active_view_id, leader_in_this_app) { + if let Some(item) = state.items_by_leader_view_id.get(&active_view_id) { + if leader_in_this_project || !item.view.is_project_item(window, cx) { + item_to_activate = Some((item.location, item.view.boxed_clone())); + } + } + } else if let Some(shared_screen) = + self.shared_screen_for_peer(peer_id, &state.center_pane, window, cx) + { + item_to_activate = Some((None, Box::new(shared_screen))); + } + item_to_activate } fn shared_screen_for_peer( @@ -4571,7 +4766,7 @@ impl Workspace { match event { call::room::Event::ParticipantLocationChanged { participant_id } | call::room::Event::RemoteVideoTracksChanged { participant_id } => { - self.leader_updated(*participant_id, window, cx); + self.leader_updated(participant_id, window, cx); } _ => {} } @@ -5285,7 +5480,7 @@ impl Workspace { } fn leader_border_for_pane( - follower_states: &HashMap, + follower_states: &HashMap, pane: &Entity, _: &Window, cx: &App, @@ -5298,14 +5493,18 @@ fn leader_border_for_pane( } })?; - let room = ActiveCall::try_global(cx)?.read(cx).room()?.read(cx); - let leader = room.remote_participant_for_peer_id(leader_id)?; + let mut leader_color = match leader_id { + CollaboratorId::PeerId(leader_peer_id) => { + let room = ActiveCall::try_global(cx)?.read(cx).room()?.read(cx); + let leader = room.remote_participant_for_peer_id(leader_peer_id)?; - let mut leader_color = cx - .theme() - .players() - .color_for_participant(leader.participant_index.0) - .cursor; + cx.theme() + .players() + .color_for_participant(leader.participant_index.0) + .cursor + } + CollaboratorId::Agent => cx.theme().players().agent().cursor, + }; leader_color.fade_out(0.3); Some( div() @@ -6029,15 +6228,20 @@ impl ViewId { Ok(Self { creator: message .creator + .map(CollaboratorId::PeerId) .ok_or_else(|| anyhow!("creator is missing"))?, id: message.id, }) } - pub(crate) fn to_proto(self) -> proto::ViewId { - proto::ViewId { - creator: Some(self.creator), - id: self.id, + pub(crate) fn to_proto(self) -> Option { + if let CollaboratorId::PeerId(peer_id) = self.creator { + Some(proto::ViewId { + creator: Some(peer_id), + id: self.id, + }) + } else { + None } } } @@ -9069,7 +9273,7 @@ mod tests { fn for_project_item( _project: Entity, - _pane: &Pane, + _pane: Option<&Pane>, _item: Entity, _: &mut Window, cx: &mut Context, @@ -9144,7 +9348,7 @@ mod tests { fn for_project_item( _project: Entity, - _pane: &Pane, + _pane: Option<&Pane>, _item: Entity, _: &mut Window, cx: &mut Context, @@ -9191,7 +9395,7 @@ mod tests { fn for_project_item( _project: Entity, - _pane: &Pane, + _pane: Option<&Pane>, _item: Entity, _: &mut Window, cx: &mut Context,