From 70a742eadcf8968f80378ec4e424d7cc45860cef Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:30:32 -0300 Subject: [PATCH 01/12] git_ui: Don't display the merge conflict notification if an agent is running (#51498) This PR is motivated by internal feedback in which the notification that we show inviting to resolve merging conflicts with an agent also pops up if the agent itself ran `git merge`. In this case, the notification is unnecessary noise. So, what I'm doing here is simply _not_ showing it if there's a running agent. I want to note that this change is accepting a trade-off here, in which there could be cases that even if an agent is running, the notification can still be useful. There could be other ways to identify whether the agent is running `git merge`, but they all felt a bit too complex for the moment. And given this is reasonably an edge case, I'm favoring a simple approach for now. Release Notes: - N/A --------- Co-authored-by: Lukas Wirth --- .../src/connection_view/thread_view.rs | 26 +++++++++++++++++-- crates/git_ui/src/conflict_view.rs | 17 +++++------- crates/workspace/src/notifications.rs | 8 ++++++ crates/workspace/src/workspace.rs | 6 +++++ 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index c5e44a582cefba92161760711ebd5ba6bf0d1936..29ba06f470d78d60772b63ce54802647ef303444 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -739,10 +739,13 @@ impl ThreadView { } } })); + if self.parent_id.is_none() { + self.suppress_merge_conflict_notification(cx); + } generation } - pub fn stop_turn(&mut self, generation: usize) { + pub fn stop_turn(&mut self, generation: usize, cx: &mut Context) { if self.turn_fields.turn_generation != generation { return; } @@ -753,6 +756,25 @@ impl ThreadView { .map(|started| started.elapsed()); self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take(); self.turn_fields._turn_timer_task = None; + if self.parent_id.is_none() { + self.unsuppress_merge_conflict_notification(cx); + } + } + + fn suppress_merge_conflict_notification(&self, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + workspace.suppress_notification(&workspace::merge_conflict_notification_id(), cx); + }) + .ok(); + } + + fn unsuppress_merge_conflict_notification(&self, cx: &mut Context) { + self.workspace + .update(cx, |workspace, _cx| { + workspace.unsuppress(workspace::merge_conflict_notification_id()); + }) + .ok(); } pub fn update_turn_tokens(&mut self, cx: &App) { @@ -962,7 +984,7 @@ impl ThreadView { let mut cx = cx.clone(); move || { this.update(&mut cx, |this, cx| { - this.stop_turn(generation); + this.stop_turn(generation, cx); cx.notify(); }) .ok(); diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index d3bb5213a5c5c94171d48d324c7ce05e6399399f..96faa8879b38f59133bf3679788a3c24d1201f54 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -18,10 +18,7 @@ use settings::Settings; use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; -use workspace::{ - Workspace, - notifications::{NotificationId, simple_message_notification::MessageNotification}, -}; +use workspace::{Workspace, notifications::simple_message_notification::MessageNotification}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, }; @@ -500,12 +497,6 @@ fn render_conflict_buttons( .into_any() } -struct MergeConflictNotification; - -fn merge_conflict_notification_id() -> NotificationId { - NotificationId::unique::() -} - fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec { let project = workspace.project().read(cx); let git_store = project.git_store().read(cx); @@ -547,8 +538,12 @@ pub(crate) fn register_conflict_notification( return; } + if workspace.is_notification_suppressed(workspace::merge_conflict_notification_id()) { + return; + } + let paths = collect_conflicted_file_paths(workspace, cx); - let notification_id = merge_conflict_notification_id(); + let notification_id = workspace::merge_conflict_notification_id(); let current_paths_set: HashSet = paths.iter().cloned().collect(); if paths.is_empty() { diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 29bb9d7b063ff6e4b9f472d708f354fb50f7a2e8..85b1fe4e707acbc7107df14d23caa3bda24519e5 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -234,6 +234,14 @@ impl Workspace { self.suppressed_notifications.insert(id.clone()); } + pub fn is_notification_suppressed(&self, notification_id: NotificationId) -> bool { + self.suppressed_notifications.contains(¬ification_id) + } + + pub fn unsuppress(&mut self, notification_id: NotificationId) { + self.suppressed_notifications.remove(¬ification_id); + } + pub fn show_initial_notifications(&mut self, cx: &mut Context) { // Allow absence of the global so that tests don't need to initialize it. let app_notifications = GLOBAL_APP_NOTIFICATIONS diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 7696af97996a83db0aab05dc11d03f6ac0a77513..dc3d076bd6addc911dfdbf0cc736d876acc78484 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7268,6 +7268,12 @@ impl GlobalAnyActiveCall { cx.global() } } + +pub fn merge_conflict_notification_id() -> NotificationId { + struct MergeConflictNotification; + NotificationId::unique::() +} + /// Workspace-local view of a remote participant's location. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParticipantLocation { From 52fb089258aaabfedeb4b708b93c1ff54f3799b3 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 16 Mar 2026 10:35:51 -0500 Subject: [PATCH 02/12] ep: Track e2e latency (#51678) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... Co-authored-by: Oleksiy --- .../cloud_llm_client/src/cloud_llm_client.rs | 4 ++ crates/edit_prediction/src/edit_prediction.rs | 14 +++++ .../src/edit_prediction_tests.rs | 18 +++++- crates/edit_prediction/src/fim.rs | 11 +--- crates/edit_prediction/src/mercury.rs | 12 ++-- crates/edit_prediction/src/prediction.rs | 23 ++------ crates/edit_prediction/src/sweep_ai.rs | 18 ++---- crates/edit_prediction/src/zeta.rs | 17 +++--- .../src/rate_prediction_modal.rs | 55 +++++-------------- 9 files changed, 73 insertions(+), 99 deletions(-) diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 9ed82365ea910dd910226f70e242d68388b41796..d2d25ff5b84ef524f4e573a13149b26fe32fc4a5 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -144,6 +144,8 @@ pub struct AcceptEditPredictionBody { pub request_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub e2e_latency_ms: Option, } #[derive(Debug, Clone, Deserialize)] @@ -164,6 +166,8 @@ pub struct EditPredictionRejection { pub was_shown: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub e2e_latency_ms: Option, } #[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index c7497fa11da3c7ec6a260aa6fe388d019e8fe24a..cfc5c7efe348b7238813853bbf3e5fd70047340d 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -385,6 +385,7 @@ impl ProjectState { EditPredictionRejectReason::Canceled, false, None, + None, cx, ); }) @@ -413,6 +414,7 @@ struct CurrentEditPrediction { pub prediction: EditPrediction, pub was_shown: bool, pub shown_with: Option, + pub e2e_latency: std::time::Duration, } impl CurrentEditPrediction { @@ -506,12 +508,14 @@ impl std::ops::Deref for BufferEditPrediction<'_> { } #[derive(Clone)] + struct PendingSettledPrediction { request_id: EditPredictionId, editable_anchor_range: Range, example: Option, enqueued_at: Instant, last_edit_at: Instant, + e2e_latency: std::time::Duration, } struct RegisteredBuffer { @@ -1686,6 +1690,7 @@ impl EditPredictionStore { request_id = pending_prediction.request_id.0.clone(), settled_editable_region, example = pending_prediction.example.take(), + e2e_latency = pending_prediction.e2e_latency.as_millis(), ); return false; @@ -1715,6 +1720,7 @@ impl EditPredictionStore { edited_buffer_snapshot: &BufferSnapshot, editable_offset_range: Range, example: Option, + e2e_latency: std::time::Duration, cx: &mut Context, ) { let this = &mut *self; @@ -1729,6 +1735,7 @@ impl EditPredictionStore { editable_anchor_range: edited_buffer_snapshot .anchor_range_around(editable_offset_range), example, + e2e_latency, enqueued_at: now, last_edit_at: now, }); @@ -1751,6 +1758,7 @@ impl EditPredictionStore { reason, prediction.was_shown, model_version, + Some(prediction.e2e_latency), cx, ); } @@ -1812,6 +1820,7 @@ impl EditPredictionStore { reason: EditPredictionRejectReason, was_shown: bool, model_version: Option, + e2e_latency: Option, cx: &App, ) { match self.edit_prediction_model { @@ -1835,6 +1844,7 @@ impl EditPredictionStore { reason, was_shown, model_version, + e2e_latency_ms: e2e_latency.map(|latency| latency.as_millis()), }, organization_id, }) @@ -2008,6 +2018,7 @@ impl EditPredictionStore { EditPredictionResult { id: prediction_result.id, prediction: Err(EditPredictionRejectReason::CurrentPreferred), + e2e_latency: prediction_result.e2e_latency, } }, PredictionRequestedBy::DiagnosticsUpdate, @@ -2205,6 +2216,7 @@ impl EditPredictionStore { prediction, was_shown: false, shown_with: None, + e2e_latency: prediction_result.e2e_latency, }; if let Some(current_prediction) = @@ -2225,6 +2237,7 @@ impl EditPredictionStore { EditPredictionRejectReason::CurrentPreferred, false, new_prediction.prediction.model_version, + Some(new_prediction.e2e_latency), cx, ); None @@ -2239,6 +2252,7 @@ impl EditPredictionStore { reject_reason, false, None, + Some(prediction_result.e2e_latency), cx, ); None diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 74688f64effc4c4e371d4516b25c6ce55b317dbb..5daa7ee4a0dea1384e002acefe1fb4b47d0d5f91 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1323,6 +1323,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Empty, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -1384,6 +1385,7 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::InterpolatedEmpty, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -1477,6 +1479,7 @@ async fn test_replace_current(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Replaced, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -1572,6 +1575,7 @@ async fn test_current_preferred(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::CurrentPreferred, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -1664,6 +1668,7 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Canceled, was_shown: false, model_version: None, + e2e_latency_ms: None, }] ); } @@ -1795,12 +1800,14 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Canceled, was_shown: false, model_version: None, + e2e_latency_ms: None, }, EditPredictionRejection { request_id: first_id, reason: EditPredictionRejectReason::Replaced, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), } ] ); @@ -1963,6 +1970,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); ep_store.reject_prediction( @@ -1970,6 +1978,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Canceled, true, None, + None, cx, ); }); @@ -1989,6 +1998,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Discarded, was_shown: false, model_version: None, + e2e_latency_ms: None } ); assert_eq!( @@ -1998,6 +2008,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Canceled, was_shown: true, model_version: None, + e2e_latency_ms: None } ); @@ -2009,6 +2020,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); } @@ -2041,6 +2053,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); }); @@ -2061,6 +2074,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); }); @@ -2394,8 +2408,6 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { can_collect_data: false, repo_url: None, }, - buffer_snapshotted_at: Instant::now(), - response_received_at: Instant::now(), model_version: None, }; @@ -3115,6 +3127,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) { &snapshot_a, editable_region_a.clone(), None, + Duration::from_secs(0), cx, ); }); @@ -3178,6 +3191,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) { &snapshot_b2, editable_region_b.clone(), None, + Duration::from_secs(0), cx, ); }); diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index 8de58b9b2e52502519a362d9502ddc1b3cdffde4..46586eb3796026c764ff8659734c564e368681b9 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -19,10 +19,8 @@ struct FimRequestOutput { request_id: String, edits: Vec<(std::ops::Range, Arc)>, snapshot: BufferSnapshot, - response_received_at: Instant, inputs: ZetaPromptInput, buffer: Entity, - buffer_snapshotted_at: Instant, } pub fn request_prediction( @@ -47,7 +45,7 @@ pub fn request_prediction( let http_client = cx.http_client(); let cursor_point = position.to_point(&snapshot); - let buffer_snapshotted_at = Instant::now(); + let request_start = cx.background_executor().now(); let Some(settings) = (match provider { settings::EditPredictionProvider::Ollama => settings.ollama.clone(), @@ -119,7 +117,7 @@ pub fn request_prediction( log::debug!( "fim: completion received ({:.2}s)", - (response_received_at - buffer_snapshotted_at).as_secs_f64() + (response_received_at - request_start).as_secs_f64() ); let completion: Arc = clean_fim_completion(&response_text).into(); @@ -135,10 +133,8 @@ pub fn request_prediction( request_id, edits, snapshot, - response_received_at, inputs, buffer, - buffer_snapshotted_at, }) }); @@ -151,10 +147,9 @@ pub fn request_prediction( &output.snapshot, output.edits.into(), None, - output.buffer_snapshotted_at, - output.response_received_at, output.inputs, None, + cx.background_executor().now() - request_start, cx, ) .await, diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index b80498c4ddccfffab02e77ceb20e6e9cf68851f4..71362f4c873ca7b6f89030392449916cdc297b8e 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -14,7 +14,7 @@ use language::{ToOffset, ToPoint as _}; use language_model::{ApiKeyState, EnvVar, env_var}; use release_channel::AppVersion; use serde::{Deserialize, Serialize}; -use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; +use std::{mem, ops::Range, path::Path, sync::Arc}; use zeta_prompt::ZetaPromptInput; const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; @@ -67,7 +67,7 @@ impl Mercury { let http_client = cx.http_client(); let cursor_point = position.to_point(&snapshot); - let buffer_snapshotted_at = Instant::now(); + let request_start = cx.background_executor().now(); let active_buffer = buffer.clone(); let result = cx.background_spawn(async move { @@ -171,7 +171,6 @@ impl Mercury { .await .context("Failed to read response body")?; - let response_received_at = Instant::now(); if !response.status().is_success() { if response.status() == StatusCode::PAYMENT_REQUIRED { anyhow::bail!(MercuryPaymentRequiredError( @@ -222,7 +221,7 @@ impl Mercury { ); } - anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) + anyhow::Ok((id, edits, snapshot, inputs)) }); cx.spawn(async move |ep_store, cx| { @@ -240,7 +239,7 @@ impl Mercury { cx.notify(); })?; - let (id, edits, old_snapshot, response_received_at, inputs) = result?; + let (id, edits, old_snapshot, inputs) = result?; anyhow::Ok(Some( EditPredictionResult::new( EditPredictionId(id.into()), @@ -248,10 +247,9 @@ impl Mercury { &old_snapshot, edits.into(), None, - buffer_snapshotted_at, - response_received_at, inputs, None, + cx.background_executor().now() - request_start, cx, ) .await, diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index 0db47b0ec93b69ceebeee1989d8196642385bdd0..ef2bf2deafb7309f4871a921061ab114fa280e2f 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -1,8 +1,4 @@ -use std::{ - ops::Range, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{ops::Range, sync::Arc}; use cloud_llm_client::EditPredictionRejectReason; use edit_prediction_types::{PredictedCursorPosition, interpolate_edits}; @@ -29,6 +25,7 @@ impl std::fmt::Display for EditPredictionId { pub struct EditPredictionResult { pub id: EditPredictionId, pub prediction: Result, + pub e2e_latency: std::time::Duration, } impl EditPredictionResult { @@ -38,15 +35,15 @@ impl EditPredictionResult { edited_buffer_snapshot: &BufferSnapshot, edits: Arc<[(Range, Arc)]>, cursor_position: Option, - buffer_snapshotted_at: Instant, - response_received_at: Instant, inputs: ZetaPromptInput, model_version: Option, + e2e_latency: std::time::Duration, cx: &mut AsyncApp, ) -> Self { if edits.is_empty() { return Self { id, + e2e_latency, prediction: Err(EditPredictionRejectReason::Empty), }; } @@ -62,6 +59,7 @@ impl EditPredictionResult { else { return Self { id, + e2e_latency, prediction: Err(EditPredictionRejectReason::InterpolatedEmpty), }; }; @@ -70,6 +68,7 @@ impl EditPredictionResult { Self { id: id.clone(), + e2e_latency, prediction: Ok(EditPrediction { id, edits, @@ -78,8 +77,6 @@ impl EditPredictionResult { edit_preview, inputs, buffer: edited_buffer.clone(), - buffer_snapshotted_at, - response_received_at, model_version, }), } @@ -94,8 +91,6 @@ pub struct EditPrediction { pub snapshot: BufferSnapshot, pub edit_preview: EditPreview, pub buffer: Entity, - pub buffer_snapshotted_at: Instant, - pub response_received_at: Instant, pub inputs: zeta_prompt::ZetaPromptInput, pub model_version: Option, } @@ -111,10 +106,6 @@ impl EditPrediction { pub fn targets_buffer(&self, buffer: &Buffer) -> bool { self.snapshot.remote_id() == buffer.remote_id() } - - pub fn latency(&self) -> Duration { - self.response_received_at - self.buffer_snapshotted_at - } } impl std::fmt::Debug for EditPrediction { @@ -169,8 +160,6 @@ mod tests { can_collect_data: false, repo_url: None, }, - buffer_snapshotted_at: Instant::now(), - response_received_at: Instant::now(), }; cx.update(|cx| { diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index 99ddd9b86d238c2e56331f52f9fad51438ee1f71..93a9a34340cfe0b55e40d35bb4c8980dff983fa5 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -21,7 +21,6 @@ use std::{ ops::Range, path::Path, sync::Arc, - time::Instant, }; const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; @@ -50,6 +49,7 @@ impl SweepAi { .sweep .privacy_mode; let debug_info = self.debug_info.clone(); + let request_start = cx.background_executor().now(); self.api_token.update(cx, |key_state, cx| { _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); }); @@ -90,8 +90,6 @@ impl SweepAi { .take(3) .collect::>(); - let buffer_snapshotted_at = Instant::now(); - let result = cx.background_spawn(async move { let text = inputs.snapshot.text(); @@ -255,7 +253,6 @@ impl SweepAi { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; - let response_received_at = Instant::now(); if !response.status().is_success() { let message = format!( "Request failed with status: {:?}\nBody: {}", @@ -289,19 +286,13 @@ impl SweepAi { }) .collect::>(); - anyhow::Ok(( - response.autocomplete_id, - edits, - inputs.snapshot, - response_received_at, - ep_inputs, - )) + anyhow::Ok((response.autocomplete_id, edits, inputs.snapshot, ep_inputs)) }); let buffer = inputs.buffer.clone(); cx.spawn(async move |cx| { - let (id, edits, old_snapshot, response_received_at, inputs) = result.await?; + let (id, edits, old_snapshot, inputs) = result.await?; anyhow::Ok(Some( EditPredictionResult::new( EditPredictionId(id.into()), @@ -309,10 +300,9 @@ impl SweepAi { &old_snapshot, edits.into(), None, - buffer_snapshotted_at, - response_received_at, inputs, None, + cx.background_executor().now() - request_start, cx, ) .await, diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index fc3ed81c78737f4ba4c8b7aa5131232b2b007b87..e7d38df5c8e99b86303ca72a715e10acf22eb9b1 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -22,7 +22,7 @@ use ui::SharedString; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; use zeta_prompt::{ParsedOutput, ZetaPromptInput}; -use std::{env, ops::Range, path::Path, sync::Arc, time::Instant}; +use std::{env, ops::Range, path::Path, sync::Arc}; use zeta_prompt::{ CURSOR_MARKER, ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, prompt_input_contains_special_tokens, stop_tokens_for_format, @@ -63,7 +63,7 @@ pub fn request_prediction_with_zeta( }; let http_client = cx.http_client(); - let buffer_snapshotted_at = Instant::now(); + let request_start = cx.background_executor().now(); let raw_config = store.zeta2_raw_config().cloned(); let preferred_experiment = store.preferred_experiment().map(|s| s.to_owned()); let open_ai_compatible_api_key = load_open_ai_compatible_api_key_if_needed(provider, cx); @@ -100,7 +100,6 @@ pub fn request_prediction_with_zeta( snapshot: BufferSnapshot, edits: Vec<(Range, Arc)>, cursor_position: Option, - received_response_at: Instant, editable_range_in_buffer: Range, model_version: Option, } @@ -295,8 +294,6 @@ pub fn request_prediction_with_zeta( return Ok((None, None)); }; - let received_response_at = Instant::now(); - log::trace!("Got edit prediction response"); let Some(ParsedOutput { @@ -358,7 +355,6 @@ pub fn request_prediction_with_zeta( snapshot: snapshot.clone(), edits, cursor_position, - received_response_at, editable_range_in_buffer, model_version, }), @@ -369,6 +365,7 @@ pub fn request_prediction_with_zeta( }); cx.spawn(async move |this, cx| { + let request_duration = cx.background_executor().now() - request_start; let Some((id, prediction)) = handle_api_response(&this, request_task.await, cx)? else { return Ok(None); }; @@ -379,13 +376,13 @@ pub fn request_prediction_with_zeta( snapshot: edited_buffer_snapshot, edits, cursor_position, - received_response_at, editable_range_in_buffer, model_version, }) = prediction else { return Ok(Some(EditPredictionResult { id, + e2e_latency: request_duration, prediction: Err(EditPredictionRejectReason::Empty), })); }; @@ -423,6 +420,7 @@ pub fn request_prediction_with_zeta( &edited_buffer_snapshot, editable_range_in_buffer, example_spec, + request_duration, cx, ); }) @@ -438,10 +436,9 @@ pub fn request_prediction_with_zeta( &edited_buffer_snapshot, edits.into(), cursor_position, - buffer_snapshotted_at, - received_response_at, inputs, model_version, + request_duration, cx, ) .await, @@ -590,6 +587,7 @@ pub(crate) fn edit_prediction_accepted( let request_id = current_prediction.prediction.id.to_string(); let model_version = current_prediction.prediction.model_version; + let e2e_latency = current_prediction.e2e_latency; let require_auth = custom_accept_url.is_none(); let client = store.client.clone(); let llm_token = store.llm_token.clone(); @@ -615,6 +613,7 @@ pub(crate) fn edit_prediction_accepted( serde_json::to_string(&AcceptEditPredictionBody { request_id: request_id.clone(), model_version: model_version.clone(), + e2e_latency_ms: Some(e2e_latency.as_millis()), })? .into(), ); diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index b2e7209c1a7e9dd403ed0ee70336119ef0f1bdc9..15cccc777feb0a999724f2b4405fc11df8c5f252 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -13,7 +13,7 @@ use project::{ }; use settings::Settings as _; use std::rc::Rc; -use std::{fmt::Write, sync::Arc, time::Duration}; +use std::{fmt::Write, sync::Arc}; use theme::ThemeSettings; use ui::{ ContextMenu, DropdownMenu, KeyBinding, List, ListItem, ListItemSpacing, PopoverMenuHandle, @@ -850,30 +850,18 @@ impl RatePredictionsModal { .gap_3() .child(Icon::new(icon_name).color(icon_color).size(IconSize::Small)) .child( - v_flex() - .child( - h_flex() - .gap_1() - .child(Label::new(file_name).size(LabelSize::Small)) - .when_some(file_path, |this, p| { - this.child( - Label::new(p) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) - .child( - Label::new(format!( - "{} ago, {:.2?}", - format_time_ago( - completion.response_received_at.elapsed() - ), - completion.latency() - )) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), + v_flex().child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when_some(file_path, |this, p| { + this.child( + Label::new(p) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ), ), ) .tooltip(Tooltip::text(tooltip_text)) @@ -977,23 +965,6 @@ impl Focusable for RatePredictionsModal { impl ModalView for RatePredictionsModal {} -fn format_time_ago(elapsed: Duration) -> String { - let seconds = elapsed.as_secs(); - if seconds < 120 { - "1 minute".to_string() - } else if seconds < 3600 { - format!("{} minutes", seconds / 60) - } else if seconds < 7200 { - "1 hour".to_string() - } else if seconds < 86400 { - format!("{} hours", seconds / 3600) - } else if seconds < 172800 { - "1 day".to_string() - } else { - format!("{} days", seconds / 86400) - } -} - struct FeedbackCompletionProvider; impl FeedbackCompletionProvider { From e5bb2c67902aab64d5d284a9b05cf1836fae91c6 Mon Sep 17 00:00:00 2001 From: loadingalias <138315197+loadingalias@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:35:59 -0400 Subject: [PATCH 03/12] Fix non-ASCII path:line:column navigation (#51238) Closes #43329 ## Summary This fixes `path:line:column` navigation for files containing non-ASCII text. Before this change, open path flows were passing the external column directly into `go_to_singleton_buffer_point`. That happened to work for ASCII, but it was wrong for Unicode because external columns are user-visible character positions while the editor buffer stores columns as UTF-8 byte offsets. This PR adds a shared text layer conversion for external row/column coordinates and uses it in the affected open-path flows: - file finder navigation - recent project remote connection navigation - recent project remote server navigation It also adds regression coverage for the Unicode case that originally failed. As a small - necessary - prerequisite, this also adds `remote_connection` test support to `file_finder`'s dev-deps so the local regression test can build and run on this branch. That follows the same feature mismatch pattern previously fixed in #48280. I wasn't able to locally verify the tests/etc. w/o the addition... so I've rolled it into this PR. Tests are green. The earlier attempt in #47093 was headed in the right direction, but it did not land and did not include the final regression coverage requested in review. ## Verification - `cargo fmt --all -- --check` - `./script/clippy -p text` - `./script/clippy -p file_finder` - `./script/clippy -p recent_projects` - `cargo test -p file_finder --lib --no-run` - `cargo test -p file_finder file_finder_tests::test_row_column_numbers_query_inside_file -- --exact` - `cargo test -p file_finder file_finder_tests::test_row_column_numbers_query_inside_unicode_file -- --exact` - `cargo test -p text tests::test_point_for_row_and_column_from_external_source -- --exact` ## Manual I reproduced locally on my machine (macOS) w/ a stateless launch using a Unicode file (Cyrillic) Before: - `:1:5` landed too far left - `:1:10` landed around the 4th visible Cyrillic character After: - `:1:5` lands after 4 visible characters / before the 5th - `:1:10` lands after 9 visible characters / before the 10th Release Notes: - Fixed `path:line:column` navigation so non-ASCII columns land on the correct character. --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 2 +- crates/file_finder/Cargo.toml | 1 - crates/file_finder/src/file_finder.rs | 8 +- crates/file_finder/src/file_finder_tests.rs | 85 +++++++++++++++++++ crates/go_to_line/Cargo.toml | 1 + crates/go_to_line/src/go_to_line.rs | 36 +++----- crates/multi_buffer/src/multi_buffer.rs | 2 +- crates/multi_buffer/src/multi_buffer_tests.rs | 24 ++++++ .../recent_projects/src/remote_connections.rs | 8 +- crates/recent_projects/src/remote_servers.rs | 15 ++-- crates/text/src/tests.rs | 18 ++++ crates/text/src/text.rs | 31 +++++++ 12 files changed, 192 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 467463433f2bb7c4bb90894095ef6216ef7c98a4..1c69b903c10345a4a78b4cab813baf8c899b7aad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6296,7 +6296,6 @@ dependencies = [ "serde", "serde_json", "settings", - "text", "theme", "ui", "util", @@ -7521,6 +7520,7 @@ dependencies = [ "indoc", "language", "menu", + "multi_buffer", "project", "rope", "serde", diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 0876d95a7b044d2a4ce5bf8be964c4057725f827..80e466ac4c571ede217aa734a7862becd08e72ba 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -28,7 +28,6 @@ picker.workspace = true project.workspace = true settings.workspace = true serde.workspace = true -text.workspace = true theme.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 3dcd052c34acc3a28650c58079c82499b7e94c85..4302669ddc11c94f7df128534217d00c27ef083a 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -35,7 +35,6 @@ use std::{ atomic::{self, AtomicBool}, }, }; -use text::Point; use ui::{ ButtonLike, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, @@ -1700,7 +1699,12 @@ impl PickerDelegate for FileFinderDelegate { active_editor .downgrade() .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + let Some(buffer) = editor.buffer().read(cx).as_singleton() else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let point = buffer_snapshot.point_from_external_input(row, col); + editor.go_to_singleton_buffer_point(point, window, cx); }) .log_err(); } diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index cd9f22ef9e9c09a828ceced449ebafb9c3c2e12b..3f9d579b03c9aa2abeb408bdf6b77cf5e69de003 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -521,6 +521,91 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_row_column_numbers_query_inside_unicode_file(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + let first_file_name = "first.rs"; + let first_file_contents = "aéøbcdef"; + app_state + .fs + .as_fake() + .insert_tree( + path!("/src"), + json!({ + "test": { + first_file_name: first_file_contents, + "second.rs": "// Second Rust file", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; + + let (picker, workspace, cx) = build_find_picker(project, cx); + + let file_query = &first_file_name[..3]; + let file_row = 1; + let file_column = 5; + let query_inside_file = format!("{file_query}:{file_row}:{file_column}"); + picker + .update_in(cx, |finder, window, cx| { + finder + .delegate + .update_matches(query_inside_file.to_string(), window, cx) + }) + .await; + picker.update(cx, |finder, _| { + assert_match_at_position(finder, 1, &query_inside_file.to_string()); + let finder = &finder.delegate; + assert_eq!(finder.matches.len(), 2); + let latest_search_query = finder + .latest_search_query + .as_ref() + .expect("Finder should have a query after the update_matches call"); + assert_eq!(latest_search_query.raw_query, query_inside_file); + assert_eq!(latest_search_query.file_query_end, Some(file_query.len())); + assert_eq!(latest_search_query.path_position.row, Some(file_row)); + assert_eq!(latest_search_query.path_position.column, Some(file_column)); + }); + + cx.dispatch_action(Confirm); + + let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::(cx).unwrap()); + cx.executor().advance_clock(Duration::from_secs(2)); + + let expected_column = first_file_contents + .chars() + .take(file_column as usize - 1) + .map(|character| character.len_utf8()) + .sum::(); + + editor.update(cx, |editor, cx| { + let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); + assert_eq!( + all_selections.len(), + 1, + "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}" + ); + let caret_selection = all_selections.into_iter().next().unwrap(); + assert_eq!( + caret_selection.start, caret_selection.end, + "Caret selection should have its start and end at the same position" + ); + assert_eq!( + file_row, + caret_selection.start.row + 1, + "Query inside file should get caret with the same focus row" + ); + assert_eq!( + expected_column, + caret_selection.start.column as usize, + "Query inside file should map user-visible columns to byte offsets for Unicode text" + ); + }); +} + #[gpui::test] async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/go_to_line/Cargo.toml b/crates/go_to_line/Cargo.toml index 58c58dc389e37210063efb55337fc385cc0ad435..c07656985380c93355a4c8429dcf1135acf93d56 100644 --- a/crates/go_to_line/Cargo.toml +++ b/crates/go_to_line/Cargo.toml @@ -17,6 +17,7 @@ editor.workspace = true gpui.workspace = true language.workspace = true menu.workspace = true +multi_buffer.workspace = true serde.workspace = true settings.workspace = true text.workspace = true diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 79c4e54700ccec7575c825ecae6a1bb05419b6fb..a5332e96c731a29027ea6a69288d7d9556cb2da0 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -2,7 +2,7 @@ pub mod cursor_position; use cursor_position::UserCaretPosition; use editor::{ - Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToOffset, ToPoint, + Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToPoint, actions::Tab, scroll::{Autoscroll, ScrollOffset}, }; @@ -11,6 +11,7 @@ use gpui::{ Subscription, div, prelude::*, }; use language::Buffer; +use multi_buffer::MultiBufferRow; use text::{Bias, Point}; use theme::ActiveTheme; use ui::prelude::*; @@ -228,31 +229,14 @@ impl GoToLine { let row = query_row.saturating_sub(1); let character = query_char.unwrap_or(0).saturating_sub(1); - let start_offset = Point::new(row, 0).to_offset(snapshot); - const MAX_BYTES_IN_UTF_8: u32 = 4; - let max_end_offset = snapshot - .clip_point( - Point::new(row, character * MAX_BYTES_IN_UTF_8 + 1), - Bias::Right, - ) - .to_offset(snapshot); - - let mut chars_to_iterate = character; - let mut end_offset = start_offset; - 'outer: for text_chunk in snapshot.text_for_range(start_offset..max_end_offset) { - let mut offset_increment = 0; - for c in text_chunk.chars() { - if chars_to_iterate == 0 { - end_offset += offset_increment; - break 'outer; - } else { - chars_to_iterate -= 1; - offset_increment += c.len_utf8(); - } - } - end_offset += offset_increment; - } - Some(snapshot.anchor_before(snapshot.clip_offset(end_offset, Bias::Left))) + let target_multi_buffer_row = MultiBufferRow(row); + let (buffer_snapshot, target_in_buffer, _) = snapshot.point_to_buffer_point(Point::new( + target_multi_buffer_row.min(snapshot.max_row()).0, + 0, + ))?; + let target_point = + buffer_snapshot.point_from_external_input(target_in_buffer.row, character); + Some(snapshot.anchor_before(target_point)) } fn relative_line_from_query(&self, cx: &App) -> Option { diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 2b4428b36a8c8f3b91f53425981bfe27480f7e64..e3b578c9d5ff274f35ddced24a49f4b31d819bf1 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -2141,7 +2141,7 @@ impl MultiBuffer { if point < start { found = Some((start, excerpt_id)); } - if point > end { + if point >= end { found = Some((end, excerpt_id)); } } diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index c169297e2d5e170cc6cd7d85838c36c3e6bcf71e..8b708968f21b103ee3c7882c01cd1edf6884af03 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -72,6 +72,30 @@ fn test_singleton(cx: &mut App) { assert_consistent_line_numbers(&snapshot); } +#[gpui::test] +fn test_buffer_point_to_anchor_at_end_of_singleton_buffer(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("abc", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + let excerpt_id = multibuffer + .read(cx) + .excerpt_ids() + .into_iter() + .next() + .unwrap(); + let anchor = multibuffer + .read(cx) + .buffer_point_to_anchor(&buffer, Point::new(0, 3), cx); + + assert_eq!( + anchor, + Some(Anchor::in_buffer( + excerpt_id, + buffer.read(cx).snapshot().anchor_after(Point::new(0, 3)), + )) + ); +} + #[gpui::test] fn test_remote(cx: &mut App) { let host_buffer = cx.new(|cx| Buffer::local("a", cx)); diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index b5af1a110a5b0ebae6cb8e6e035791b564e15527..5275cdaa1526a670e817ff3b229d7e92b94bb309 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -10,7 +10,6 @@ use extension_host::ExtensionStore; use futures::{FutureExt as _, channel::oneshot, select}; use gpui::{AppContext, AsyncApp, PromptLevel, WindowHandle}; -use language::Point; use project::trusted_worktrees; use remote::{ DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions, @@ -458,7 +457,12 @@ pub fn navigate_to_positions( active_editor.update(cx, |editor, cx| { let row = row.saturating_sub(1); let col = path.column.unwrap_or(0).saturating_sub(1); - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + let Some(buffer) = editor.buffer().read(cx).as_singleton() else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let point = buffer_snapshot.point_from_external_input(row, col); + editor.go_to_singleton_buffer_point(point, window, cx); }); }) .ok(); diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index d4cfb6520e6f73592ede5abcacb558967d10dbc7..4569492d4c73b6e8087cf8363db805a645e5314e 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -17,7 +17,6 @@ use gpui::{ EventEmitter, FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window, canvas, }; -use language::Point; use log::{debug, info}; use open_path_prompt::OpenPathDelegate; use paths::{global_ssh_config_file, user_ssh_config_file}; @@ -519,11 +518,15 @@ impl ProjectPicker { active_editor.update(cx, |editor, cx| { let row = row.saturating_sub(1); let col = path.column.unwrap_or(0).saturating_sub(1); - editor.go_to_singleton_buffer_point( - Point::new(row, col), - window, - cx, - ); + let Some(buffer) = + editor.buffer().read(cx).as_singleton() + else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let point = + buffer_snapshot.point_from_external_input(row, col); + editor.go_to_singleton_buffer_point(point, window, cx); }); }) .ok(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 194ac2a40d5ac96a39177eedd35b991ded30de38..d5d3facb9b97d09e4724369bd17df639e2b6ac42 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -30,6 +30,24 @@ fn test_edit() { assert_eq!(buffer.text(), "ghiamnoef"); } +#[test] +fn test_point_for_row_and_column_from_external_source() { + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + "aéøbcdef\nsecond", + ); + let snapshot = buffer.snapshot(); + + assert_eq!(snapshot.point_from_external_input(0, 0), Point::new(0, 0)); + assert_eq!(snapshot.point_from_external_input(0, 4), Point::new(0, 6)); + assert_eq!( + snapshot.point_from_external_input(0, 100), + Point::new(0, 10) + ); + assert_eq!(snapshot.point_from_external_input(1, 3), Point::new(1, 3)); +} + #[gpui::test(iterations = 100)] fn test_random_edits(mut rng: StdRng) { let operations = env::var("OPERATIONS") diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index a991a72df40c502a90aa0b82191b37c54b3f8de2..c054a4caacd34904090397612474be55c48ffbfd 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2254,6 +2254,37 @@ impl BufferSnapshot { (row_end_offset - row_start_offset) as u32 } + /// A function to convert character offsets from e.g. user's `go.mod:22:33` input into byte-offset Point columns. + pub fn point_from_external_input(&self, row: u32, characters: u32) -> Point { + const MAX_BYTES_IN_UTF_8: u32 = 4; + + let row = row.min(self.max_point().row); + let start = Point::new(row, 0); + let end = self.clip_point( + Point::new( + row, + characters + .saturating_mul(MAX_BYTES_IN_UTF_8) + .saturating_add(1), + ), + Bias::Right, + ); + let range = start..end; + let mut point = range.start; + let mut remaining_columns = characters; + + for chunk in self.text_for_range(range) { + for character in chunk.chars() { + if remaining_columns == 0 { + return point; + } + remaining_columns -= 1; + point.column += character.len_utf8() as u32; + } + } + point + } + pub fn line_indents_in_row_range( &self, row_range: Range, From 8022e9d8d17d0f2f4acc7eb0e6658f659742a591 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 16 Mar 2026 16:46:09 +0100 Subject: [PATCH 04/12] extension_ci: Bump update action revision (#51679) This should put us in a state where everything finally works in its entirety. Release Notes: - N/A --- .github/workflows/extension_bump.yml | 2 +- tooling/xtask/src/tasks/workflows/extension_bump.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index a20235592847416f8d83aef42dd3d0d4fd283ecb..c971bc2ab096cd54089558a6a19875cb66f03918 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -230,7 +230,7 @@ jobs: echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT" - name: extension_bump::release_action - uses: zed-extensions/update-action@543925fc45da8866b0d017218a656c8a3296ed3f + uses: zed-extensions/update-action@1ef53b23be40fe2549be0baffaa98e9f51838fef with: extension-name: ${{ steps.get-extension-id.outputs.extension_id }} push-to: zed-industries/extensions diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index f5f1cef56b5e434ca3b0516fd83ffa960a95951d..3097611e079195d2d6244f3ab2b15d8f99e8c8a4 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -430,7 +430,7 @@ fn release_action( named::uses( "zed-extensions", "update-action", - "543925fc45da8866b0d017218a656c8a3296ed3f", + "1ef53b23be40fe2549be0baffaa98e9f51838fef", ) .add_with(("extension-name", extension_id.to_string())) .add_with(("push-to", "zed-industries/extensions")) From b338a699339008f7177d1488cf6ca828fc995d0c Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Mon, 16 Mar 2026 23:53:06 +0800 Subject: [PATCH 05/12] language_selector: Fix language selector query selection (#51581) Closes https://github.com/zed-industries/zed/issues/51576 Release Notes: - Select first entry in the language selector when matching a query https://github.com/user-attachments/assets/c824c024-d2f1-416e-a347-0eab7bc3ae0a Signed-off-by: Xiaobo Liu --- .../src/language_selector.rs | 180 +++++++++++++----- 1 file changed, 135 insertions(+), 45 deletions(-) diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 17a39d4979a1321a4b0e612bff228f186098babf..e5e6a2e264dbb923390e05b283fe341a3336af97 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -280,20 +280,28 @@ impl PickerDelegate for LanguageSelectorDelegate { }; this.update_in(cx, |this, window, cx| { - let delegate = &mut this.delegate; - delegate.matches = matches; - delegate.selected_index = delegate - .selected_index - .min(delegate.matches.len().saturating_sub(1)); - - if query_is_empty { - if let Some(index) = delegate - .current_language_candidate_index - .and_then(|ci| delegate.matches.iter().position(|m| m.candidate_id == ci)) - { - this.set_selected_index(index, None, false, window, cx); - } + if matches.is_empty() { + this.delegate.matches = matches; + this.delegate.selected_index = 0; + cx.notify(); + return; } + + let selected_index = if query_is_empty { + this.delegate + .current_language_candidate_index + .and_then(|current_language_candidate_index| { + matches.iter().position(|mat| { + mat.candidate_id == current_language_candidate_index + }) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.delegate.matches = matches; + this.set_selected_index(selected_index, None, false, window, cx); cx.notify(); }) .log_err(); @@ -345,28 +353,25 @@ mod tests { fn register_test_languages(project: &Entity, cx: &mut VisualTestContext) { project.read_with(cx, |project, _| { let language_registry = project.languages(); - language_registry.add(Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - ))); - language_registry.add(Arc::new(Language::new( - LanguageConfig { - name: "TypeScript".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["ts".to_string()], + for (language_name, path_suffix) in [ + ("C", "c"), + ("Go", "go"), + ("Ruby", "rb"), + ("Rust", "rs"), + ("TypeScript", "ts"), + ] { + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: language_name.into(), + matcher: LanguageMatcher { + path_suffixes: vec![path_suffix.to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - None, - ))); + None, + ))); + } }); } @@ -406,6 +411,24 @@ mod tests { workspace: &Entity, project: &Entity, cx: &mut VisualTestContext, + ) -> Entity { + let editor = open_new_buffer_editor(workspace, project, cx).await; + // Ensure the buffer has no language after the editor is created + let (_, buffer, _) = editor.read_with(cx, |editor, cx| { + editor + .active_excerpt(cx) + .expect("editor should have an active excerpt") + }); + buffer.update(cx, |buffer, cx| { + buffer.set_language(None, cx); + }); + editor + } + + async fn open_new_buffer_editor( + workspace: &Entity, + project: &Entity, + cx: &mut VisualTestContext, ) -> Entity { let create_buffer = project.update(cx, |project, cx| project.create_buffer(None, true, cx)); let buffer = create_buffer.await.expect("empty buffer should be created"); @@ -415,10 +438,6 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_center(Box::new(editor.clone()), window, cx); }); - // Ensure the buffer has no language after the editor is created - buffer.update(cx, |buffer, cx| { - buffer.set_language(None, cx); - }); editor } @@ -559,15 +578,86 @@ mod tests { assert_selected_language_for_editor(&workspace, &rust_editor, Some("Rust"), cx); assert_selected_language_for_editor(&workspace, &typescript_editor, Some("TypeScript"), cx); - // Ensure the empty editor's buffer has no language before asserting - let (_, buffer, _) = empty_editor.read_with(cx, |editor, cx| { - editor - .active_excerpt(cx) - .expect("editor should have an active excerpt") + assert_selected_language_for_editor(&workspace, &empty_editor, None, cx); + } + + #[gpui::test] + async fn test_language_selector_selects_first_match_after_querying_new_buffer( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/test"), json!({})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + register_test_languages(&project, cx); + + let editor = open_new_buffer_editor(&workspace, &project, cx).await; + workspace.update_in(cx, |workspace, window, cx| { + let was_activated = workspace.activate_item(&editor, true, true, window, cx); + assert!( + was_activated, + "editor should be activated before opening the modal" + ); }); - buffer.update(cx, |buffer, cx| { - buffer.set_language(None, cx); + cx.run_until_parked(); + + let picker = open_selector(&workspace, cx); + picker.read_with(cx, |picker, _| { + let selected_match = picker + .delegate + .matches + .get(picker.delegate.selected_index) + .expect("selected index should point to a match"); + let selected_candidate = picker + .delegate + .candidates + .get(selected_match.candidate_id) + .expect("selected match should map to a candidate"); + + assert_eq!(selected_candidate.string, "Plain Text"); + assert!( + picker + .delegate + .current_language_candidate_index + .is_some_and(|current_language_candidate_index| { + current_language_candidate_index > 1 + }), + "test setup should place Plain Text after at least two earlier languages", + ); + }); + + picker.update_in(cx, |picker, window, cx| { + picker.update_matches("ru".to_string(), window, cx) + }); + cx.run_until_parked(); + + picker.read_with(cx, |picker, _| { + assert!( + picker.delegate.matches.len() > 1, + "query should return multiple matches" + ); + assert_eq!(picker.delegate.selected_index, 0); + + let first_match = picker + .delegate + .matches + .first() + .expect("query should produce at least one match"); + let selected_match = picker + .delegate + .matches + .get(picker.delegate.selected_index) + .expect("selected index should point to a match"); + + assert_eq!(selected_match.candidate_id, first_match.candidate_id); }); - assert_selected_language_for_editor(&workspace, &empty_editor, None, cx); } } From ae445634e05ea9df7cc8ae102a2b5884e46223ea Mon Sep 17 00:00:00 2001 From: ISHIMWE Vainqueur <65293976+IVainqueur@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:57:20 +0200 Subject: [PATCH 06/12] git_ui: Show uncommitted change count badge on git panel icon (#49624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Implements `icon_label` on `GitPanel` to return the total count of uncommitted changes (`new_count + changes_count`) when non-zero, capped at `"99+"` for large repos. - Updates `PanelButtons::render()` to render that label as a small green badge overlaid on the panel's sidebar icon, using absolute positioning within a `div().relative()` wrapper. - The badge uses `version_control_added` theme color and `LabelSize::XSmall` text with `LineHeightStyle::UiLabel` for accurate vertical centering, positioned at the top-right corner of the icon button. The `icon_label` method already existed on the `Panel`/`PanelHandle` traits with a default `None` impl, and was already implemented by `NotificationPanel` (unread notification count) and `TerminalPanel` (open terminal count) — but was never rendered. This wires it up for all three panels at once. ## Notes - Badge is positioned with non-negative offsets (`top(0)`, `right(0)`) to stay within the parent container's bounds. The status bar's `render_left_tools()` uses `.overflow_x_hidden()`, which in GPUI clips both axes (the `overflow_mask` returns a full content mask whenever any axis is non-`Visible`), so negative offsets would be clipped. - `LineHeightStyle::UiLabel` collapses line height to `relative(1.)` so flex centering aligns the visual glyph rather than a taller-than-necessary line box. - No new data tracking logic — `GitPanel` already maintains `new_count` and `changes_count` reactively. - No feature flag or settings added per YAGNI. ## Suggested .rules additions The following pattern came up repeatedly and would prevent future sessions from hitting the same issue: ``` ## GPUI overflow clipping `overflow_x_hidden()` (and any single-axis overflow setter) clips **both** axes in GPUI. The `overflow_mask()` implementation in `style.rs` returns a full `ContentMask` (bounding box) whenever any axis is non-`Visible`. Absolute-positioned children that extend outside the element bounds will be clipped even if only the X axis is set to Hidden. Avoid negative `top`/`right`/`bottom`/`left` offsets on absolute children of containers that have any overflow hidden — keep badge/overlay elements within the parent's bounds instead. ``` Release Notes: - Added a numeric badge to the git panel sidebar icon showing the count of uncommitted changes. --------- Co-authored-by: Danilo Leal --- assets/settings/default.json | 8 ++ crates/collab_ui/src/notification_panel.rs | 3 + crates/collab_ui/src/panel_settings.rs | 2 + crates/git_ui/src/git_panel.rs | 8 ++ crates/git_ui/src/git_panel_settings.rs | 2 + crates/settings/src/vscode_import.rs | 1 + .../settings_content/src/settings_content.rs | 9 ++ crates/settings_content/src/terminal.rs | 4 + crates/settings_ui/src/page_data.rs | 72 +++++++++++++- crates/terminal/src/terminal_settings.rs | 2 + crates/terminal_view/src/terminal_panel.rs | 3 + crates/ui/src/components.rs | 2 + crates/ui/src/components/count_badge.rs | 93 +++++++++++++++++++ crates/workspace/src/dock.rs | 19 +++- crates/workspace/src/workspace.rs | 2 - 15 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 crates/ui/src/components/count_badge.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 563f0371f9612e5b69cce570fd84fdab325824c4..74ba9e6d52158ea63bab9c6200567b27286aa1da 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -922,6 +922,10 @@ /// /// Default: false "tree_view": false, + // Whether to show a badge on the git panel icon with the count of uncommitted changes. + // + // Default: false + "show_count_badge": false, "scrollbar": { // When to show the scrollbar in the git panel. // @@ -946,6 +950,8 @@ "dock": "right", // Default width of the notification panel. "default_width": 380, + // Whether to show a badge on the notification panel icon with the count of unread notifications. + "show_count_badge": false, }, "agent": { // Whether the inline assistant should use streaming tools, when available @@ -1867,6 +1873,8 @@ // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a // timeout of `0` will disable path hyperlinking in terminal. "path_hyperlink_timeout_ms": 1, + // Whether to show a badge on the terminal panel icon with the count of open terminals. + "show_count_badge": false, }, "code_actions_on_format": {}, // Settings related to running tasks. diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index fd70163896113f0a20b66c5181749d58385b4c34..308d521832d5f2964a46f32e88329bd15d5358ee 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -677,6 +677,9 @@ impl Panel for NotificationPanel { } fn icon_label(&self, _window: &Window, cx: &App) -> Option { + if !NotificationPanelSettings::get_global(cx).show_count_badge { + return None; + } let count = self.notification_store.read(cx).unread_notification_count(); if count == 0 { None diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index ebd021be4b56f4051feae01f3fef7a063c3a8214..938d33159e9adb7a9e63ceb73219b70724efee17 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -15,6 +15,7 @@ pub struct NotificationPanelSettings { pub button: bool, pub dock: DockPosition, pub default_width: Pixels, + pub show_count_badge: bool, } impl Settings for CollaborationPanelSettings { @@ -36,6 +37,7 @@ impl Settings for NotificationPanelSettings { button: panel.button.unwrap(), dock: panel.dock.unwrap().into(), default_width: panel.default_width.map(px).unwrap(), + show_count_badge: panel.show_count_badge.unwrap(), }; } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index ac1c4f97d4ebdbd387da0a0bb3306c58dde8c11e..7bd4f3b32a6bd1fa2be6e26d5662f51c80e5a0e6 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5797,6 +5797,14 @@ impl Panel for GitPanel { Some("Git Panel") } + fn icon_label(&self, _: &Window, cx: &App) -> Option { + if !GitPanelSettings::get_global(cx).show_count_badge { + return None; + } + let total = self.changes_count; + (total > 0).then(|| total.to_string()) + } + fn toggle_action(&self) -> Box { Box::new(ToggleFocus) } diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index d48cf44232afa93a8d9ce4e441364912a7200c45..baf453e310c02097da1d11344e79bac31f891d0b 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -28,6 +28,7 @@ pub struct GitPanelSettings { pub collapse_untracked_diff: bool, pub tree_view: bool, pub diff_stats: bool, + pub show_count_badge: bool, } impl ScrollbarVisibility for GitPanelSettings { @@ -64,6 +65,7 @@ impl Settings for GitPanelSettings { collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), tree_view: git_panel.tree_view.unwrap(), diff_stats: git_panel.diff_stats.unwrap(), + show_count_badge: git_panel.show_count_badge.unwrap(), } } } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index bcc579984bda0268a7405cbd1ea184cafc493aab..abfe0ec727c7388a612c38f5bb0b0c4d0dbf5682 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -877,6 +877,7 @@ impl VsCodeSettings { scrollbar: None, scroll_multiplier: None, toolbar: None, + show_count_badge: None, }) } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 95fc79e2718859baf60ebdd548a172bdc5526468..8ab0ad6874a9c87a2104ba580c7fb1a90276027e 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -635,6 +635,11 @@ pub struct GitPanelSettingsContent { /// /// Default: true pub diff_stats: Option, + + /// Whether to show a badge on the git panel icon with the count of uncommitted changes. + /// + /// Default: false + pub show_count_badge: Option, } #[derive( @@ -682,6 +687,10 @@ pub struct NotificationPanelSettingsContent { /// Default: 300 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub default_width: Option, + /// Whether to show a badge on the notification panel icon with the count of unread notifications. + /// + /// Default: false + pub show_count_badge: Option, } #[with_fallible_options] diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs index a13613badfaa0a375dbcbdf6424e7bda59a84dc4..83f3b32fdd14a6ee693f775b74022af4841af0a5 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -171,6 +171,10 @@ pub struct TerminalSettingsContent { /// Default: 45 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub minimum_contrast: Option, + /// Whether to show a badge on the terminal panel icon with the count of open terminals. + /// + /// Default: false + pub show_count_badge: Option, } /// Shell configuration to open the terminal with. diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index e6c4ba58c39968985478067527fd7109a22db3b5..f5398b60fe528153c3a6d146fcf1eb9b105f713f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4820,7 +4820,7 @@ fn panels_page() -> SettingsPage { ] } - fn terminal_panel_section() -> [SettingsPageItem; 2] { + fn terminal_panel_section() -> [SettingsPageItem; 3] { [ SettingsPageItem::SectionHeader("Terminal Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -4836,6 +4836,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Count Badge", + description: "Show a badge on the terminal panel icon with the count of open terminals.", + field: Box::new(SettingField { + json_path: Some("terminal.show_count_badge"), + pick: |settings_content| { + settings_content + .terminal + .as_ref()? + .show_count_badge + .as_ref() + }, + write: |settings_content, value| { + settings_content + .terminal + .get_or_insert_default() + .show_count_badge = value; + }, + }), + metadata: None, + files: USER, + }), ] } @@ -5048,7 +5070,7 @@ fn panels_page() -> SettingsPage { ] } - fn git_panel_section() -> [SettingsPageItem; 13] { + fn git_panel_section() -> [SettingsPageItem; 14] { [ SettingsPageItem::SectionHeader("Git Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5244,6 +5266,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Count Badge", + description: "Whether to show a badge on the git panel icon with the count of uncommitted changes.", + field: Box::new(SettingField { + json_path: Some("git_panel.show_count_badge"), + pick: |settings_content| { + settings_content + .git_panel + .as_ref()? + .show_count_badge + .as_ref() + }, + write: |settings_content, value| { + settings_content + .git_panel + .get_or_insert_default() + .show_count_badge = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Scroll Bar", description: "How and when the scrollbar should be displayed.", @@ -5294,7 +5338,7 @@ fn panels_page() -> SettingsPage { ] } - fn notification_panel_section() -> [SettingsPageItem; 4] { + fn notification_panel_section() -> [SettingsPageItem; 5] { [ SettingsPageItem::SectionHeader("Notification Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5359,6 +5403,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Count Badge", + description: "Show a badge on the notification panel icon with the count of unread notifications.", + field: Box::new(SettingField { + json_path: Some("notification_panel.show_count_badge"), + pick: |settings_content| { + settings_content + .notification_panel + .as_ref()? + .show_count_badge + .as_ref() + }, + write: |settings_content, value| { + settings_content + .notification_panel + .get_or_insert_default() + .show_count_badge = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 45f22319869381ae497e64c2f8e65abed6fe9d69..f24bd5ead6cfd8cb0d4ded66a770a6040d957b72 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -50,6 +50,7 @@ pub struct TerminalSettings { pub minimum_contrast: f32, pub path_hyperlink_regexes: Vec, pub path_hyperlink_timeout_ms: u64, + pub show_count_badge: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -129,6 +130,7 @@ impl settings::Settings for TerminalSettings { }) .collect(), path_hyperlink_timeout_ms: project_content.path_hyperlink_timeout_ms.unwrap(), + show_count_badge: user_content.show_count_badge.unwrap(), } } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 93b9e651191e791da8bbda35600c3db001b46d90..b3c1f0bf1754d9b0d814bea3dff48b5a7f205613 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1606,6 +1606,9 @@ impl Panel for TerminalPanel { } fn icon_label(&self, _window: &Window, cx: &App) -> Option { + if !TerminalSettings::get_global(cx).show_count_badge { + return None; + } let count = self .center .panes() diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index ef344529cd92efcbf8f57d192c44bbb53befc25e..68b1ff9beb7a8918ee3f5e1857e3cc68e15a3fc1 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -6,6 +6,7 @@ mod callout; mod chip; mod collab; mod context_menu; +mod count_badge; mod data_table; mod diff_stat; mod disclosure; @@ -49,6 +50,7 @@ pub use callout::*; pub use chip::*; pub use collab::*; pub use context_menu::*; +pub use count_badge::*; pub use data_table::*; pub use diff_stat::*; pub use disclosure::*; diff --git a/crates/ui/src/components/count_badge.rs b/crates/ui/src/components/count_badge.rs new file mode 100644 index 0000000000000000000000000000000000000000..c546d69e6d15b12e75ff94424b03b82f371ac94a --- /dev/null +++ b/crates/ui/src/components/count_badge.rs @@ -0,0 +1,93 @@ +use gpui::FontWeight; + +use crate::prelude::*; + +/// A small, pill-shaped badge that displays a numeric count. +/// +/// The count is capped at 99 and displayed as "99+" beyond that. +#[derive(IntoElement, RegisterComponent)] +pub struct CountBadge { + count: usize, +} + +impl CountBadge { + pub fn new(count: usize) -> Self { + Self { count } + } +} + +impl RenderOnce for CountBadge { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let label = if self.count > 99 { + "99+".to_string() + } else { + self.count.to_string() + }; + + let bg = cx + .theme() + .colors() + .editor_background + .blend(cx.theme().status().error.opacity(0.4)); + + h_flex() + .absolute() + .top_0() + .right_0() + .p_px() + .h_3p5() + .min_w_3p5() + .rounded_full() + .justify_center() + .text_center() + .border_1() + .border_color(cx.theme().colors().border) + .bg(bg) + .shadow_sm() + .child( + Label::new(label) + .size(LabelSize::Custom(rems_from_px(9.))) + .weight(FontWeight::MEDIUM), + ) + } +} + +impl Component for CountBadge { + fn scope() -> ComponentScope { + ComponentScope::Status + } + + fn description() -> Option<&'static str> { + Some("A small, pill-shaped badge that displays a numeric count.") + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + div() + .relative() + .size_8() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + }; + + Some( + v_flex() + .gap_6() + .child(example_group_with_title( + "Count Badge", + vec![ + single_example( + "Basic Count", + container().child(CountBadge::new(3)).into_any_element(), + ), + single_example( + "Capped Count", + container().child(CountBadge::new(150)).into_any_element(), + ), + ], + )) + .into_any_element(), + ) + } +} diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 439c6df5ee45938368895a67834d57df695fde89..44a24f687a49552f707d968e82d19387b74b0ac1 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -12,8 +12,10 @@ use gpui::{ }; use settings::SettingsStore; use std::sync::Arc; -use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex}; -use ui::{prelude::*, right_click_menu}; +use ui::{ + ContextMenu, CountBadge, Divider, DividerColor, IconButton, Tooltip, prelude::*, + right_click_menu, +}; use util::ResultExt as _; pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.); @@ -940,6 +942,7 @@ impl Render for PanelButtons { }; let focus_handle = dock.focus_handle(cx); + let icon_label = entry.panel.icon_label(window, cx); Some( right_click_menu(name) @@ -973,7 +976,7 @@ impl Render for PanelButtons { .trigger(move |is_active, _window, _cx| { // Include active state in element ID to invalidate the cached // tooltip when panel state changes (e.g., via keyboard shortcut) - IconButton::new((name, is_active_button as u64), icon) + let button = IconButton::new((name, is_active_button as u64), icon) .icon_size(IconSize::Small) .toggle_state(is_active_button) .on_click({ @@ -987,7 +990,15 @@ impl Render for PanelButtons { this.tooltip(move |_window, cx| { Tooltip::for_action(tooltip.clone(), &*action, cx) }) - }) + }); + + div().relative().child(button).when_some( + icon_label + .clone() + .filter(|_| !is_active_button) + .and_then(|label| label.parse::().ok()), + |this, count| this.child(CountBadge::new(count)), + ) }), ) }) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index dc3d076bd6addc911dfdbf0cc736d876acc78484..38271ac77cf05d9545f22084696837121b13f93d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7868,7 +7868,6 @@ impl Render for Workspace { window, cx, )), - BottomDockLayout::RightAligned => div() .flex() .flex_row() @@ -7927,7 +7926,6 @@ impl Render for Workspace { .children(self.render_dock(DockPosition::Bottom, &self.bottom_dock, window, cx)) ), ), - BottomDockLayout::Contained => div() .flex() .flex_row() From fa7182b6bac3954be4c7b74764f0ec538bcae0e0 Mon Sep 17 00:00:00 2001 From: Sebastian Kootz Date: Mon, 16 Mar 2026 16:58:53 +0100 Subject: [PATCH 07/12] gpui: Add `align-self` methods to `Styled` trait (#51652) This PR adds the missing methods for the `align-self` css property. The `align_self` field existed on the `StyleRefinement` struct but was inaccessible using the `styled` trait. Release Notes: - Adds `self_start`, `self_end`, `self_center`, `self_flex_start`, `self_flex_end`, `self_baseline`, and `self_stretch` methods to the `trait Styled` --- crates/gpui/src/styled.rs | 51 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index f83e9103572b9b708ef4b9a8f99bf73244be71a4..bc394271585f1e392353187692b1b25df198d130 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,5 +1,5 @@ use crate::{ - self as gpui, AbsoluteLength, AlignContent, AlignItems, BorderStyle, CursorStyle, + self as gpui, AbsoluteLength, AlignContent, AlignItems, AlignSelf, BorderStyle, CursorStyle, DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle, FontWeight, GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, StyleRefinement, TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, @@ -278,6 +278,55 @@ pub trait Styled: Sized { self } + /// Sets how this specific element is aligned along the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#start) + fn self_start(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Start); + self + } + + /// Sets this element to align against the end of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#end) + fn self_end(mut self) -> Self { + self.style().align_self = Some(AlignSelf::End); + self + } + + /// Sets this element to align against the start of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#start) + fn self_flex_start(mut self) -> Self { + self.style().align_self = Some(AlignSelf::FlexStart); + self + } + + /// Sets this element to align against the end of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#end) + fn self_flex_end(mut self) -> Self { + self.style().align_self = Some(AlignSelf::FlexEnd); + self + } + + /// Sets this element to align along the center of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#center) + fn self_center(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Center); + self + } + + /// Sets this element to align along the baseline of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#baseline) + fn self_baseline(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Baseline); + self + } + + /// Sets this element to stretch to fill the available space along the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#stretch) + fn self_stretch(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Stretch); + self + } + /// Sets the element to justify flex items against the start of the container's main axis. /// [Docs](https://tailwindcss.com/docs/justify-content#start) fn justify_start(mut self) -> Self { From 7b35337b7d1f053078d6ce7c638111d9013ad637 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 16 Mar 2026 17:05:55 +0100 Subject: [PATCH 08/12] glsl: Highlight null as builtin constant (#51680) Mostly just a test to check whether everything works properly now. Release Notes: - N/A --- extensions/glsl/languages/glsl/highlights.scm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/glsl/languages/glsl/highlights.scm b/extensions/glsl/languages/glsl/highlights.scm index 9f0754b61ed2f8a596e186063224499f2afd1188..0509d0f5ef00977a8f809baa4684a09628dd0172 100644 --- a/extensions/glsl/languages/glsl/highlights.scm +++ b/extensions/glsl/languages/glsl/highlights.scm @@ -57,7 +57,7 @@ (system_lib_string) ] @string -(null) @constant +(null) @constant.builtin [ (number_literal) From 662f2c7857ac9e771a96af05bc53cdfc9a521649 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 16 Mar 2026 09:10:01 -0700 Subject: [PATCH 09/12] Update BYOK to 1m context windows (#51625) Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Updated our BYOK integration to support the new 1M context windows for Opus and Sonnet. --- crates/anthropic/src/anthropic.rs | 104 ++++++++---------------------- 1 file changed, 28 insertions(+), 76 deletions(-) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index a6509c81fa1ecabac32ff9e8bb0fafdddd9e7414..39ad14390a13b95e94029b9841b99facda3716ba 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -78,23 +78,20 @@ pub enum Model { alias = "claude-opus-4-5-thinking-latest" )] ClaudeOpus4_5Thinking, - #[serde(rename = "claude-opus-4-6", alias = "claude-opus-4-6-latest")] - ClaudeOpus4_6, - #[serde( - rename = "claude-opus-4-6-thinking", - alias = "claude-opus-4-6-thinking-latest" - )] - ClaudeOpus4_6Thinking, #[serde( - rename = "claude-opus-4-6-1m-context", + rename = "claude-opus-4-6", + alias = "claude-opus-4-6-latest", + alias = "claude-opus-4-6-1m-context", alias = "claude-opus-4-6-1m-context-latest" )] - ClaudeOpus4_6_1mContext, + ClaudeOpus4_6, #[serde( - rename = "claude-opus-4-6-1m-context-thinking", + rename = "claude-opus-4-6-thinking", + alias = "claude-opus-4-6-thinking-latest", + alias = "claude-opus-4-6-1m-context-thinking", alias = "claude-opus-4-6-1m-context-thinking-latest" )] - ClaudeOpus4_6_1mContextThinking, + ClaudeOpus4_6Thinking, #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")] ClaudeSonnet4, #[serde( @@ -120,23 +117,20 @@ pub enum Model { )] ClaudeSonnet4_5_1mContextThinking, #[default] - #[serde(rename = "claude-sonnet-4-6", alias = "claude-sonnet-4-6-latest")] - ClaudeSonnet4_6, #[serde( - rename = "claude-sonnet-4-6-thinking", - alias = "claude-sonnet-4-6-thinking-latest" - )] - ClaudeSonnet4_6Thinking, - #[serde( - rename = "claude-sonnet-4-6-1m-context", + rename = "claude-sonnet-4-6", + alias = "claude-sonnet-4-6-latest", + alias = "claude-sonnet-4-6-1m-context", alias = "claude-sonnet-4-6-1m-context-latest" )] - ClaudeSonnet4_6_1mContext, + ClaudeSonnet4_6, #[serde( - rename = "claude-sonnet-4-6-1m-context-thinking", + rename = "claude-sonnet-4-6-thinking", + alias = "claude-sonnet-4-6-thinking-latest", + alias = "claude-sonnet-4-6-1m-context-thinking", alias = "claude-sonnet-4-6-1m-context-thinking-latest" )] - ClaudeSonnet4_6_1mContextThinking, + ClaudeSonnet4_6Thinking, #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] ClaudeHaiku4_5, #[serde( @@ -172,11 +166,11 @@ impl Model { pub fn from_id(id: &str) -> Result { if id.starts_with("claude-opus-4-6-1m-context-thinking") { - return Ok(Self::ClaudeOpus4_6_1mContextThinking); + return Ok(Self::ClaudeOpus4_6Thinking); } if id.starts_with("claude-opus-4-6-1m-context") { - return Ok(Self::ClaudeOpus4_6_1mContext); + return Ok(Self::ClaudeOpus4_6); } if id.starts_with("claude-opus-4-6-thinking") { @@ -212,11 +206,11 @@ impl Model { } if id.starts_with("claude-sonnet-4-6-1m-context-thinking") { - return Ok(Self::ClaudeSonnet4_6_1mContextThinking); + return Ok(Self::ClaudeSonnet4_6Thinking); } if id.starts_with("claude-sonnet-4-6-1m-context") { - return Ok(Self::ClaudeSonnet4_6_1mContext); + return Ok(Self::ClaudeSonnet4_6); } if id.starts_with("claude-sonnet-4-6-thinking") { @@ -276,8 +270,6 @@ impl Model { Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-thinking-latest", Self::ClaudeOpus4_6 => "claude-opus-4-6-latest", Self::ClaudeOpus4_6Thinking => "claude-opus-4-6-thinking-latest", - Self::ClaudeOpus4_6_1mContext => "claude-opus-4-6-1m-context-latest", - Self::ClaudeOpus4_6_1mContextThinking => "claude-opus-4-6-1m-context-thinking-latest", Self::ClaudeSonnet4 => "claude-sonnet-4-latest", Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest", Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest", @@ -288,10 +280,6 @@ impl Model { } Self::ClaudeSonnet4_6 => "claude-sonnet-4-6-latest", Self::ClaudeSonnet4_6Thinking => "claude-sonnet-4-6-thinking-latest", - Self::ClaudeSonnet4_6_1mContext => "claude-sonnet-4-6-1m-context-latest", - Self::ClaudeSonnet4_6_1mContextThinking => { - "claude-sonnet-4-6-1m-context-thinking-latest" - } Self::ClaudeHaiku4_5 => "claude-haiku-4-5-latest", Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-thinking-latest", Self::Claude3Haiku => "claude-3-haiku-20240307", @@ -305,19 +293,13 @@ impl Model { Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514", Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-20250805", Self::ClaudeOpus4_5 | Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-20251101", - Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking => "claude-opus-4-6", + Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking => "claude-opus-4-6", Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514", Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking | Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_5_1mContextThinking => "claude-sonnet-4-5-20250929", - Self::ClaudeSonnet4_6 - | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking => "claude-sonnet-4-6", + Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking => "claude-sonnet-4-6", Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-20251001", Self::Claude3Haiku => "claude-3-haiku-20240307", Self::Custom { name, .. } => name, @@ -334,8 +316,6 @@ impl Model { Self::ClaudeOpus4_5Thinking => "Claude Opus 4.5 Thinking", Self::ClaudeOpus4_6 => "Claude Opus 4.6", Self::ClaudeOpus4_6Thinking => "Claude Opus 4.6 Thinking", - Self::ClaudeOpus4_6_1mContext => "Claude Opus 4.6 (1M context)", - Self::ClaudeOpus4_6_1mContextThinking => "Claude Opus 4.6 Thinking (1M context)", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking", Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", @@ -344,8 +324,6 @@ impl Model { Self::ClaudeSonnet4_5_1mContextThinking => "Claude Sonnet 4.5 Thinking (1M context)", Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", Self::ClaudeSonnet4_6Thinking => "Claude Sonnet 4.6 Thinking", - Self::ClaudeSonnet4_6_1mContext => "Claude Sonnet 4.6 (1M context)", - Self::ClaudeSonnet4_6_1mContextThinking => "Claude Sonnet 4.6 Thinking (1M context)", Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", Self::ClaudeHaiku4_5Thinking => "Claude Haiku 4.5 Thinking", Self::Claude3Haiku => "Claude 3 Haiku", @@ -365,8 +343,6 @@ impl Model { | Self::ClaudeOpus4_5Thinking | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -375,8 +351,6 @@ impl Model { | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking | Self::Claude3Haiku => Some(AnthropicModelCacheConfiguration { @@ -399,23 +373,19 @@ impl Model { | Self::ClaudeOpus4_1Thinking | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_5Thinking - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking - | Self::ClaudeSonnet4_6 - | Self::ClaudeSonnet4_6Thinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking | Self::Claude3Haiku => 200_000, - Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking + Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_6Thinking | Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_5_1mContextThinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking => 1_000_000, + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_6Thinking => 1_000_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -436,14 +406,9 @@ impl Model { | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => 64_000, - Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking => 128_000, + Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking => 128_000, Self::Claude3Haiku => 4_096, Self::Custom { max_output_tokens, .. @@ -461,8 +426,6 @@ impl Model { | Self::ClaudeOpus4_5Thinking | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -471,8 +434,6 @@ impl Model { | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking | Self::Claude3Haiku => 1.0, @@ -489,24 +450,20 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6_1mContext | Self::ClaudeSonnet4 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_6 - | Self::ClaudeSonnet4_6_1mContext | Self::ClaudeHaiku4_5 | Self::Claude3Haiku => AnthropicModelMode::Default, Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking | Self::ClaudeOpus4_5Thinking | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContextThinking | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5Thinking | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5Thinking => AnthropicModelMode::Thinking { budget_tokens: Some(4_096), }, @@ -518,12 +475,7 @@ impl Model { let mut headers = vec![]; match self { - Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking - | Self::ClaudeSonnet4_5_1mContext - | Self::ClaudeSonnet4_5_1mContextThinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking => { + Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_5_1mContextThinking => { headers.push(CONTEXT_1M_BETA_HEADER.to_string()); } Self::Custom { From 8d79dd76881b28b27ab124eca54bf96043e39b67 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:17:56 +0000 Subject: [PATCH 10/12] glsl: Bump to v0.2.2 (#51682) This PR bumps the version of the GLSL extension to v0.2.2. Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 +- extensions/glsl/Cargo.toml | 2 +- extensions/glsl/extension.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c69b903c10345a4a78b4cab813baf8c899b7aad..007fcac22c93a5e86a034041603faf3c24dea4c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22123,7 +22123,7 @@ dependencies = [ [[package]] name = "zed_glsl" -version = "0.2.1" +version = "0.2.2" dependencies = [ "zed_extension_api 0.1.0", ] diff --git a/extensions/glsl/Cargo.toml b/extensions/glsl/Cargo.toml index 902a6f3aafcd123603c93ad52ee0d988019e00cf..5d7b6ce941c14f68410ac33f825d0ee0b645d6b5 100644 --- a/extensions/glsl/Cargo.toml +++ b/extensions/glsl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_glsl" -version = "0.2.1" +version = "0.2.2" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/glsl/extension.toml b/extensions/glsl/extension.toml index df41e9c204e920dc5802d6a33fa9c5b2ae16270b..f866091b84674780e859407ebd893641a3a159ce 100644 --- a/extensions/glsl/extension.toml +++ b/extensions/glsl/extension.toml @@ -1,7 +1,7 @@ id = "glsl" name = "GLSL" description = "GLSL support." -version = "0.2.1" +version = "0.2.2" schema_version = 1 authors = ["Mikayla Maki "] repository = "https://github.com/zed-industries/zed" From 6a9a7c00141d20d26b115850bf344f12aa5ee714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Mon, 16 Mar 2026 17:20:31 +0100 Subject: [PATCH 11/12] Fix echo canceller not working (#51673) Release Notes: - Fixed: echo's on experimental audio pipeline Some more context: - `EchoCanceller` was not implemented on the output side - removes the planned migration to a different sample rate (44100) and channel count (1) - de-duplicate the interleaved `#cfg[]`'s and centralized them in `echo_canceller.rs` --- crates/audio/src/audio.rs | 437 +----------------- crates/audio/src/audio_pipeline.rs | 355 ++++++++++++++ .../src/audio_pipeline/echo_canceller.rs | 54 +++ .../audio/src/{ => audio_pipeline}/replays.rs | 3 +- .../src/{ => audio_pipeline}/rodio_ext.rs | 0 .../src/livekit_client/playback.rs | 19 +- .../src/livekit_client/playback/source.rs | 5 +- .../src/pages/audio_test_window.rs | 2 +- 8 files changed, 438 insertions(+), 437 deletions(-) create mode 100644 crates/audio/src/audio_pipeline.rs create mode 100644 crates/audio/src/audio_pipeline/echo_canceller.rs rename crates/audio/src/{ => audio_pipeline}/replays.rs (97%) rename crates/audio/src/{ => audio_pipeline}/rodio_ext.rs (100%) diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 2165cf39136a1ed7268fbf6ea670d825b2b50bcc..650285aa654ac02ae03f41d0af66b33f086a106e 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -1,77 +1,22 @@ -use anyhow::{Context as _, Result}; -use collections::HashMap; -use cpal::{ - DeviceDescription, DeviceId, default_host, - traits::{DeviceTrait, HostTrait}, -}; -use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global}; +use std::time::Duration; -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -mod non_windows_and_freebsd_deps { - pub(super) use cpal::Sample; - pub(super) use libwebrtc::native::apm; - pub(super) use parking_lot::Mutex; - pub(super) use rodio::source::LimitSettings; - pub(super) use std::sync::Arc; -} - -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -use non_windows_and_freebsd_deps::*; +use rodio::{ChannelCount, SampleRate, nz}; -use rodio::{ - Decoder, DeviceSinkBuilder, MixerDeviceSink, Source, - mixer::Mixer, - nz, - source::{AutomaticGainControlSettings, Buffered}, -}; -use settings::Settings; -use std::{io::Cursor, num::NonZero, path::PathBuf, sync::atomic::Ordering, time::Duration}; -use util::ResultExt; +pub const REPLAY_DURATION: Duration = Duration::from_secs(30); +pub const SAMPLE_RATE: SampleRate = nz!(48000); +pub const CHANNEL_COUNT: ChannelCount = nz!(2); mod audio_settings; -mod replays; -mod rodio_ext; pub use audio_settings::AudioSettings; -pub use rodio_ext::RodioExt; -use crate::audio_settings::LIVE_SETTINGS; - -// We are migrating to 16kHz sample rate from 48kHz. In the future -// once we are reasonably sure most users have upgraded we will -// remove the LEGACY parameters. -// -// We migrate to 16kHz because it is sufficient for speech and required -// by the denoiser and future Speech to Text layers. -pub const SAMPLE_RATE: NonZero = nz!(16000); -pub const CHANNEL_COUNT: NonZero = nz!(1); -pub const BUFFER_SIZE: usize = // echo canceller and livekit want 10ms of audio - (SAMPLE_RATE.get() as usize / 100) * CHANNEL_COUNT.get() as usize; - -pub const LEGACY_SAMPLE_RATE: NonZero = nz!(48000); -pub const LEGACY_CHANNEL_COUNT: NonZero = nz!(2); - -pub const REPLAY_DURATION: Duration = Duration::from_secs(30); - -pub fn init(cx: &mut App) { - LIVE_SETTINGS.initialize(cx); -} - -// TODO(jk): this is currently cached only once - we should observe and react instead -pub fn ensure_devices_initialized(cx: &mut App) { - if cx.has_global::() { - return; - } - cx.default_global::(); - let task = cx - .background_executor() - .spawn(async move { get_available_audio_devices() }); - cx.spawn(async move |cx: &mut AsyncApp| { - let devices = task.await; - cx.update(|cx| cx.set_global(AvailableAudioDevices(devices))); - cx.refresh(); - }) - .detach(); -} +mod audio_pipeline; +pub use audio_pipeline::{Audio, VoipParts}; +pub use audio_pipeline::{AudioDeviceInfo, AvailableAudioDevices}; +pub use audio_pipeline::{ensure_devices_initialized, resolve_device}; +// TODO(audio) replace with input test functionality in the audio crate +pub use audio_pipeline::RodioExt; +pub use audio_pipeline::init; +pub use audio_pipeline::{open_input_stream, open_test_output}; #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)] pub enum Sound { @@ -99,359 +44,3 @@ impl Sound { } } } - -pub struct Audio { - output_handle: Option, - #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] - pub echo_canceller: Arc>, - source_cache: HashMap>>>>, - replays: replays::Replays, -} - -impl Default for Audio { - fn default() -> Self { - Self { - output_handle: Default::default(), - #[cfg(not(any( - all(target_os = "windows", target_env = "gnu"), - target_os = "freebsd" - )))] - echo_canceller: Arc::new(Mutex::new(apm::AudioProcessingModule::new( - true, false, false, false, - ))), - source_cache: Default::default(), - replays: Default::default(), - } - } -} - -impl Global for Audio {} - -impl Audio { - fn ensure_output_exists(&mut self, output_audio_device: Option) -> Result<&Mixer> { - #[cfg(debug_assertions)] - log::warn!( - "Audio does not sound correct without optimizations. Use a release build to debug audio issues" - ); - - if self.output_handle.is_none() { - let output_handle = open_output_stream(output_audio_device)?; - - // The webrtc apm is not yet compiling for windows & freebsd - #[cfg(not(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - )))] - let echo_canceller = Arc::clone(&self.echo_canceller); - - #[cfg(not(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - )))] - { - let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE) - .inspect_buffer::(move |buffer| { - let mut buf: [i16; _] = buffer.map(|s| s.to_sample()); - echo_canceller - .lock() - .process_reverse_stream( - &mut buf, - SAMPLE_RATE.get() as i32, - CHANNEL_COUNT.get().into(), - ) - .expect("Audio input and output threads should not panic"); - }); - output_handle.mixer().add(source); - } - - #[cfg(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - ))] - { - let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE); - output_handle.mixer().add(source); - } - - self.output_handle = Some(output_handle); - } - - Ok(self - .output_handle - .as_ref() - .map(|h| h.mixer()) - .expect("we only get here if opening the outputstream succeeded")) - } - - pub fn save_replays( - &self, - executor: BackgroundExecutor, - ) -> gpui::Task> { - self.replays.replays_to_tar(executor) - } - - #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] - pub fn open_microphone(voip_parts: VoipParts) -> anyhow::Result { - let stream = open_input_stream(voip_parts.input_audio_device)?; - let stream = stream - .possibly_disconnected_channels_to_mono() - .constant_samplerate(SAMPLE_RATE) - .limit(LimitSettings::live_performance()) - .process_buffer::(move |buffer| { - let mut int_buffer: [i16; _] = buffer.map(|s| s.to_sample()); - if voip_parts - .echo_canceller - .lock() - .process_stream( - &mut int_buffer, - SAMPLE_RATE.get() as i32, - CHANNEL_COUNT.get() as i32, - ) - .context("livekit audio processor error") - .log_err() - .is_some() - { - for (sample, processed) in buffer.iter_mut().zip(&int_buffer) { - *sample = (*processed).to_sample(); - } - } - }) - .denoise() - .context("Could not set up denoiser")? - .automatic_gain_control(AutomaticGainControlSettings { - target_level: 0.90, - attack_time: Duration::from_secs(1), - release_time: Duration::from_secs(0), - absolute_max_gain: 5.0, - }) - .periodic_access(Duration::from_millis(100), move |agc_source| { - agc_source - .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed)); - let denoise = agc_source.inner_mut(); - denoise.set_enabled(LIVE_SETTINGS.denoise.load(Ordering::Relaxed)); - }); - - let stream = if voip_parts.legacy_audio_compatible { - stream.constant_params(LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) - } else { - stream.constant_params(CHANNEL_COUNT, SAMPLE_RATE) - }; - - let (replay, stream) = stream.replayable(REPLAY_DURATION)?; - voip_parts - .replays - .add_voip_stream("local microphone".to_string(), replay); - - Ok(stream) - } - - pub fn play_voip_stream( - source: impl rodio::Source + Send + 'static, - speaker_name: String, - is_staff: bool, - cx: &mut App, - ) -> anyhow::Result<()> { - let (replay_source, source) = source - .constant_params(CHANNEL_COUNT, SAMPLE_RATE) - .automatic_gain_control(AutomaticGainControlSettings { - target_level: 0.90, - attack_time: Duration::from_secs(1), - release_time: Duration::from_secs(0), - absolute_max_gain: 5.0, - }) - .periodic_access(Duration::from_millis(100), move |agc_source| { - agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed)); - }) - .replayable(REPLAY_DURATION) - .expect("REPLAY_DURATION is longer than 100ms"); - let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); - - cx.update_default_global(|this: &mut Self, _cx| { - let output_mixer = this - .ensure_output_exists(output_audio_device) - .context("Could not get output mixer")?; - output_mixer.add(source); - if is_staff { - this.replays.add_voip_stream(speaker_name, replay_source); - } - Ok(()) - }) - } - - pub fn play_sound(sound: Sound, cx: &mut App) { - let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); - cx.update_default_global(|this: &mut Self, cx| { - let source = this.sound_source(sound, cx).log_err()?; - let output_mixer = this - .ensure_output_exists(output_audio_device) - .context("Could not get output mixer") - .log_err()?; - - output_mixer.add(source); - Some(()) - }); - } - - pub fn end_call(cx: &mut App) { - cx.update_default_global(|this: &mut Self, _cx| { - this.output_handle.take(); - }); - } - - fn sound_source(&mut self, sound: Sound, cx: &App) -> Result> { - if let Some(wav) = self.source_cache.get(&sound) { - return Ok(wav.clone()); - } - - let path = format!("sounds/{}.wav", sound.file()); - let bytes = cx - .asset_source() - .load(&path)? - .map(anyhow::Ok) - .with_context(|| format!("No asset available for path {path}"))?? - .into_owned(); - let cursor = Cursor::new(bytes); - let source = Decoder::new(cursor)?.buffered(); - - self.source_cache.insert(sound, source.clone()); - - Ok(source) - } -} - -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -pub struct VoipParts { - echo_canceller: Arc>, - replays: replays::Replays, - legacy_audio_compatible: bool, - input_audio_device: Option, -} - -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -impl VoipParts { - pub fn new(cx: &AsyncApp) -> anyhow::Result { - let (apm, replays) = cx.read_default_global::(|audio, _| { - (Arc::clone(&audio.echo_canceller), audio.replays.clone()) - }); - let legacy_audio_compatible = - AudioSettings::try_read_global(cx, |settings| settings.legacy_audio_compatible) - .unwrap_or(true); - let input_audio_device = - AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) - .flatten(); - - Ok(Self { - legacy_audio_compatible, - echo_canceller: apm, - replays, - input_audio_device, - }) - } -} - -pub fn open_input_stream( - device_id: Option, -) -> anyhow::Result { - let builder = rodio::microphone::MicrophoneBuilder::new(); - let builder = if let Some(id) = device_id { - // TODO(jk): upstream patch - // if let Some(input_device) = default_host().device_by_id(id) { - // builder.device(input_device); - // } - let mut found = None; - for input in rodio::microphone::available_inputs()? { - if input.clone().into_inner().id()? == id { - found = Some(builder.device(input)); - break; - } - } - found.unwrap_or_else(|| builder.default_device())? - } else { - builder.default_device()? - }; - let stream = builder - .default_config()? - .prefer_sample_rates([ - SAMPLE_RATE, - SAMPLE_RATE.saturating_mul(rodio::nz!(2)), - SAMPLE_RATE.saturating_mul(rodio::nz!(3)), - SAMPLE_RATE.saturating_mul(rodio::nz!(4)), - ]) - .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)]) - .prefer_buffer_sizes(512..) - .open_stream()?; - log::info!("Opened microphone: {:?}", stream.config()); - Ok(stream) -} - -pub fn resolve_device(device_id: Option<&DeviceId>, input: bool) -> anyhow::Result { - if let Some(id) = device_id { - if let Some(device) = default_host().device_by_id(id) { - return Ok(device); - } - log::warn!("Selected audio device not found, falling back to default"); - } - if input { - default_host() - .default_input_device() - .context("no audio input device available") - } else { - default_host() - .default_output_device() - .context("no audio output device available") - } -} - -pub fn open_output_stream(device_id: Option) -> anyhow::Result { - let device = resolve_device(device_id.as_ref(), false)?; - let mut output_handle = DeviceSinkBuilder::from_device(device)? - .open_stream() - .context("Could not open output stream")?; - output_handle.log_on_drop(false); - log::info!("Output stream: {:?}", output_handle); - Ok(output_handle) -} - -#[derive(Clone, Debug)] -pub struct AudioDeviceInfo { - pub id: DeviceId, - pub desc: DeviceDescription, -} - -impl AudioDeviceInfo { - pub fn matches_input(&self, is_input: bool) -> bool { - if is_input { - self.desc.supports_input() - } else { - self.desc.supports_output() - } - } - - pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool { - &self.id == id && self.matches_input(is_input) - } -} - -impl std::fmt::Display for AudioDeviceInfo { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ({})", self.desc.name(), self.id) - } -} - -fn get_available_audio_devices() -> Vec { - let Some(devices) = default_host().devices().ok() else { - return Vec::new(); - }; - devices - .filter_map(|device| { - let id = device.id().ok()?; - let desc = device.description().ok()?; - Some(AudioDeviceInfo { id, desc }) - }) - .collect() -} - -#[derive(Default, Clone, Debug)] -pub struct AvailableAudioDevices(pub Vec); - -impl Global for AvailableAudioDevices {} diff --git a/crates/audio/src/audio_pipeline.rs b/crates/audio/src/audio_pipeline.rs new file mode 100644 index 0000000000000000000000000000000000000000..3d2a6ae32c381b1cab590946c35fbb68325af5db --- /dev/null +++ b/crates/audio/src/audio_pipeline.rs @@ -0,0 +1,355 @@ +use anyhow::{Context as _, Result}; +use collections::HashMap; +use cpal::{ + DeviceDescription, DeviceId, default_host, + traits::{DeviceTrait, HostTrait}, +}; +use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global}; + +pub(super) use cpal::Sample; +pub(super) use rodio::source::LimitSettings; + +use rodio::{ + Decoder, DeviceSinkBuilder, MixerDeviceSink, Source, + mixer::Mixer, + source::{AutomaticGainControlSettings, Buffered}, +}; +use settings::Settings; +use std::{io::Cursor, path::PathBuf, sync::atomic::Ordering, time::Duration}; +use util::ResultExt; + +mod echo_canceller; +use echo_canceller::EchoCanceller; +mod replays; +mod rodio_ext; +pub use crate::audio_settings::AudioSettings; +pub use rodio_ext::RodioExt; + +use crate::audio_settings::LIVE_SETTINGS; + +use crate::Sound; + +use super::{CHANNEL_COUNT, SAMPLE_RATE}; +pub const BUFFER_SIZE: usize = // echo canceller and livekit want 10ms of audio + (SAMPLE_RATE.get() as usize / 100) * CHANNEL_COUNT.get() as usize; + +pub fn init(cx: &mut App) { + LIVE_SETTINGS.initialize(cx); +} + +// TODO(jk): this is currently cached only once - we should observe and react instead +pub fn ensure_devices_initialized(cx: &mut App) { + if cx.has_global::() { + return; + } + cx.default_global::(); + let task = cx + .background_executor() + .spawn(async move { get_available_audio_devices() }); + cx.spawn(async move |cx: &mut AsyncApp| { + let devices = task.await; + cx.update(|cx| cx.set_global(AvailableAudioDevices(devices))); + cx.refresh(); + }) + .detach(); +} + +#[derive(Default)] +pub struct Audio { + output: Option<(MixerDeviceSink, Mixer)>, + pub echo_canceller: EchoCanceller, + source_cache: HashMap>>>>, + replays: replays::Replays, +} + +impl Global for Audio {} + +impl Audio { + fn ensure_output_exists(&mut self, output_audio_device: Option) -> Result<&Mixer> { + #[cfg(debug_assertions)] + log::warn!( + "Audio does not sound correct without optimizations. Use a release build to debug audio issues" + ); + + if self.output.is_none() { + let (output_handle, output_mixer) = + open_output_stream(output_audio_device, self.echo_canceller.clone())?; + self.output = Some((output_handle, output_mixer)); + } + + Ok(self + .output + .as_ref() + .map(|(_, mixer)| mixer) + .expect("we only get here if opening the outputstream succeeded")) + } + + pub fn save_replays( + &self, + executor: BackgroundExecutor, + ) -> gpui::Task> { + self.replays.replays_to_tar(executor) + } + + pub fn open_microphone(mut voip_parts: VoipParts) -> anyhow::Result { + let stream = open_input_stream(voip_parts.input_audio_device)?; + let stream = stream + .possibly_disconnected_channels_to_mono() + .constant_params(CHANNEL_COUNT, SAMPLE_RATE) + .process_buffer::(move |buffer| { + let mut int_buffer: [i16; _] = buffer.map(|s| s.to_sample()); + if voip_parts + .echo_canceller + .process_stream(&mut int_buffer) + .log_err() + .is_some() + { + for (sample, processed) in buffer.iter_mut().zip(&int_buffer) { + *sample = (*processed).to_sample(); + } + } + }) + .limit(LimitSettings::live_performance()) + .automatic_gain_control(AutomaticGainControlSettings { + target_level: 0.90, + attack_time: Duration::from_secs(1), + release_time: Duration::from_secs(0), + absolute_max_gain: 5.0, + }) + .periodic_access(Duration::from_millis(100), move |agc_source| { + agc_source + .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed)); + let _ = LIVE_SETTINGS.denoise; // TODO(audio: re-introduce de-noising + }); + + let (replay, stream) = stream.replayable(crate::REPLAY_DURATION)?; + voip_parts + .replays + .add_voip_stream("local microphone".to_string(), replay); + + Ok(stream) + } + + pub fn play_voip_stream( + source: impl rodio::Source + Send + 'static, + speaker_name: String, + is_staff: bool, + cx: &mut App, + ) -> anyhow::Result<()> { + let (replay_source, source) = source + .automatic_gain_control(AutomaticGainControlSettings { + target_level: 0.90, + attack_time: Duration::from_secs(1), + release_time: Duration::from_secs(0), + absolute_max_gain: 5.0, + }) + .periodic_access(Duration::from_millis(100), move |agc_source| { + agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed)); + }) + .replayable(crate::REPLAY_DURATION) + .expect("REPLAY_DURATION is longer than 100ms"); + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); + + cx.update_default_global(|this: &mut Self, _cx| { + let output_mixer = this + .ensure_output_exists(output_audio_device) + .context("Could not get output mixer")?; + output_mixer.add(source); + if is_staff { + this.replays.add_voip_stream(speaker_name, replay_source); + } + Ok(()) + }) + } + + pub fn play_sound(sound: Sound, cx: &mut App) { + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); + cx.update_default_global(|this: &mut Self, cx| { + let source = this.sound_source(sound, cx).log_err()?; + let output_mixer = this + .ensure_output_exists(output_audio_device) + .context("Could not get output mixer") + .log_err()?; + + output_mixer.add(source); + Some(()) + }); + } + + pub fn end_call(cx: &mut App) { + cx.update_default_global(|this: &mut Self, _cx| { + this.output.take(); + }); + } + + fn sound_source(&mut self, sound: Sound, cx: &App) -> Result> { + if let Some(wav) = self.source_cache.get(&sound) { + return Ok(wav.clone()); + } + + let path = format!("sounds/{}.wav", sound.file()); + let bytes = cx + .asset_source() + .load(&path)? + .map(anyhow::Ok) + .with_context(|| format!("No asset available for path {path}"))?? + .into_owned(); + let cursor = Cursor::new(bytes); + let source = Decoder::new(cursor)?.buffered(); + + self.source_cache.insert(sound, source.clone()); + + Ok(source) + } +} + +pub struct VoipParts { + echo_canceller: EchoCanceller, + replays: replays::Replays, + input_audio_device: Option, +} + +impl VoipParts { + pub fn new(cx: &AsyncApp) -> anyhow::Result { + let (apm, replays) = cx.read_default_global::(|audio, _| { + (audio.echo_canceller.clone(), audio.replays.clone()) + }); + let input_audio_device = + AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) + .flatten(); + + Ok(Self { + echo_canceller: apm, + replays, + input_audio_device, + }) + } +} + +pub fn open_input_stream( + device_id: Option, +) -> anyhow::Result { + let builder = rodio::microphone::MicrophoneBuilder::new(); + let builder = if let Some(id) = device_id { + // TODO(jk): upstream patch + // if let Some(input_device) = default_host().device_by_id(id) { + // builder.device(input_device); + // } + let mut found = None; + for input in rodio::microphone::available_inputs()? { + if input.clone().into_inner().id()? == id { + found = Some(builder.device(input)); + break; + } + } + found.unwrap_or_else(|| builder.default_device())? + } else { + builder.default_device()? + }; + let stream = builder + .default_config()? + .prefer_sample_rates([ + SAMPLE_RATE, + SAMPLE_RATE.saturating_mul(rodio::nz!(2)), + SAMPLE_RATE.saturating_mul(rodio::nz!(3)), + SAMPLE_RATE.saturating_mul(rodio::nz!(4)), + ]) + .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)]) + .prefer_buffer_sizes(512..) + .open_stream()?; + log::info!("Opened microphone: {:?}", stream.config()); + Ok(stream) +} + +pub fn resolve_device(device_id: Option<&DeviceId>, input: bool) -> anyhow::Result { + if let Some(id) = device_id { + if let Some(device) = default_host().device_by_id(id) { + return Ok(device); + } + log::warn!("Selected audio device not found, falling back to default"); + } + if input { + default_host() + .default_input_device() + .context("no audio input device available") + } else { + default_host() + .default_output_device() + .context("no audio output device available") + } +} + +pub fn open_test_output(device_id: Option) -> anyhow::Result { + let device = resolve_device(device_id.as_ref(), false)?; + DeviceSinkBuilder::from_device(device)? + .open_stream() + .context("Could not open output stream") +} + +pub fn open_output_stream( + device_id: Option, + mut echo_canceller: EchoCanceller, +) -> anyhow::Result<(MixerDeviceSink, Mixer)> { + let device = resolve_device(device_id.as_ref(), false)?; + let mut output_handle = DeviceSinkBuilder::from_device(device)? + .open_stream() + .context("Could not open output stream")?; + output_handle.log_on_drop(false); + log::info!("Output stream: {:?}", output_handle); + + let (output_mixer, source) = rodio::mixer::mixer(CHANNEL_COUNT, SAMPLE_RATE); + // otherwise the mixer ends as it's empty + output_mixer.add(rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE)); + let echo_cancelling_source = source // apply echo cancellation just before output + .inspect_buffer::(move |buffer| { + let mut buf: [i16; _] = buffer.map(|s| s.to_sample()); + echo_canceller.process_reverse_stream(&mut buf) + }); + output_handle.mixer().add(echo_cancelling_source); + + Ok((output_handle, output_mixer)) +} + +#[derive(Clone, Debug)] +pub struct AudioDeviceInfo { + pub id: DeviceId, + pub desc: DeviceDescription, +} + +impl AudioDeviceInfo { + pub fn matches_input(&self, is_input: bool) -> bool { + if is_input { + self.desc.supports_input() + } else { + self.desc.supports_output() + } + } + + pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool { + &self.id == id && self.matches_input(is_input) + } +} + +impl std::fmt::Display for AudioDeviceInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.desc.name(), self.id) + } +} + +fn get_available_audio_devices() -> Vec { + let Some(devices) = default_host().devices().ok() else { + return Vec::new(); + }; + devices + .filter_map(|device| { + let id = device.id().ok()?; + let desc = device.description().ok()?; + Some(AudioDeviceInfo { id, desc }) + }) + .collect() +} + +#[derive(Default, Clone, Debug)] +pub struct AvailableAudioDevices(pub Vec); + +impl Global for AvailableAudioDevices {} diff --git a/crates/audio/src/audio_pipeline/echo_canceller.rs b/crates/audio/src/audio_pipeline/echo_canceller.rs new file mode 100644 index 0000000000000000000000000000000000000000..ec612b1b448bd33871b33638468747b765fc3c1a --- /dev/null +++ b/crates/audio/src/audio_pipeline/echo_canceller.rs @@ -0,0 +1,54 @@ +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +mod real_implementation { + use anyhow::Context; + use libwebrtc::native::apm; + use parking_lot::Mutex; + use std::sync::Arc; + + use crate::{CHANNEL_COUNT, SAMPLE_RATE}; + + #[derive(Clone)] + pub struct EchoCanceller(Arc>); + + impl Default for EchoCanceller { + fn default() -> Self { + Self(Arc::new(Mutex::new(apm::AudioProcessingModule::new( + true, false, false, false, + )))) + } + } + + impl EchoCanceller { + pub fn process_reverse_stream(&mut self, buf: &mut [i16]) { + self.0 + .lock() + .process_reverse_stream(buf, SAMPLE_RATE.get() as i32, CHANNEL_COUNT.get().into()) + .expect("Audio input and output threads should not panic"); + } + + pub fn process_stream(&mut self, buf: &mut [i16]) -> anyhow::Result<()> { + self.0 + .lock() + .process_stream(buf, SAMPLE_RATE.get() as i32, CHANNEL_COUNT.get() as i32) + .context("livekit audio processor error") + } + } +} + +#[cfg(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd"))] +mod fake_implementation { + #[derive(Clone, Default)] + pub struct EchoCanceller; + + impl EchoCanceller { + pub fn process_reverse_stream(&mut self, _buf: &mut [i16]) {} + pub fn process_stream(&mut self, _buf: &mut [i16]) -> anyhow::Result<()> { + Ok(()) + } + } +} + +#[cfg(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd"))] +pub use fake_implementation::EchoCanceller; +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +pub use real_implementation::EchoCanceller; diff --git a/crates/audio/src/replays.rs b/crates/audio/src/audio_pipeline/replays.rs similarity index 97% rename from crates/audio/src/replays.rs rename to crates/audio/src/audio_pipeline/replays.rs index bb21df51e5642bf633d068d544690cb26a239151..3228700b2df5581e862da6ec71787704985386a2 100644 --- a/crates/audio/src/replays.rs +++ b/crates/audio/src/audio_pipeline/replays.rs @@ -8,7 +8,8 @@ use rodio::Source; use smol::fs::File; use std::{io, path::PathBuf, sync::Arc, time::Duration}; -use crate::{REPLAY_DURATION, rodio_ext::Replay}; +use crate::REPLAY_DURATION; +use crate::audio_pipeline::rodio_ext::Replay; #[derive(Default, Clone)] pub(crate) struct Replays(Arc>>); diff --git a/crates/audio/src/rodio_ext.rs b/crates/audio/src/audio_pipeline/rodio_ext.rs similarity index 100% rename from crates/audio/src/rodio_ext.rs rename to crates/audio/src/audio_pipeline/rodio_ext.rs diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index d6fc061321acd8d40a7df0e615bad0b8ecbb1f26..4b3c55109a297c888ac64d5742a1df91163d77e0 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, Result}; -use audio::{AudioSettings, CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; +use audio::{AudioSettings, CHANNEL_COUNT, SAMPLE_RATE}; use cpal::DeviceId; use cpal::traits::{DeviceTrait, StreamTrait as _}; use futures::channel::mpsc::Sender; @@ -99,8 +99,8 @@ impl AudioStack { let next_ssrc = self.next_ssrc.fetch_add(1, Ordering::Relaxed); let source = AudioMixerSource { ssrc: next_ssrc, - sample_rate: LEGACY_SAMPLE_RATE.get(), - num_channels: LEGACY_CHANNEL_COUNT.get() as u32, + sample_rate: SAMPLE_RATE.get(), + num_channels: CHANNEL_COUNT.get() as u32, buffer: Arc::default(), }; self.mixer.lock().add_source(source.clone()); @@ -145,8 +145,8 @@ impl AudioStack { executor, apm, mixer, - LEGACY_SAMPLE_RATE.get(), - LEGACY_CHANNEL_COUNT.get().into(), + SAMPLE_RATE.get(), + CHANNEL_COUNT.get().into(), output_audio_device, ) .await @@ -171,8 +171,9 @@ impl AudioStack { NativeAudioSource::new( // n.b. this struct's options are always ignored, noise cancellation is provided by apm. AudioSourceOptions::default(), - LEGACY_SAMPLE_RATE.get(), - LEGACY_CHANNEL_COUNT.get().into(), + SAMPLE_RATE.get(), // TODO(audio): this was legacy params, + // removed for now for simplicity + CHANNEL_COUNT.get().into(), 10, ) } else { @@ -233,8 +234,8 @@ impl AudioStack { executor, apm, frame_tx, - LEGACY_SAMPLE_RATE.get(), - LEGACY_CHANNEL_COUNT.get().into(), + SAMPLE_RATE.get(), // TODO(audio): was legacy removed for now + CHANNEL_COUNT.get().into(), input_audio_device, ) .await diff --git a/crates/livekit_client/src/livekit_client/playback/source.rs b/crates/livekit_client/src/livekit_client/playback/source.rs index b90c3613f8215481a4a535eb81c665fccae80e5c..2738109ff8fc972e9ab53768fd212d6f5ff5f194 100644 --- a/crates/livekit_client/src/livekit_client/playback/source.rs +++ b/crates/livekit_client/src/livekit_client/playback/source.rs @@ -7,7 +7,7 @@ use rodio::{ ChannelCount, SampleRate, Source, buffer::SamplesBuffer, conversions::SampleTypeConverter, }; -use audio::{CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; +use audio::{CHANNEL_COUNT, SAMPLE_RATE}; fn frame_to_samplesbuffer(frame: AudioFrame) -> SamplesBuffer { let samples = frame.data.iter().copied(); @@ -35,7 +35,8 @@ impl LiveKitStream { legacy: bool, ) -> Self { let (channel_count, sample_rate) = if legacy { - (LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) + // (LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) TODO(audio): do this or remove + (CHANNEL_COUNT, SAMPLE_RATE) } else { (CHANNEL_COUNT, SAMPLE_RATE) }; diff --git a/crates/settings_ui/src/pages/audio_test_window.rs b/crates/settings_ui/src/pages/audio_test_window.rs index 63bd1d14ffb3ad9c7d1b2d176d9de58aa762ec25..d50d017d7abde836fb2945baf2f1434472281005 100644 --- a/crates/settings_ui/src/pages/audio_test_window.rs +++ b/crates/settings_ui/src/pages/audio_test_window.rs @@ -88,7 +88,7 @@ fn start_test_playback( } }; - let Ok(output) = audio::open_output_stream(output_device_id) else { + let Ok(output) = audio::open_test_output(output_device_id) else { log::error!("Could not open output device for audio test"); return; }; From 015225196df8985c4c324d53378e05ab6d3e4daf Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 16 Mar 2026 17:23:02 +0100 Subject: [PATCH 12/12] Store ACP thread metadata (#51657) Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --------- Co-authored-by: cameron Co-authored-by: Ben Brandt --- crates/acp_thread/src/acp_thread.rs | 119 ++- crates/acp_thread/src/connection.rs | 32 +- crates/acp_tools/src/acp_tools.rs | 14 +- crates/agent/src/agent.rs | 64 +- crates/agent/src/db.rs | 7 +- crates/agent/src/native_agent_server.rs | 7 +- crates/agent/src/tests/mod.rs | 16 +- crates/agent/src/thread_store.rs | 64 -- crates/agent_servers/src/acp.rs | 62 +- crates/agent_servers/src/agent_servers.rs | 6 +- crates/agent_servers/src/custom.rs | 116 +-- crates/agent_servers/src/e2e_tests.rs | 9 +- crates/agent_ui/src/agent_configuration.rs | 4 +- crates/agent_ui/src/agent_connection_store.rs | 5 +- crates/agent_ui/src/agent_diff.rs | 18 +- crates/agent_ui/src/agent_panel.rs | 136 +-- crates/agent_ui/src/agent_ui.rs | 35 +- crates/agent_ui/src/connection_view.rs | 354 +++----- .../src/connection_view/thread_view.rs | 32 +- crates/agent_ui/src/entry_view_state.rs | 22 +- crates/agent_ui/src/message_editor.rs | 15 +- crates/agent_ui/src/sidebar.rs | 788 +++++++++--------- crates/agent_ui/src/test_support.rs | 5 +- crates/agent_ui/src/thread_history.rs | 14 +- crates/agent_ui/src/thread_history_view.rs | 2 +- crates/agent_ui/src/thread_metadata_store.rs | 528 ++++++++++++ crates/agent_ui/src/threads_archive_view.rs | 22 +- .../agent_ui/src/ui/acp_onboarding_modal.rs | 4 +- .../src/ui/claude_agent_onboarding_modal.rs | 4 +- crates/eval_cli/src/main.rs | 7 +- crates/project/src/agent_registry_store.rs | 12 +- crates/project/src/agent_server_store.rs | 76 +- crates/project/src/project.rs | 4 +- .../tests/integration/ext_agent_tests.rs | 8 +- .../integration/extension_agent_tests.rs | 18 +- crates/util/src/path_list.rs | 17 +- crates/zed/src/visual_test_runner.rs | 7 +- docs/acp-threads-in-sidebar-plan.md | 580 +++++++++++++ 38 files changed, 2138 insertions(+), 1095 deletions(-) create mode 100644 crates/agent_ui/src/thread_metadata_store.rs create mode 100644 docs/acp-threads-in-sidebar-plan.md diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 99fe83a5c6f74c1989e2b5e2317d7c267d531eef..1a5764eca1b1861aa4c928aa5ede12e18c49e64b 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -31,6 +31,7 @@ use task::{Shell, ShellBuilder}; pub use terminal::*; use text::Bias; use ui::App; +use util::path_list::PathList; use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; use uuid::Uuid; @@ -953,7 +954,7 @@ struct RunningTurn { pub struct AcpThread { session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, parent_session_id: Option, title: SharedString, provisional_title: Option, @@ -1119,7 +1120,7 @@ impl AcpThread { pub fn new( parent_session_id: Option, title: impl Into, - cwd: Option, + work_dirs: Option, connection: Rc, project: Entity, action_log: Entity, @@ -1140,7 +1141,7 @@ impl AcpThread { Self { parent_session_id, - cwd, + work_dirs, action_log, shared_buffers: Default::default(), entries: Default::default(), @@ -1219,8 +1220,8 @@ impl AcpThread { &self.session_id } - pub fn cwd(&self) -> Option<&PathBuf> { - self.cwd.as_ref() + pub fn work_dirs(&self) -> Option<&PathList> { + self.work_dirs.as_ref() } pub fn status(&self) -> ThreadStatus { @@ -2858,7 +2859,7 @@ mod tests { use futures::{channel::mpsc, future::LocalBoxFuture, select}; use gpui::{App, AsyncApp, TestAppContext, WeakEntity}; use indoc::indoc; - use project::{FakeFs, Fs}; + use project::{AgentId, FakeFs, Fs}; use rand::{distr, prelude::*}; use serde_json::json; use settings::SettingsStore; @@ -2871,7 +2872,7 @@ mod tests { sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, time::Duration, }; - use util::path; + use util::{path, path_list::PathList}; fn init_test(cx: &mut TestAppContext) { env_logger::try_init().ok(); @@ -2889,7 +2890,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -2953,7 +2960,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -3041,7 +3054,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project.clone(), Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -3152,7 +3171,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3246,7 +3267,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3327,7 +3350,9 @@ mod tests { .unwrap(); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3368,7 +3393,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3443,7 +3470,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3517,7 +3546,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3565,7 +3596,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3656,7 +3689,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3715,7 +3750,9 @@ mod tests { } })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3888,7 +3925,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3964,7 +4003,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4037,7 +4078,9 @@ mod tests { } })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4158,6 +4201,10 @@ mod tests { } impl AgentConnection for FakeAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("fake") + } + fn telemetry_id(&self) -> SharedString { "fake".into() } @@ -4169,7 +4216,7 @@ mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { let session_id = acp::SessionId::new( @@ -4184,7 +4231,7 @@ mod tests { AcpThread::new( None, "Test", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4283,7 +4330,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4349,7 +4398,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4662,7 +4713,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4736,7 +4789,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4819,7 +4874,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4867,7 +4924,9 @@ mod tests { let set_title_calls = connection.set_title_calls.clone(); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 4f6aaf86bad68f919c2c5de30214b21ff851c3dd..33692c90d7915b52d33764ce99f949ffab84e04e 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -5,17 +5,11 @@ use chrono::{DateTime, Utc}; use collections::IndexMap; use gpui::{Entity, SharedString, Task}; use language_model::LanguageModelProviderId; -use project::Project; +use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; -use std::{ - any::Any, - error::Error, - fmt, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; +use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc, sync::Arc}; use ui::{App, IconName}; +use util::path_list::PathList; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -28,12 +22,14 @@ impl UserMessageId { } pub trait AgentConnection { + fn agent_id(&self) -> AgentId; + fn telemetry_id(&self) -> SharedString; fn new_session( self: Rc, project: Entity, - cwd: &Path, + _work_dirs: PathList, cx: &mut App, ) -> Task>>; @@ -47,7 +43,7 @@ pub trait AgentConnection { self: Rc, _session_id: acp::SessionId, _project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { @@ -78,7 +74,7 @@ pub trait AgentConnection { self: Rc, _session_id: acp::SessionId, _project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { @@ -243,7 +239,7 @@ impl AgentSessionListResponse { #[derive(Debug, Clone, PartialEq)] pub struct AgentSessionInfo { pub session_id: acp::SessionId, - pub cwd: Option, + pub work_dirs: Option, pub title: Option, pub updated_at: Option>, pub created_at: Option>, @@ -254,7 +250,7 @@ impl AgentSessionInfo { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), - cwd: None, + work_dirs: None, title: None, updated_at: None, created_at: None, @@ -609,6 +605,10 @@ mod test_support { } impl AgentConnection for StubAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("stub") + } + fn telemetry_id(&self) -> SharedString { "stub".into() } @@ -627,7 +627,7 @@ mod test_support { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0); @@ -638,7 +638,7 @@ mod test_support { AcpThread::new( None, "Test", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index b5b0e078ae0e41f5c3527265009fac803757ff1a..30d13effcb53395972879ef109a253be0c134ec1 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -14,7 +14,7 @@ use gpui::{ }; use language::LanguageRegistry; use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; -use project::Project; +use project::{AgentId, Project}; use settings::Settings; use theme::ThemeSettings; use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*}; @@ -48,7 +48,7 @@ pub struct AcpConnectionRegistry { } struct ActiveConnection { - server_name: SharedString, + agent_id: AgentId, connection: Weak, } @@ -65,12 +65,12 @@ impl AcpConnectionRegistry { pub fn set_active_connection( &self, - server_name: impl Into, + agent_id: AgentId, connection: &Rc, cx: &mut Context, ) { self.active_connection.replace(Some(ActiveConnection { - server_name: server_name.into(), + agent_id, connection: Rc::downgrade(connection), })); cx.notify(); @@ -87,7 +87,7 @@ struct AcpTools { } struct WatchedConnection { - server_name: SharedString, + agent_id: AgentId, messages: Vec, list_state: ListState, connection: Weak, @@ -144,7 +144,7 @@ impl AcpTools { }); self.watched_connection = Some(WatchedConnection { - server_name: active_connection.server_name.clone(), + agent_id: active_connection.agent_id.clone(), messages: vec![], list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), connection: active_connection.connection.clone(), @@ -483,7 +483,7 @@ impl Item for AcpTools { "ACP: {}", self.watched_connection .as_ref() - .map_or("Disconnected", |connection| &connection.server_name) + .map_or("Disconnected", |connection| connection.agent_id.0.as_ref()) ) .into() } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 2ac341dc997b016f3e723fad99a4a57007510c52..d4062fec85cb458ade372085d23fa42a47e631ed 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -41,7 +41,7 @@ use gpui::{ WeakEntity, }; use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; -use project::{Project, ProjectItem, ProjectPath, Worktree}; +use project::{AgentId, Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, WorktreeContext, @@ -49,9 +49,9 @@ use prompt_store::{ use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, update_settings_file}; use std::any::Any; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use util::ResultExt; use util::path_list::PathList; use util::rel_path::RelPath; @@ -1381,7 +1381,13 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { } } +pub static ZED_AGENT_ID: LazyLock = LazyLock::new(|| AgentId::new("Zed Agent")); + impl acp_thread::AgentConnection for NativeAgentConnection { + fn agent_id(&self) -> AgentId { + ZED_AGENT_ID.clone() + } + fn telemetry_id(&self) -> SharedString { "zed".into() } @@ -1389,10 +1395,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { - log::debug!("Creating new thread for project at: {cwd:?}"); + log::debug!("Creating new thread for project at: {work_dirs:?}"); Task::ready(Ok(self .0 .update(cx, |agent, cx| agent.new_session(project, cx)))) @@ -1406,7 +1412,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { self: Rc, session_id: acp::SessionId, project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { @@ -2079,6 +2085,8 @@ impl TerminalHandle for AcpTerminalHandle { #[cfg(test)] mod internal_tests { + use std::path::Path; + use super::*; use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; use fs::FakeFs; @@ -2111,7 +2119,13 @@ mod internal_tests { // Creating a session registers the project and triggers context building. let connection = NativeAgentConnection(agent.clone()); let _acp_thread = cx - .update(|cx| Rc::new(connection).new_session(project.clone(), Path::new("/"), cx)) + .update(|cx| { + Rc::new(connection).new_session( + project.clone(), + PathList::new(&[Path::new("/")]), + cx, + ) + }) .await .unwrap(); cx.run_until_parked(); @@ -2180,7 +2194,11 @@ mod internal_tests { // Create a thread/session let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2251,7 +2269,11 @@ mod internal_tests { // Create a thread/session let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2343,7 +2365,11 @@ mod internal_tests { let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2450,9 +2476,11 @@ mod internal_tests { // Create a thread and select the thinking model. let acp_thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new("/a"), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2552,9 +2580,11 @@ mod internal_tests { // Create a thread and select the model. let acp_thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new("/a"), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2645,7 +2675,7 @@ mod internal_tests { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 43ab9c3c1826ea7d81fed2c934b96f3bb05dd519..bde07a040869bf11a1b95bf433bf6af1e2d0a932 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -25,11 +25,10 @@ pub type DbMessage = crate::Message; pub type DbSummary = crate::legacy_thread::DetailedSummaryState; pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct DbThreadMetadata { pub id: acp::SessionId, pub parent_session_id: Option, - #[serde(alias = "summary")] pub title: SharedString, pub updated_at: DateTime, pub created_at: Option>, @@ -42,7 +41,7 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo { fn from(meta: &DbThreadMetadata) -> Self { Self { session_id: meta.id.clone(), - cwd: None, + work_dirs: Some(meta.folder_paths.clone()), title: Some(meta.title.clone()), updated_at: Some(meta.updated_at), created_at: meta.created_at, @@ -881,7 +880,6 @@ mod tests { let threads = database.list_threads().await.unwrap(); assert_eq!(threads.len(), 1); - assert_eq!(threads[0].folder_paths, folder_paths); } #[gpui::test] @@ -901,7 +899,6 @@ mod tests { let threads = database.list_threads().await.unwrap(); assert_eq!(threads.len(), 1); - assert!(threads[0].folder_paths.is_empty()); } #[test] diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index ca5128fc80d49df0f165ab065a510585400f55d9..b2c3c913f19a877dcd001bd771809ce7f9a4afa5 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -6,7 +6,8 @@ use agent_settings::AgentSettings; use anyhow::Result; use collections::HashSet; use fs::Fs; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, Entity, Task}; +use project::AgentId; use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; @@ -25,8 +26,8 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn name(&self) -> SharedString { - "Zed Agent".into() + fn agent_id(&self) -> AgentId { + crate::ZED_AGENT_ID.clone() } fn logo(&self) -> ui::IconName { diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index db3fa7c56ebc8ba7a94850d9d38b07c65a7ef4ba..e8a8acefce6d5728cd666d7fb7cb87ec3dcccb3e 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3177,7 +3177,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone())); fake_fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; - let cwd = Path::new("/test"); + let cwd = PathList::new(&[Path::new("/test")]); let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection @@ -4389,7 +4389,7 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4524,7 +4524,7 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4672,7 +4672,7 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4802,7 +4802,7 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5174,7 +5174,7 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5300,7 +5300,7 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5474,7 +5474,7 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index dd1f650de2f59a0e681e15e7eae3fad1a49ccc41..379ae675d4bbf3c2a9570365493317178f38a804 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -2,7 +2,6 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; use gpui::{App, Context, Entity, Global, Task, prelude::*}; -use std::collections::HashMap; use util::path_list::PathList; struct GlobalThreadStore(Entity); @@ -11,7 +10,6 @@ impl Global for GlobalThreadStore {} pub struct ThreadStore { threads: Vec, - threads_by_paths: HashMap>, } impl ThreadStore { @@ -31,7 +29,6 @@ impl ThreadStore { pub fn new(cx: &mut Context) -> Self { let this = Self { threads: Vec::new(), - threads_by_paths: HashMap::default(), }; this.reload(cx); this @@ -97,16 +94,10 @@ impl ThreadStore { let all_threads = database.list_threads().await?; this.update(cx, |this, cx| { this.threads.clear(); - this.threads_by_paths.clear(); for thread in all_threads { if thread.parent_session_id.is_some() { continue; } - let index = this.threads.len(); - this.threads_by_paths - .entry(thread.folder_paths.clone()) - .or_default() - .push(index); this.threads.push(thread); } cx.notify(); @@ -122,15 +113,6 @@ impl ThreadStore { pub fn entries(&self) -> impl Iterator + '_ { self.threads.iter().cloned() } - - /// Returns threads whose folder_paths match the given paths exactly. - /// Uses a cached index for O(1) lookup per path list. - pub fn threads_for_paths(&self, paths: &PathList) -> impl Iterator { - self.threads_by_paths - .get(paths) - .into_iter() - .flat_map(|indices| indices.iter().map(|&index| &self.threads[index])) - } } #[cfg(test)] @@ -306,50 +288,4 @@ mod tests { assert_eq!(entries[0].id, first_id); assert_eq!(entries[1].id, second_id); } - - #[gpui::test] - async fn test_threads_for_paths_filters_correctly(cx: &mut TestAppContext) { - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - cx.run_until_parked(); - - let project_a_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-a")]); - let project_b_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-b")]); - - let thread_a = make_thread( - "Thread in A", - Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), - ); - let thread_b = make_thread( - "Thread in B", - Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), - ); - let thread_a_id = session_id("thread-a"); - let thread_b_id = session_id("thread-b"); - - let save_a = thread_store.update(cx, |store, cx| { - store.save_thread(thread_a_id.clone(), thread_a, project_a_paths.clone(), cx) - }); - save_a.await.unwrap(); - - let save_b = thread_store.update(cx, |store, cx| { - store.save_thread(thread_b_id.clone(), thread_b, project_b_paths.clone(), cx) - }); - save_b.await.unwrap(); - - cx.run_until_parked(); - - thread_store.read_with(cx, |store, _cx| { - let a_threads: Vec<_> = store.threads_for_paths(&project_a_paths).collect(); - assert_eq!(a_threads.len(), 1); - assert_eq!(a_threads[0].id, thread_a_id); - - let b_threads: Vec<_> = store.threads_for_paths(&project_b_paths).collect(); - assert_eq!(b_threads.len(), 1); - assert_eq!(b_threads[0].id, thread_b_id); - - let nonexistent = PathList::new(&[std::path::PathBuf::from("/nonexistent")]); - let no_threads: Vec<_> = store.threads_for_paths(&nonexistent).collect(); - assert!(no_threads.is_empty()); - }); - } } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 8f7f7c94535453f0c2c0598de2b86bf51cd79a9d..54166f1d553b4ed1ef0f3642517125b30bc5fda8 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,18 +9,19 @@ use anyhow::anyhow; use collections::HashMap; use futures::AsyncBufReadExt as _; use futures::io::BufReader; -use project::Project; -use project::agent_server_store::{AgentServerCommand, GEMINI_NAME}; +use project::agent_server_store::{AgentServerCommand, GEMINI_ID}; +use project::{AgentId, Project}; use serde::Deserialize; use settings::Settings as _; use task::ShellBuilder; use util::ResultExt as _; +use util::path_list::PathList; use util::process::Child; use std::path::PathBuf; use std::process::Stdio; +use std::rc::Rc; use std::{any::Any, cell::RefCell}; -use std::{path::Path, rc::Rc}; use thiserror::Error; use anyhow::{Context as _, Result}; @@ -35,7 +36,7 @@ use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings pub struct UnsupportedVersion; pub struct AcpConnection { - server_name: SharedString, + id: AgentId, display_name: SharedString, telemetry_id: SharedString, connection: Rc, @@ -124,7 +125,7 @@ impl AgentSessionList for AcpSessionList { .into_iter() .map(|s| AgentSessionInfo { session_id: s.session_id, - cwd: Some(s.cwd), + work_dirs: Some(PathList::new(&[s.cwd])), title: s.title.map(Into::into), updated_at: s.updated_at.and_then(|date_str| { chrono::DateTime::parse_from_rfc3339(&date_str) @@ -158,7 +159,7 @@ impl AgentSessionList for AcpSessionList { } pub async fn connect( - server_name: SharedString, + agent_id: AgentId, display_name: SharedString, command: AgentServerCommand, default_mode: Option, @@ -167,7 +168,7 @@ pub async fn connect( cx: &mut AsyncApp, ) -> Result> { let conn = AcpConnection::stdio( - server_name, + agent_id, display_name, command.clone(), default_mode, @@ -183,7 +184,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1 impl AcpConnection { pub async fn stdio( - server_name: SharedString, + agent_id: AgentId, display_name: SharedString, command: AgentServerCommand, default_mode: Option, @@ -270,7 +271,7 @@ impl AcpConnection { cx.update(|cx| { AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { - registry.set_active_connection(server_name.clone(), &connection, cx) + registry.set_active_connection(agent_id.clone(), &connection, cx) }); }); @@ -305,7 +306,7 @@ impl AcpConnection { // Use the one the agent provides if we have one .map(|info| info.name.into()) // Otherwise, just use the name - .unwrap_or_else(|| server_name.clone()); + .unwrap_or_else(|| agent_id.0.to_string().into()); let session_list = if response .agent_capabilities @@ -321,7 +322,7 @@ impl AcpConnection { }; // TODO: Remove this override once Google team releases their official auth methods - let auth_methods = if server_name == GEMINI_NAME { + let auth_methods = if agent_id.0.as_ref() == GEMINI_ID { let mut args = command.args.clone(); args.retain(|a| a != "--experimental-acp"); let value = serde_json::json!({ @@ -340,9 +341,9 @@ impl AcpConnection { response.auth_methods }; Ok(Self { + id: agent_id, auth_methods, connection, - server_name, display_name, telemetry_id, sessions, @@ -368,7 +369,7 @@ impl AcpConnection { config_options: &Rc>>, cx: &mut AsyncApp, ) { - let name = self.server_name.clone(); + let id = self.id.clone(); let defaults_to_apply: Vec<_> = { let config_opts_ref = config_options.borrow(); config_opts_ref @@ -410,7 +411,7 @@ impl AcpConnection { "`{}` is not a valid value for config option `{}` in {}", default_value, config_option.id.0, - name + id ); None } @@ -466,6 +467,10 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { + fn agent_id(&self) -> AgentId { + self.id.clone() + } + fn telemetry_id(&self) -> SharedString { self.telemetry_id.clone() } @@ -473,11 +478,14 @@ impl AgentConnection for AcpConnection { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { - let name = self.server_name.clone(); - let cwd = cwd.to_path_buf(); + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; + let name = self.id.0.clone(); let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { @@ -575,7 +583,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, self.display_name.clone(), - Some(cwd), + Some(work_dirs), self.clone(), project, action_log, @@ -616,7 +624,7 @@ impl AgentConnection for AcpConnection { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, title: Option, cx: &mut App, ) -> Task>> { @@ -625,8 +633,11 @@ impl AgentConnection for AcpConnection { "Loading sessions is not supported by this agent.".into() )))); } + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; - let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); let title = title.unwrap_or_else(|| self.display_name.clone()); @@ -634,7 +645,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, title, - Some(cwd.clone()), + Some(work_dirs.clone()), self.clone(), project, action_log, @@ -691,7 +702,7 @@ impl AgentConnection for AcpConnection { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, title: Option, cx: &mut App, ) -> Task>> { @@ -705,8 +716,11 @@ impl AgentConnection for AcpConnection { "Resuming sessions is not supported by this agent.".into() )))); } + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; - let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); let title = title.unwrap_or_else(|| self.display_name.clone()); @@ -714,7 +728,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, title, - Some(cwd.clone()), + Some(work_dirs), self.clone(), project, action_log, diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index a12b63164325cfc447e44b3a5899e79b774e141f..020e36b999e3586430ae99b12af55a845de91cb8 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -9,11 +9,11 @@ use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; use http_client::read_no_proxy_from_env; -use project::agent_server_store::AgentServerStore; +use project::{AgentId, agent_server_store::AgentServerStore}; use acp_thread::AgentConnection; use anyhow::Result; -use gpui::{App, AppContext, Entity, SharedString, Task}; +use gpui::{App, AppContext, Entity, Task}; use settings::SettingsStore; use std::{any::Any, rc::Rc, sync::Arc}; @@ -38,7 +38,7 @@ impl AgentServerDelegate { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; - fn name(&self) -> SharedString; + fn agent_id(&self) -> AgentId; fn connect( &self, delegate: AgentServerDelegate, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index d87b9dc4ece042d94da6e6e0ac99e1474c1ce018..d9a4469aefa957033a583a1061656dcb090eeec1 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -5,10 +5,10 @@ use anyhow::{Context as _, Result}; use collections::HashSet; use credentials_provider::CredentialsProvider; use fs::Fs; -use gpui::{App, AppContext as _, SharedString, Task}; +use gpui::{App, AppContext as _, Task}; use language_model::{ApiKey, EnvVar}; use project::agent_server_store::{ - AllAgentServersSettings, CLAUDE_AGENT_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME, + AgentId, AllAgentServersSettings, CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID, }; use settings::{SettingsStore, update_settings_file}; use std::{rc::Rc, sync::Arc}; @@ -16,18 +16,18 @@ use ui::IconName; /// A generic agent server implementation for custom user-defined agents pub struct CustomAgentServer { - name: SharedString, + agent_id: AgentId, } impl CustomAgentServer { - pub fn new(name: SharedString) -> Self { - Self { name } + pub fn new(agent_id: AgentId) -> Self { + Self { agent_id } } } impl AgentServer for CustomAgentServer { - fn name(&self) -> SharedString { - self.name.clone() + fn agent_id(&self) -> AgentId { + self.agent_id.clone() } fn logo(&self) -> IconName { @@ -38,7 +38,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().0.as_ref()) .cloned() }); @@ -55,7 +55,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().0.as_ref()) .cloned() }); @@ -80,7 +80,7 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &App, ) { - let name = self.name(); + let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.to_string(); @@ -88,8 +88,8 @@ impl AgentServer for CustomAgentServer { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -124,13 +124,13 @@ impl AgentServer for CustomAgentServer { } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { - let name = self.name(); + let agent_id = self.agent_id(); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_mode, .. } @@ -146,7 +146,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -156,13 +156,13 @@ impl AgentServer for CustomAgentServer { } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { - let name = self.name(); + let agent_id = self.agent_id(); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_model, .. } @@ -178,7 +178,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -200,13 +200,13 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &App, ) { - let name = self.name(); + let agent_id = self.agent_id(); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); let favorite_models = match settings { settings::CustomAgentServerSettings::Custom { @@ -235,7 +235,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -251,15 +251,15 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &mut App, ) { - let name = self.name(); + let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.map(|s| s.to_string()); update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| default_settings_for_agent(&name, cx)); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -289,19 +289,19 @@ impl AgentServer for CustomAgentServer { delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { - let name = self.name(); + let agent_id = self.agent_id(); let display_name = delegate .store .read(cx) - .agent_display_name(&ExternalAgentServerName(name.clone())) - .unwrap_or_else(|| name.clone()); + .agent_display_name(&agent_id) + .unwrap_or_else(|| agent_id.0.clone()); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); - let is_registry_agent = is_registry_agent(&name, cx); + let is_registry_agent = is_registry_agent(agent_id.clone(), cx); let default_config_options = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .map(|s| match s { project::agent_server_store::CustomAgentServerSettings::Custom { default_config_options, @@ -330,11 +330,11 @@ impl AgentServer for CustomAgentServer { extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } if is_registry_agent { - match name.as_ref() { - CLAUDE_AGENT_NAME => { + match agent_id.as_ref() { + CLAUDE_AGENT_ID => { extra_env.insert("ANTHROPIC_API_KEY".into(), "".into()); } - CODEX_NAME => { + CODEX_ID => { if let Ok(api_key) = std::env::var("CODEX_API_KEY") { extra_env.insert("CODEX_API_KEY".into(), api_key); } @@ -342,7 +342,7 @@ impl AgentServer for CustomAgentServer { extra_env.insert("OPEN_AI_API_KEY".into(), api_key); } } - GEMINI_NAME => { + GEMINI_ID => { extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); } _ => {} @@ -350,18 +350,16 @@ impl AgentServer for CustomAgentServer { } let store = delegate.store.downgrade(); cx.spawn(async move |cx| { - if is_registry_agent && name.as_ref() == GEMINI_NAME { + if is_registry_agent && agent_id.as_ref() == GEMINI_ID { if let Some(api_key) = cx.update(api_key_for_gemini_cli).await.ok() { extra_env.insert("GEMINI_API_KEY".into(), api_key); } } let command = store .update(cx, |store, cx| { - let agent = store - .get_external_agent(&ExternalAgentServerName(name.clone())) - .with_context(|| { - format!("Custom agent server `{}` is not registered", name) - })?; + let agent = store.get_external_agent(&agent_id).with_context(|| { + format!("Custom agent server `{}` is not registered", agent_id) + })?; anyhow::Ok(agent.get_command( extra_env, delegate.new_version_available, @@ -370,7 +368,7 @@ impl AgentServer for CustomAgentServer { })?? .await?; let connection = crate::acp::connect( - name, + agent_id, display_name, command, default_mode, @@ -405,15 +403,17 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task> { }) } -fn is_registry_agent(name: &str, cx: &App) -> bool { - let is_previous_built_in = matches!(name, CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME); +fn is_registry_agent(agent_id: impl Into, cx: &App) -> bool { + let agent_id = agent_id.into(); + let is_previous_built_in = + matches!(agent_id.0.as_ref(), CLAUDE_AGENT_ID | CODEX_ID | GEMINI_ID); let is_in_registry = project::AgentRegistryStore::try_global(cx) - .map(|store| store.read(cx).agent(name).is_some()) + .map(|store| store.read(cx).agent(&agent_id).is_some()) .unwrap_or(false); let is_settings_registry = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(name) + .get(agent_id.as_ref()) .is_some_and(|s| { matches!( s, @@ -424,8 +424,11 @@ fn is_registry_agent(name: &str, cx: &App) -> bool { is_previous_built_in || is_in_registry || is_settings_registry } -fn default_settings_for_agent(name: &str, cx: &App) -> settings::CustomAgentServerSettings { - if is_registry_agent(name, cx) { +fn default_settings_for_agent( + agent_id: impl Into, + cx: &App, +) -> settings::CustomAgentServerSettings { + if is_registry_agent(agent_id, cx) { settings::CustomAgentServerSettings::Registry { default_model: None, default_mode: None, @@ -455,6 +458,7 @@ mod tests { AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent, }; use settings::Settings as _; + use ui::SharedString; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -470,7 +474,7 @@ mod tests { let id = SharedString::from(id.to_string()); RegistryAgent::Npx(RegistryNpxAgent { metadata: RegistryAgentMetadata { - id: id.clone(), + id: AgentId::new(id.clone()), name: id.clone(), description: SharedString::from(""), version: SharedString::from("1.0.0"), @@ -509,9 +513,9 @@ mod tests { fn test_previous_builtins_are_registry(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { - assert!(is_registry_agent(CLAUDE_AGENT_NAME, cx)); - assert!(is_registry_agent(CODEX_NAME, cx)); - assert!(is_registry_agent(GEMINI_NAME, cx)); + assert!(is_registry_agent(CLAUDE_AGENT_ID, cx)); + assert!(is_registry_agent(CODEX_ID, cx)); + assert!(is_registry_agent(GEMINI_ID, cx)); }); } @@ -582,15 +586,15 @@ mod tests { init_test(cx); cx.update(|cx| { assert!(matches!( - default_settings_for_agent(CODEX_NAME, cx), + default_settings_for_agent(CODEX_ID, cx), settings::CustomAgentServerSettings::Registry { .. } )); assert!(matches!( - default_settings_for_agent(CLAUDE_AGENT_NAME, cx), + default_settings_for_agent(CLAUDE_AGENT_ID, cx), settings::CustomAgentServerSettings::Registry { .. } )); assert!(matches!( - default_settings_for_agent(GEMINI_NAME, cx), + default_settings_for_agent(GEMINI_ID, cx), settings::CustomAgentServerSettings::Registry { .. } )); }); diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 5dcf416bb87ba4812e1a828c23d49819f2874a99..b9365296c3fdb9ed7dc45c1c146d0abd7a831fce 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -14,6 +14,7 @@ use std::{ time::Duration, }; use util::path; +use util::path_list::PathList; pub async fn test_basic(server: F, cx: &mut TestAppContext) where @@ -435,9 +436,11 @@ pub async fn new_test_thread( let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap(); - cx.update(|cx| connection.new_session(project.clone(), current_dir.as_ref(), cx)) - .await - .unwrap() + cx.update(|cx| { + connection.new_session(project.clone(), PathList::new(&[current_dir.as_ref()]), cx) + }) + .await + .unwrap() } pub async fn run_until_first_tool_call( diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 6b7f46d87f2db1e9262eadf9e7064c06245b1e3c..7c2f23fcbce43bed271c58b750145d75655d16ba 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -28,7 +28,7 @@ use language_model::{ use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - agent_server_store::{AgentServerStore, ExternalAgentServerName, ExternalAgentSource}, + agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; use settings::{Settings, SettingsStore, update_settings_file}; @@ -1103,7 +1103,7 @@ impl AgentConfiguration { ExternalAgentSource::Custom => None, }; - let agent_server_name = ExternalAgentServerName(id.clone()); + let agent_server_name = AgentId(id.clone()); let uninstall_button = match source { ExternalAgentSource::Extension => Some( diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index c9be46aea3ad99dec77724710db9088ae459696e..79644eb26886c4ea23b9440473193a8f15bec977 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -10,7 +10,6 @@ use project::{AgentServerStore, AgentServersUpdated, Project}; use watch::Receiver; use crate::{Agent, ThreadHistory}; -use project::ExternalAgentServerName; pub enum AgentConnectionEntry { Connecting { @@ -143,9 +142,7 @@ impl AgentConnectionStore { let store = store.read(cx); self.entries.retain(|key, _| match key { Agent::NativeAgent => true, - Agent::Custom { name } => store - .external_agents - .contains_key(&ExternalAgentServerName(name.clone())), + Agent::Custom { id } => store.external_agents.contains_key(id), }); cx.notify(); } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index bb1367b7da31d7975ab271ec821fb43a5da70605..a44546fb2bfdfe4800d8087a6370635c6e96de9e 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1805,7 +1805,7 @@ mod tests { use settings::SettingsStore; use std::{path::Path, rc::Rc}; use util::path; - use workspace::MultiWorkspace; + use workspace::{MultiWorkspace, PathList}; #[gpui::test] async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) { @@ -1833,9 +1833,11 @@ mod tests { let connection = Rc::new(acp_thread::StubAgentConnection::new()); let thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/test")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) }) .await .unwrap(); @@ -2024,9 +2026,11 @@ mod tests { let connection = Rc::new(acp_thread::StubAgentConnection::new()); let thread = cx .update(|_, cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/test")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) }) .await .unwrap(); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 10d24e61fe3e6bbf5d0a0d88e0f28ba3fbfa2b78..ccf9e481ef48095de76587c915962eef458a77e1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -17,8 +17,8 @@ use collections::HashSet; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use itertools::Itertools; use project::{ - ExternalAgentServerName, - agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}, + AgentId, + agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}, }; use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; @@ -86,8 +86,8 @@ use ui::{ use util::{ResultExt as _, debug_panic}; use workspace::{ CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar, - MultiWorkspace, OpenResult, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom, - ToolbarItemView, Workspace, WorkspaceId, + MultiWorkspace, OpenResult, PathList, SIDEBAR_RESIZE_HANDLE_SIZE, SerializedPathList, + ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, multi_workspace_enabled, }; @@ -180,7 +180,7 @@ fn read_legacy_serialized_panel() -> Option { .and_then(|json| serde_json::from_str::(&json).log_err()) } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { width: Option, selected_agent: Option, @@ -190,12 +190,12 @@ struct SerializedAgentPanel { start_thread_in: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug)] struct SerializedActiveThread { session_id: String, agent_type: AgentType, title: Option, - cwd: Option, + work_dirs: Option, } pub fn init(cx: &mut App) { @@ -651,7 +651,8 @@ pub enum AgentType { NativeAgent, TextThread, Custom { - name: SharedString, + #[serde(rename = "name")] + id: AgentId, }, } @@ -671,13 +672,13 @@ impl<'de> Deserialize<'de> for AgentType { "NativeAgent" => Ok(Self::NativeAgent), "TextThread" => Ok(Self::TextThread), "ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }), "Codex" => Ok(Self::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }), "Gemini" => Ok(Self::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }), other => Err(serde::de::Error::unknown_variant( other, @@ -702,7 +703,9 @@ impl<'de> Deserialize<'de> for AgentType { } let fields: CustomFields = serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; - return Ok(Self::Custom { name: fields.name }); + return Ok(Self::Custom { + id: AgentId::new(fields.name), + }); } } @@ -720,7 +723,7 @@ impl AgentType { fn label(&self) -> SharedString { match self { Self::NativeAgent | Self::TextThread => "Zed Agent".into(), - Self::Custom { name, .. } => name.into(), + Self::Custom { id, .. } => id.0.clone(), } } @@ -735,7 +738,7 @@ impl AgentType { impl From for AgentType { fn from(value: Agent) -> Self { match value { - Agent::Custom { name } => Self::Custom { name }, + Agent::Custom { id } => Self::Custom { id }, Agent::NativeAgent => Self::NativeAgent, } } @@ -913,6 +916,7 @@ impl AgentPanel { let last_active_thread = self.active_agent_thread(cx).map(|thread| { let thread = thread.read(cx); let title = thread.title(); + let work_dirs = thread.work_dirs().cloned(); SerializedActiveThread { session_id: thread.session_id().0.to_string(), agent_type: self.selected_agent_type.clone(), @@ -921,7 +925,7 @@ impl AgentPanel { } else { None }, - cwd: None, + work_dirs: work_dirs.map(|dirs| dirs.serialize()), } }); @@ -979,7 +983,7 @@ impl AgentPanel { let last_active_thread = if let Some(thread_info) = serialized_panel .as_ref() - .and_then(|p| p.last_active_thread.clone()) + .and_then(|p| p.last_active_thread.as_ref()) { if thread_info.agent_type.is_native() { let session_id = acp::SessionId::new(thread_info.session_id.clone()); @@ -1048,9 +1052,9 @@ impl AgentPanel { if let Some(agent) = panel.selected_agent() { panel.load_agent_thread( agent, - thread_info.session_id.into(), - thread_info.cwd, - thread_info.title.map(SharedString::from), + thread_info.session_id.clone().into(), + thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)), + thread_info.title.as_ref().map(|t| t.clone().into()), false, window, cx, @@ -1292,7 +1296,7 @@ impl AgentPanel { pub fn open_thread( &mut self, session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, title: Option, window: &mut Window, cx: &mut Context, @@ -1300,7 +1304,7 @@ impl AgentPanel { self.external_thread( Some(crate::Agent::NativeAgent), Some(session_id), - cwd, + work_dirs, title, None, true, @@ -1435,7 +1439,7 @@ impl AgentPanel { &mut self, agent_choice: Option, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, focus: bool, @@ -1476,7 +1480,7 @@ impl AgentPanel { self.create_agent_thread( server, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace, @@ -1509,7 +1513,7 @@ impl AgentPanel { agent_panel.create_agent_thread( server, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace, @@ -1569,8 +1573,8 @@ impl AgentPanel { fn has_history_for_selected_agent(&self, cx: &App) -> bool { match &self.selected_agent_type { AgentType::TextThread | AgentType::NativeAgent => true, - AgentType::Custom { name } => { - let agent = Agent::Custom { name: name.clone() }; + AgentType::Custom { id } => { + let agent = Agent::Custom { id: id.clone() }; self.connection_store .read(cx) .entry(&agent) @@ -1599,8 +1603,8 @@ impl AgentPanel { view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx), }) } - AgentType::Custom { name } => { - let agent = Agent::Custom { name: name.clone() }; + AgentType::Custom { id, .. } => { + let agent = Agent::Custom { id: id.clone() }; let history = self .connection_store .read(cx) @@ -1635,7 +1639,7 @@ impl AgentPanel { this.load_agent_thread( agent.clone(), thread.session_id.clone(), - thread.cwd.clone(), + thread.work_dirs.clone(), thread.title.clone(), true, window, @@ -2286,7 +2290,7 @@ impl AgentPanel { this.load_agent_thread( agent, entry.session_id.clone(), - entry.cwd.clone(), + entry.work_dirs.clone(), entry.title.clone(), true, window, @@ -2415,7 +2419,7 @@ impl AgentPanel { pub(crate) fn selected_agent(&self) -> Option { match &self.selected_agent_type { AgentType::NativeAgent => Some(Agent::NativeAgent), - AgentType::Custom { name } => Some(Agent::Custom { name: name.clone() }), + AgentType::Custom { id } => Some(Agent::Custom { id: id.clone() }), AgentType::TextThread => None, } } @@ -2494,8 +2498,8 @@ impl AgentPanel { window, cx, ), - AgentType::Custom { name } => self.external_thread( - Some(crate::Agent::Custom { name }), + AgentType::Custom { id } => self.external_thread( + Some(crate::Agent::Custom { id }), None, None, None, @@ -2511,7 +2515,7 @@ impl AgentPanel { &mut self, agent: Agent, session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, title: Option, focus: bool, window: &mut Window, @@ -2550,7 +2554,7 @@ impl AgentPanel { self.external_thread( Some(agent), Some(session_id), - cwd, + work_dirs, title, None, focus, @@ -2563,7 +2567,7 @@ impl AgentPanel { &mut self, server: Rc, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, workspace: WeakEntity, @@ -2592,7 +2596,7 @@ impl AgentPanel { connection_store, ext_agent, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace.clone(), @@ -3871,12 +3875,12 @@ impl AgentPanel { let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; let (selected_agent_custom_icon, selected_agent_label) = - if let AgentType::Custom { name, .. } = &self.selected_agent_type { + if let AgentType::Custom { id, .. } = &self.selected_agent_type { let store = agent_server_store.read(cx); - let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + let icon = store.agent_icon(&id); let label = store - .agent_display_name(&ExternalAgentServerName(name.clone())) + .agent_display_name(&id) .unwrap_or_else(|| self.selected_agent_type.label()); (icon, label) } else { @@ -4005,24 +4009,24 @@ impl AgentPanel { registry_store.as_ref().map(|s| s.read(cx)); struct AgentMenuItem { - id: ExternalAgentServerName, + id: AgentId, display_name: SharedString, } let agent_items = agent_server_store .external_agents() - .map(|name| { + .map(|agent_id| { let display_name = agent_server_store - .agent_display_name(name) + .agent_display_name(agent_id) .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(agent_id)) .map(|a| a.name().clone()) }) - .unwrap_or_else(|| name.0.clone()); + .unwrap_or_else(|| agent_id.0.clone()); AgentMenuItem { - id: name.clone(), + id: agent_id.clone(), display_name, } }) @@ -4038,7 +4042,7 @@ impl AgentPanel { .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|store| store.agent(&item.id)) .and_then(|a| a.icon_path().cloned()) }); @@ -4051,7 +4055,7 @@ impl AgentPanel { entry = entry .when( is_agent_selected(AgentType::Custom { - name: item.id.0.clone(), + id: item.id.clone(), }), |this| { this.action(Box::new( @@ -4073,7 +4077,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_id.0.clone(), + id: agent_id.clone(), }, window, cx, @@ -4098,20 +4102,20 @@ impl AgentPanel { let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); - let previous_built_in_ids: &[ExternalAgentServerName] = - &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()]; + let previous_built_in_ids: &[AgentId] = + &[CLAUDE_AGENT_ID.into(), CODEX_ID.into(), GEMINI_ID.into()]; let promoted_items = previous_built_in_ids .iter() .filter(|id| { !agent_server_store.external_agents.contains_key(*id) }) - .filter_map(|name| { + .filter_map(|id| { let display_name = registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(&id)) .map(|a| a.name().clone())?; - Some((name.clone(), display_name)) + Some((id.clone(), display_name)) }) .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase()) .collect::>(); @@ -4122,7 +4126,7 @@ impl AgentPanel { let icon_path = registry_store_ref .as_ref() - .and_then(|store| store.agent(agent_id.0.as_str())) + .and_then(|store| store.agent(agent_id)) .and_then(|a| a.icon_path().cloned()); if let Some(icon_path) = icon_path { @@ -4169,7 +4173,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_id.0.clone(), + id: agent_id.clone(), }, window, cx, @@ -5217,7 +5221,7 @@ impl AgentPanel { let project = self.project.clone(); let ext_agent = Agent::Custom { - name: server.name(), + id: server.agent_id(), }; self.create_agent_thread( @@ -5379,7 +5383,7 @@ mod tests { panel_b.update(cx, |panel, _cx| { panel.width = Some(px(400.0)); panel.selected_agent_type = AgentType::Custom { - name: "claude-acp".into(), + id: "claude-acp".into(), }; }); @@ -5430,7 +5434,7 @@ mod tests { assert_eq!( panel.selected_agent_type, AgentType::Custom { - name: "claude-acp".into() + id: "claude-acp".into() }, "workspace B agent type should be restored" ); @@ -6229,25 +6233,25 @@ mod tests { assert_eq!( serde_json::from_str::(r#""ClaudeAgent""#).unwrap(), AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""ClaudeCode""#).unwrap(), AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""Codex""#).unwrap(), AgentType::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""Gemini""#).unwrap(), AgentType::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }, ); } @@ -6265,7 +6269,7 @@ mod tests { assert_eq!( serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), AgentType::Custom { - name: "my-agent".into(), + id: "my-agent".into(), }, ); } @@ -6285,14 +6289,14 @@ mod tests { assert_eq!( panel.selected_agent, Some(AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }), ); let thread = panel.last_active_thread.unwrap(); assert_eq!( thread.agent_type, AgentType::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }, ); } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index db0cf873418ea38f8d5771c13b281528218fb94e..e0ab9a707b7e7ab5fed6a0a27d4c253c08445dfa 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -34,6 +34,7 @@ mod text_thread_editor; mod text_thread_history; mod thread_history; mod thread_history_view; +mod thread_metadata_store; mod threads_archive_view; mod ui; @@ -55,7 +56,7 @@ use language::{ use language_model::{ ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; -use project::DisableAiSettings; +use project::{AgentId, DisableAiSettings}; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -221,7 +222,10 @@ pub struct NewNativeAgentThreadFromSummary { #[serde(rename_all = "snake_case")] pub enum Agent { NativeAgent, - Custom { name: SharedString }, + Custom { + #[serde(rename = "name")] + id: AgentId, + }, } // Custom impl handles legacy variant names from before the built-in agents were moved to @@ -233,7 +237,7 @@ impl<'de> serde::Deserialize<'de> for Agent { where D: serde::Deserializer<'de>, { - use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}; + use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}; let value = serde_json::Value::deserialize(deserializer)?; @@ -241,13 +245,13 @@ impl<'de> serde::Deserialize<'de> for Agent { return match s { "native_agent" => Ok(Self::NativeAgent), "claude_code" | "claude_agent" => Ok(Self::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }), "codex" => Ok(Self::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }), "gemini" => Ok(Self::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }), other => Err(serde::de::Error::unknown_variant( other, @@ -271,7 +275,9 @@ impl<'de> serde::Deserialize<'de> for Agent { } let fields: CustomFields = serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; - return Ok(Self::Custom { name: fields.name }); + return Ok(Self::Custom { + id: AgentId::new(fields.name), + }); } } @@ -289,7 +295,9 @@ impl Agent { ) -> Rc { match self { Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, thread_store)), - Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())), + Self::Custom { id: name } => { + Rc::new(agent_servers::CustomAgentServer::new(name.clone())) + } } } } @@ -378,6 +386,7 @@ pub fn init( agent_panel::init(cx); context_server_configuration::init(language_registry.clone(), fs.clone(), cx); TextThreadEditor::init(cx); + thread_metadata_store::init(cx); register_slash_commands(cx); inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); @@ -751,24 +760,24 @@ mod tests { #[test] fn test_deserialize_legacy_external_agent_variants() { - use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}; + use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}; assert_eq!( serde_json::from_str::(r#""claude_code""#).unwrap(), Agent::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""codex""#).unwrap(), Agent::Custom { - name: CODEX_NAME.into(), + id: CODEX_ID.into(), }, ); assert_eq!( serde_json::from_str::(r#""gemini""#).unwrap(), Agent::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }, ); } @@ -782,7 +791,7 @@ mod tests { assert_eq!( serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), Agent::Custom { - name: "my-agent".into(), + id: "my-agent".into(), }, ); } diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 4d352c6a8494f97358ee012740e539c750308886..42d4fe4e4ef6b69e53d951e0007e564d5cc614a4 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -36,12 +36,12 @@ use gpui::{ use language::Buffer; use language_model::LanguageModelRegistry; use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; -use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId}; +use project::{AgentId, AgentServerStore, Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; use std::cell::RefCell; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; @@ -56,6 +56,7 @@ use ui::{ }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use util::{debug_panic, defer}; +use workspace::PathList; use workspace::{ CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId, }; @@ -74,6 +75,7 @@ use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; +use crate::thread_metadata_store::ThreadMetadataStore; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, @@ -482,7 +484,7 @@ impl ConnectionView { connection_store: Entity, connection_key: Agent, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, workspace: WeakEntity, @@ -531,7 +533,7 @@ impl ConnectionView { connection_store, connection_key, resume_session_id, - cwd, + work_dirs, title, project, initial_content, @@ -563,7 +565,7 @@ impl ConnectionView { let thread = thread_view.read(cx).thread.read(cx); ( Some(thread.session_id().clone()), - thread.cwd().cloned(), + thread.work_dirs().cloned(), Some(thread.title()), ) }) @@ -602,7 +604,7 @@ impl ConnectionView { connection_store: Entity, connection_key: Agent, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, project: Entity, initial_content: Option, @@ -638,24 +640,13 @@ impl ConnectionView { } }) .collect(); - let session_cwd = cwd - .filter(|cwd| { - // Validate with the normalized path (rejects `..` traversals), - // but return the original cwd to preserve its path separators. - // On Windows, `normalize_lexically` rebuilds the path with - // backslashes via `PathBuf::push`, which would corrupt - // forward-slash Linux paths used by WSL agents. - util::paths::normalize_lexically(cwd) - .ok() - .is_some_and(|normalized| { - worktree_roots - .iter() - .any(|root| normalized.starts_with(root.as_ref())) - }) - }) - .map(|path| path.into()) - .or_else(|| worktree_roots.first().cloned()) - .unwrap_or_else(|| paths::home_dir().as_path().into()); + let session_work_dirs = work_dirs.unwrap_or_else(|| { + if worktree_roots.is_empty() { + PathList::new(&[paths::home_dir().as_path()]) + } else { + PathList::new(&worktree_roots) + } + }); let connection_entry = connection_store.update(cx, |store, cx| { store.request_connection(connection_key, agent.clone(), cx) @@ -701,7 +692,7 @@ impl ConnectionView { connection.clone().load_session( session_id, project.clone(), - &session_cwd, + session_work_dirs, title, cx, ) @@ -710,7 +701,7 @@ impl ConnectionView { connection.clone().resume_session( session_id, project.clone(), - &session_cwd, + session_work_dirs, title, cx, ) @@ -725,7 +716,7 @@ impl ConnectionView { cx.update(|_, cx| { connection .clone() - .new_session(project.clone(), session_cwd.as_ref(), cx) + .new_session(project.clone(), session_work_dirs, cx) }) .log_err() }; @@ -741,7 +732,7 @@ impl ConnectionView { Self::handle_auth_required( this, err, - agent.name(), + agent.agent_id(), connection, window, cx, @@ -829,7 +820,7 @@ impl ConnectionView { window: &mut Window, cx: &mut Context, ) -> Entity { - let agent_name = self.agent.name(); + let agent_id = self.agent.agent_id(); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); @@ -846,7 +837,7 @@ impl ConnectionView { self.prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), - self.agent.name(), + self.agent.agent_id(), ) }); @@ -969,19 +960,19 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(agent_name.clone())) - .unwrap_or_else(|| agent_name.clone()); + .agent_display_name(&agent_id.clone()) + .unwrap_or_else(|| agent_id.0.clone()); let agent_icon = self.agent.logo(); let agent_icon_from_external_svg = self .agent_server_store .read(cx) - .agent_icon(&ExternalAgentServerName(self.agent.name())) + .agent_icon(&self.agent.agent_id()) .or_else(|| { project::AgentRegistryStore::try_global(cx).and_then(|store| { store .read(cx) - .agent(self.agent.name().as_ref()) + .agent(&self.agent.agent_id()) .and_then(|a| a.icon_path().cloned()) }) }); @@ -995,7 +986,7 @@ impl ConnectionView { weak, agent_icon, agent_icon_from_external_svg, - agent_name, + agent_id, agent_display_name, self.workspace.clone(), entry_view_state, @@ -1022,7 +1013,7 @@ impl ConnectionView { fn handle_auth_required( this: WeakEntity, err: AuthRequired, - agent_name: SharedString, + agent_id: AgentId, connection: Rc, window: &mut Window, cx: &mut App, @@ -1051,7 +1042,7 @@ impl ConnectionView { let view = registry.read(cx).provider(&provider_id).map(|provider| { provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_name), + language_model::ConfigurationViewTargetAgent::Other(agent_id.0), window, cx, ) @@ -1166,12 +1157,14 @@ impl ConnectionView { ServerState::Connected(_) => "New Thread".into(), ServerState::Loading(_) => "Loading…".into(), ServerState::LoadError { error, .. } => match error { - LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::Unsupported { .. } => { + format!("Upgrade {}", self.agent.agent_id()).into() + } LoadError::FailedToInstall(_) => { - format!("Failed to Install {}", self.agent.name()).into() + format!("Failed to Install {}", self.agent.agent_id()).into() } - LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), - LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), + LoadError::Exited { .. } => format!("{} Exited", self.agent.agent_id()).into(), + LoadError::Other(_) => format!("Error Loading {}", self.agent.agent_id()).into(), }, } } @@ -1451,8 +1444,8 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0.to_string().into()); if let Some(active) = self.active_thread() { let new_placeholder = @@ -1673,24 +1666,21 @@ impl ConnectionView { { return; } - let root_dir = self - .project + let Some(parent_thread) = connected.threads.get(&parent_id) else { + return; + }; + let work_dirs = parent_thread .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - if worktree.read(cx).is_single_file() { - Some(worktree.read(cx).abs_path().parent()?.into()) - } else { - Some(worktree.read(cx).abs_path()) - } - }) - .next(); - let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into()); + .thread + .read(cx) + .work_dirs() + .cloned() + .unwrap_or_else(|| PathList::new(&[paths::home_dir().as_path()])); let subagent_thread_task = connected.connection.clone().load_session( subagent_id.clone(), self.project.clone(), - &cwd, + work_dirs, None, cx, ); @@ -1876,8 +1866,8 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0); let show_fallback_description = auth_methods.len() > 1 && configuration_view.is_none() @@ -2038,7 +2028,7 @@ impl ConnectionView { LoadError::Other(_) => "other", }; - let agent_name = self.agent.name(); + let agent_name = self.agent.agent_id(); telemetry::event!( "Agent Panel Error Shown", @@ -2097,7 +2087,7 @@ impl ConnectionView { cx: &mut Context, ) -> AnyElement { let (heading_label, description_label) = ( - format!("Upgrade {} to work with Zed", self.agent.name()), + format!("Upgrade {} to work with Zed", self.agent.agent_id()), if version.is_empty() { format!( "Currently using {}, which does not report a valid --version", @@ -2217,7 +2207,7 @@ impl ConnectionView { let needed_count = self.queued_messages_len(cx); let queued_messages = self.queued_message_contents(cx); - let agent_name = self.agent.name(); + let agent_name = self.agent.agent_id(); let workspace = self.workspace.clone(); let project = self.project.downgrade(); let Some(connected) = self.as_connected() else { @@ -2396,7 +2386,7 @@ impl ConnectionView { } // TODO: Change this once we have title summarization for external agents. - let title = self.agent.name(); + let title = self.agent.agent_id().0; match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { @@ -2585,7 +2575,7 @@ impl ConnectionView { .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent.name() + self.agent.agent_id().0 } } @@ -2596,7 +2586,7 @@ impl ConnectionView { } pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { - let agent_name = self.agent.name(); + let agent_id = self.agent.agent_id(); if let Some(active) = self.active_thread() { active.update(cx, |active, cx| active.clear_thread_error(cx)); } @@ -2606,14 +2596,7 @@ impl ConnectionView { return; }; window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired::new(), - agent_name, - connection, - window, - cx, - ); + Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); }) } @@ -2630,6 +2613,12 @@ impl ConnectionView { .history .update(cx, |history, cx| history.delete_session(&session_id, cx)); task.detach_and_log_err(cx); + + if let Some(store) = ThreadMetadataStore::try_global(cx) { + store + .update(cx, |store, cx| store.delete(session_id.clone(), cx)) + .detach_and_log_err(cx); + } } } @@ -2642,7 +2631,7 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement { } fn placeholder_text(agent_name: &str, has_commands: bool) -> String { - if agent_name == "Zed Agent" { + if agent_name == agent::ZED_AGENT_ID.as_ref() { format!("Message the {} — @ to include context", agent_name) } else if has_commands { format!( @@ -2923,9 +2912,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::default_response()), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -3035,9 +3022,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, Some(SessionId::new("resume-session")), None, None, @@ -3081,7 +3066,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); + let captured_cwd = connection.captured_work_dirs.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let connection_store = @@ -3092,11 +3077,9 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection)), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, Some(SessionId::new("session-1")), - Some(PathBuf::from("/project/subdir")), + Some(PathList::new(&[PathBuf::from("/project/subdir")])), None, None, workspace.downgrade(), @@ -3112,122 +3095,12 @@ pub(crate) mod tests { cx.run_until_parked(); assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project/subdir")), + captured_cwd.lock().as_ref().unwrap(), + &PathList::new(&[Path::new("/project/subdir")]), "Should use session cwd when it's inside the project" ); } - #[gpui::test] - async fn test_resume_thread_uses_fallback_cwd_when_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let connection_store = - cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - ConnectionView::new( - Rc::new(StubAgentServer::new(connection)), - connection_store, - Agent::Custom { - name: "Test".into(), - }, - Some(SessionId::new("session-1")), - Some(PathBuf::from("/some/other/path")), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should use fallback project cwd when session cwd is outside the project" - ); - } - - #[gpui::test] - async fn test_resume_thread_rejects_unnormalized_cwd_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let connection_store = - cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - ConnectionView::new( - Rc::new(StubAgentServer::new(connection)), - connection_store, - Agent::Custom { - name: "Test".into(), - }, - Some(SessionId::new("session-1")), - Some(PathBuf::from("/project/../outside")), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should reject unnormalized cwd that resolves outside the project and use fallback cwd" - ); - } - #[gpui::test] async fn test_refusal_handling(cx: &mut TestAppContext) { init_test(cx); @@ -3519,9 +3392,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(agent), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -3734,9 +3605,7 @@ pub(crate) mod tests { let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - let agent_key = Agent::Custom { - name: "Test".into(), - }; + let agent_key = Agent::Custom { id: "Test".into() }; let thread_view = cx.update(|window, cx| { cx.new(|cx| { @@ -3849,7 +3718,7 @@ pub(crate) mod tests { ui::IconName::Ai } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Test".into() } @@ -3873,8 +3742,8 @@ pub(crate) mod tests { ui::IconName::AiOpenAi } - fn name(&self) -> SharedString { - "Codex CLI".into() + fn agent_id(&self) -> AgentId { + AgentId::new("Codex CLI") } fn connect( @@ -3960,6 +3829,10 @@ pub(crate) mod tests { } impl AgentConnection for SessionHistoryConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("history-connection") + } + fn telemetry_id(&self) -> SharedString { "history-connection".into() } @@ -3967,7 +3840,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + _work_dirs: PathList, cx: &mut App, ) -> Task>> { let thread = build_test_thread( @@ -4020,6 +3893,10 @@ pub(crate) mod tests { struct ResumeOnlyAgentConnection; impl AgentConnection for ResumeOnlyAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("resume-only") + } + fn telemetry_id(&self) -> SharedString { "resume-only".into() } @@ -4027,7 +3904,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + _work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { let thread = build_test_thread( @@ -4048,7 +3925,7 @@ pub(crate) mod tests { self: Rc, session_id: acp::SessionId, project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { @@ -4109,6 +3986,10 @@ pub(crate) mod tests { } impl AgentConnection for AuthGatedAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("auth-gated") + } + fn telemetry_id(&self) -> SharedString { "auth-gated".into() } @@ -4116,7 +3997,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { if !*self.authenticated.lock() { @@ -4131,7 +4012,7 @@ pub(crate) mod tests { AcpThread::new( None, "AuthGatedAgent", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4186,6 +4067,10 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("saboteur") + } + fn telemetry_id(&self) -> SharedString { "saboteur".into() } @@ -4193,7 +4078,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4201,7 +4086,7 @@ pub(crate) mod tests { AcpThread::new( None, "SaboteurAgentConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4252,6 +4137,10 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("refusal") + } + fn telemetry_id(&self) -> SharedString { "refusal".into() } @@ -4259,7 +4148,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4267,7 +4156,7 @@ pub(crate) mod tests { AcpThread::new( None, "RefusalAgentConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4315,18 +4204,22 @@ pub(crate) mod tests { #[derive(Clone)] struct CwdCapturingConnection { - captured_cwd: Arc>>, + captured_work_dirs: Arc>>, } impl CwdCapturingConnection { fn new() -> Self { Self { - captured_cwd: Arc::new(Mutex::new(None)), + captured_work_dirs: Arc::new(Mutex::new(None)), } } } impl AgentConnection for CwdCapturingConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("cwd-capturing") + } + fn telemetry_id(&self) -> SharedString { "cwd-capturing".into() } @@ -4334,16 +4227,16 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); + *self.captured_work_dirs.lock() = Some(work_dirs.clone()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( None, "CwdCapturingConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4368,17 +4261,17 @@ pub(crate) mod tests { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); + *self.captured_work_dirs.lock() = Some(work_dirs.clone()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( None, "CwdCapturingConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4427,6 +4320,7 @@ pub(crate) mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); + ThreadMetadataStore::init_global(cx); theme::init(theme::LoadThemes::JustBase, cx); editor::init(cx); agent_panel::init(cx); @@ -4484,9 +4378,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::new(connection.as_ref().clone())), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -6562,9 +6454,7 @@ pub(crate) mod tests { ConnectionView::new( Rc::new(StubAgentServer::default_response()), connection_store, - Agent::Custom { - name: "Test".into(), - }, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -6693,6 +6583,10 @@ pub(crate) mod tests { } impl AgentConnection for CloseCapableConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("close-capable") + } + fn telemetry_id(&self) -> SharedString { "close-capable".into() } @@ -6700,7 +6594,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -6708,7 +6602,7 @@ pub(crate) mod tests { AcpThread::new( None, "CloseCapableConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 29ba06f470d78d60772b63ce54802647ef303444..ed2a062c0aed13c60c8ea15193bb598764a31806 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -170,7 +170,7 @@ pub struct ThreadView { pub server_view: WeakEntity, pub agent_icon: IconName, pub agent_icon_from_external_svg: Option, - pub agent_name: SharedString, + pub agent_id: AgentId, pub focus_handle: FocusHandle, pub workspace: WeakEntity, pub entry_view_state: Entity, @@ -259,7 +259,7 @@ impl ThreadView { server_view: WeakEntity, agent_icon: IconName, agent_icon_from_external_svg: Option, - agent_name: SharedString, + agent_id: AgentId, agent_display_name: SharedString, workspace: WeakEntity, entry_view_state: Entity, @@ -300,7 +300,7 @@ impl ThreadView { prompt_store, prompt_capabilities.clone(), available_commands.clone(), - agent_name.clone(), + agent_id.clone(), &placeholder, editor::EditorMode::AutoHeight { min_lines: AgentSettings::get_global(cx).message_editor_min_lines, @@ -342,7 +342,7 @@ impl ThreadView { let show_codex_windows_warning = cfg!(windows) && project.upgrade().is_some_and(|p| p.read(cx).is_local()) - && agent_name == "Codex"; + && agent_id.as_ref() == "Codex"; let title_editor = { let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx)); @@ -403,7 +403,7 @@ impl ThreadView { server_view, agent_icon, agent_icon_from_external_svg, - agent_name, + agent_id, workspace, entry_view_state, title_editor, @@ -879,13 +879,13 @@ impl ThreadView { let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { - let agent_name = self.agent_name.clone(); + let agent_id = self.agent_id.clone(); let server_view = self.server_view.clone(); move |window, cx| { ConnectionView::handle_auth_required( server_view.clone(), AuthRequired::new(), - agent_name, + agent_id, connection, window, cx, @@ -3722,16 +3722,16 @@ impl ThreadView { let following = self.is_following(cx); let tooltip_label = if following { - if self.agent_name == "Zed Agent" { - format!("Stop Following the {}", self.agent_name) + if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + format!("Stop Following the {}", self.agent_id) } else { - format!("Stop Following {}", self.agent_name) + format!("Stop Following {}", self.agent_id) } } else { - if self.agent_name == "Zed Agent" { - format!("Follow the {}", self.agent_name) + if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + format!("Follow the {}", self.agent_id) } else { - format!("Follow {}", self.agent_name) + format!("Follow {}", self.agent_id) } }; @@ -3823,7 +3823,7 @@ impl ThreadView { let agent_name = if is_subagent { "subagents".into() } else { - self.agent_name.clone() + self.agent_id.clone() }; v_flex() @@ -7308,7 +7308,7 @@ impl ThreadView { .on_click(cx.listener({ move |this, _, window, cx| { let server_view = this.server_view.clone(); - let agent_name = this.agent_name.clone(); + let agent_name = this.agent_id.clone(); this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { @@ -7343,7 +7343,7 @@ impl ThreadView { .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent_name.clone() + self.agent_id.0.clone() } } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 17769335a1cc7e514bad15862d20d4048a089b7b..92075616547d7917119b42cf762557ce163d0a2a 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -8,10 +8,10 @@ use collections::HashMap; use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window, + ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; -use project::Project; +use project::{AgentId, Project}; use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; @@ -31,7 +31,7 @@ pub struct EntryViewState { entries: Vec, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, } impl EntryViewState { @@ -43,7 +43,7 @@ impl EntryViewState { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, ) -> Self { Self { workspace, @@ -54,7 +54,7 @@ impl EntryViewState { entries: Vec::new(), prompt_capabilities, available_commands, - agent_name, + agent_id, } } @@ -96,7 +96,7 @@ impl EntryViewState { self.prompt_store.clone(), self.prompt_capabilities.clone(), self.available_commands.clone(), - self.agent_name.clone(), + self.agent_id.clone(), "Edit message - @ to include context", editor::EditorMode::AutoHeight { min_lines: 1, @@ -468,7 +468,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use util::path; - use workspace::MultiWorkspace; + use workspace::{MultiWorkspace, PathList}; #[gpui::test] async fn test_diff_sync(cx: &mut TestAppContext) { @@ -495,9 +495,11 @@ mod tests { let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/project")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/project"))]), + cx, + ) }) .await .unwrap(); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 4170417df0c5fdfcdb86f2e4c0478c0ef59cefa9..6c62bd2f81ded87ae3b2aec4ac23473bc3324b8c 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -27,6 +27,7 @@ use gpui::{ KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, Language, language_settings::InlayHintKind}; +use project::AgentId; use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; use prompt_store::PromptStore; use rope::Point; @@ -45,7 +46,7 @@ pub struct MessageEditor { workspace: WeakEntity, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, thread_store: Option>, _subscriptions: Vec, _parse_slash_command_task: Task<()>, @@ -113,7 +114,7 @@ impl MessageEditor { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, placeholder: &str, mode: EditorMode, window: &mut Window, @@ -236,7 +237,7 @@ impl MessageEditor { workspace, prompt_capabilities, available_commands, - agent_name, + agent_id, thread_store, _subscriptions: subscriptions, _parse_slash_command_task: Task::ready(()), @@ -379,7 +380,7 @@ impl MessageEditor { fn validate_slash_commands( text: &str, available_commands: &[acp::AvailableCommand], - agent_name: &str, + agent_id: &AgentId, ) -> Result<()> { if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) { if let Some(command_name) = parsed_command.command { @@ -392,7 +393,7 @@ impl MessageEditor { return Err(anyhow!( "The /{} command is not supported by {}.\n\nAvailable commands: {}", command_name, - agent_name, + agent_id, if available_commands.is_empty() { "none".to_string() } else { @@ -416,11 +417,11 @@ impl MessageEditor { ) -> Task, Vec>)>> { let text = self.editor.read(cx).text(cx); let available_commands = self.available_commands.borrow().clone(); - let agent_name = self.agent_name.clone(); + let agent_id = self.agent_id.clone(); let build_task = self.build_content_blocks(full_mention_content, cx); cx.spawn(async move |_, _cx| { - Self::validate_slash_commands(&text, &available_commands, &agent_name)?; + Self::validate_slash_commands(&text, &available_commands, &agent_id)?; build_task.await }) } diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 333146bd7ac43f7a9c3851de1f4a7e6176609368..b2634a807fe0a0588536e822f2ec06c6d2099c09 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,9 +1,10 @@ +use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::ThreadStore; -use agent_client_protocol as acp; +use agent_client_protocol::{self as acp}; use agent_settings::AgentSettings; use chrono::Utc; use db::kvp::KEY_VALUE_STORE; @@ -14,7 +15,7 @@ use gpui::{ Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::Event as ProjectEvent; +use project::{AgentId, Event as ProjectEvent}; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; @@ -91,7 +92,7 @@ impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { fn from(info: &ActiveThreadInfo) -> Self { Self { session_id: info.session_id.clone(), - cwd: None, + work_dirs: None, title: Some(info.title.clone()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -251,6 +252,7 @@ pub struct Sidebar { view: SidebarView, archive_view: Option>, _subscriptions: Vec, + _update_entries_task: Option>, } impl Sidebar { @@ -274,14 +276,14 @@ impl Sidebar { window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { MultiWorkspaceEvent::ActiveWorkspaceChanged => { - this.update_entries(cx); + this.update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceAdded(workspace) => { this.subscribe_to_workspace(workspace, window, cx); - this.update_entries(cx); + this.update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceRemoved(_) => { - this.update_entries(cx); + this.update_entries(false, cx); } }, ) @@ -293,33 +295,18 @@ impl Sidebar { if !query.is_empty() { this.selection.take(); } - this.update_entries(cx); - if !query.is_empty() { - this.selection = this - .contents - .entries - .iter() - .position(|entry| matches!(entry, ListEntry::Thread(_))) - .or_else(|| { - if this.contents.entries.is_empty() { - None - } else { - Some(0) - } - }); - } + this.update_entries(!query.is_empty(), cx); } }) .detach(); - let thread_store = ThreadStore::global(cx); - cx.observe_in(&thread_store, window, |this, _, _window, cx| { - this.update_entries(cx); + cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { + this.update_entries(false, cx); }) .detach(); cx.observe_flag::(window, |_is_enabled, this, _window, cx| { - this.update_entries(cx); + this.update_entries(false, cx); }) .detach(); @@ -328,7 +315,7 @@ impl Sidebar { for workspace in &workspaces { this.subscribe_to_workspace(workspace, window, cx); } - this.update_entries(cx); + this.update_entries(false, cx); }); let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0); @@ -337,6 +324,7 @@ impl Sidebar { .unwrap_or(false); Self { + _update_entries_task: None, multi_workspace: multi_workspace.downgrade(), persistence_key, is_open, @@ -371,7 +359,7 @@ impl Sidebar { ProjectEvent::WorktreeAdded(_) | ProjectEvent::WorktreeRemoved(_) | ProjectEvent::WorktreeOrderChanged => { - this.update_entries(cx); + this.update_entries(false, cx); } _ => {} }, @@ -392,7 +380,7 @@ impl Sidebar { ) ) { this.prune_stale_worktree_workspaces(window, cx); - this.update_entries(cx); + this.update_entries(false, cx); } }, ) @@ -429,7 +417,7 @@ impl Sidebar { AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => { - this.update_entries(cx); + this.update_entries(false, cx); } }, ) @@ -487,7 +475,7 @@ impl Sidebar { .collect() } - fn rebuild_contents(&mut self, cx: &App) { + fn rebuild_contents(&mut self, thread_entries: Vec, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -501,7 +489,19 @@ impl Sidebar { .and_then(|panel| panel.read(cx).active_connection_view().cloned()) .and_then(|cv| cv.read(cx).parent_id(cx)); - let thread_store = ThreadStore::try_global(cx); + let mut threads_by_paths: HashMap> = HashMap::new(); + for row in thread_entries { + threads_by_paths + .entry(row.folder_paths.clone()) + .or_default() + .push(row); + } + + // Build a lookup for agent icons from the first workspace's AgentServerStore. + let agent_server_store = workspaces + .first() + .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); + let query = self.filter_editor.read(cx).text(cx); let previous = mem::take(&mut self.contents); @@ -586,14 +586,35 @@ impl Sidebar { if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); - if let Some(ref thread_store) = thread_store { - for meta in thread_store.read(cx).threads_for_paths(&path_list) { - seen_session_ids.insert(meta.id.clone()); + // Read threads from SidebarDb for this workspace's path list. + if let Some(rows) = threads_by_paths.get(&path_list) { + for row in rows { + seen_session_ids.insert(row.session_id.clone()); + let (agent, icon, icon_from_external_svg) = match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(id) => { + let custom_icon = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(&id)); + ( + Agent::Custom { id: id.clone() }, + IconName::Terminal, + custom_icon, + ) + } + }; threads.push(ThreadEntry { - agent: Agent::NativeAgent, - session_info: meta.into(), - icon: IconName::ZedAgent, - icon_from_external_svg: None, + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, status: AgentThreadStatus::default(), workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, @@ -608,7 +629,7 @@ impl Sidebar { } // Load threads from linked git worktrees of this workspace's repos. - if let Some(ref thread_store) = thread_store { + { let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = Vec::new(); for snapshot in root_repository_snapshots(workspace, cx) { @@ -639,25 +660,52 @@ impl Sidebar { None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), }; - for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) { - if !seen_session_ids.insert(meta.id.clone()) { - continue; + if let Some(rows) = threads_by_paths.get(worktree_path_list) { + for row in rows { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; + } + let (agent, icon, icon_from_external_svg) = match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(name) => { + let custom_icon = + agent_server_store.as_ref().and_then(|store| { + store + .read(cx) + .agent_icon(&AgentId(name.clone().into())) + }); + ( + Agent::Custom { + id: AgentId::new(name.clone()), + }, + IconName::Terminal, + custom_icon, + ) + } + }; + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: target_workspace.clone(), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: Some(worktree_name.clone()), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); } - threads.push(ThreadEntry { - agent: Agent::NativeAgent, - session_info: meta.into(), - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::default(), - workspace: target_workspace.clone(), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: Some(worktree_name.clone()), - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); } } } @@ -866,7 +914,7 @@ impl Sidebar { }; } - fn update_entries(&mut self, cx: &mut Context) { + fn update_entries(&mut self, select_first_thread: bool, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -878,18 +926,44 @@ impl Sidebar { let scroll_position = self.list_state.logical_scroll_top(); - self.rebuild_contents(cx); + let list_thread_entries_task = ThreadMetadataStore::global(cx).read(cx).list(cx); - self.list_state.reset(self.contents.entries.len()); - self.list_state.scroll_to(scroll_position); + self._update_entries_task.take(); + self._update_entries_task = Some(cx.spawn(async move |this, cx| { + let Some(thread_entries) = list_thread_entries_task.await.log_err() else { + return; + }; + this.update(cx, |this, cx| { + this.rebuild_contents(thread_entries, cx); - if had_notifications != self.has_notifications(cx) { - multi_workspace.update(cx, |_, cx| { - cx.notify(); - }); - } + if select_first_thread { + this.selection = this + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(_))) + .or_else(|| { + if this.contents.entries.is_empty() { + None + } else { + Some(0) + } + }); + } - cx.notify(); + this.list_state.reset(this.contents.entries.len()); + this.list_state.scroll_to(scroll_position); + + if had_notifications != this.has_notifications(cx) { + multi_workspace.update(cx, |_, cx| { + cx.notify(); + }); + } + + cx.notify(); + }) + .ok(); + })); } fn render_list_entry( @@ -1073,7 +1147,7 @@ impl Sidebar { move |this, _, _window, cx| { this.selection = None; this.expanded_groups.remove(&path_list_for_collapse); - this.update_entries(cx); + this.update_entries(false, cx); } })), ) @@ -1279,14 +1353,14 @@ impl Sidebar { } else { self.collapsed_groups.insert(path_list.clone()); } - self.update_entries(cx); + self.update_entries(false, cx); } fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context) {} fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.reset_filter_editor_text(window, cx) { - self.update_entries(cx); + self.update_entries(false, cx); } else { self.focus_handle.focus(window, cx); } @@ -1405,7 +1479,7 @@ impl Sidebar { let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0); self.expanded_groups.insert(path_list, current + 1); } - self.update_entries(cx); + self.update_entries(false, cx); } ListEntry::NewThread { workspace, .. } => { let workspace = workspace.clone(); @@ -1439,7 +1513,7 @@ impl Sidebar { panel.load_agent_thread( agent, session_info.session_id, - session_info.cwd, + session_info.work_dirs, session_info.title, true, window, @@ -1448,7 +1522,7 @@ impl Sidebar { }); } - self.update_entries(cx); + self.update_entries(false, cx); } fn open_workspace_and_activate_thread( @@ -1499,24 +1573,11 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { - let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| { - thread_store - .read(cx) - .thread_from_session_id(&session_info.session_id) - .map(|thread| thread.folder_paths.clone()) - }); - let path_list = saved_path_list.or_else(|| { - // we don't have saved metadata, so create path list based on the cwd - session_info - .cwd - .as_ref() - .map(|cwd| PathList::new(&[cwd.to_path_buf()])) - }); - - if let Some(path_list) = path_list { + if let Some(path_list) = &session_info.work_dirs { if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) { self.activate_thread(agent, session_info, &workspace, window, cx); } else { + let path_list = path_list.clone(); self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx); } return; @@ -1547,7 +1608,7 @@ impl Sidebar { if self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.remove(&path_list); - self.update_entries(cx); + self.update_entries(false, cx); } else if ix + 1 < self.contents.entries.len() { self.selection = Some(ix + 1); self.list_state.scroll_to_reveal_item(ix + 1); @@ -1571,7 +1632,7 @@ impl Sidebar { if !self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.insert(path_list); - self.update_entries(cx); + self.update_entries(false, cx); } } Some( @@ -1584,7 +1645,7 @@ impl Sidebar { let path_list = path_list.clone(); self.selection = Some(i); self.collapsed_groups.insert(path_list); - self.update_entries(cx); + self.update_entries(false, cx); break; } } @@ -1602,6 +1663,10 @@ impl Sidebar { .delete_thread(session_id.clone(), cx) .detach_and_log_err(cx); }); + + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id.clone(), cx)) + .detach_and_log_err(cx); } fn remove_selected_thread( @@ -1807,7 +1872,7 @@ impl Sidebar { let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0); this.expanded_groups.insert(path_list.clone(), current + 1); } - this.update_entries(cx); + this.update_entries(false, cx); })) .into_any_element() } @@ -1899,7 +1964,7 @@ impl Sidebar { .tooltip(Tooltip::text("Clear Search")) .on_click(cx.listener(|this, _, window, cx| { this.reset_filter_editor_text(window, cx); - this.update_entries(cx); + this.update_entries(false, cx); })), ) }) @@ -2153,7 +2218,8 @@ mod tests { use feature_flags::FeatureFlagAppExt as _; use fs::FakeFs; use gpui::TestAppContext; - use std::sync::Arc; + use pretty_assertions::assert_eq; + use std::{path::PathBuf, sync::Arc}; use util::path_list::PathList; fn init_test(cx: &mut TestAppContext) { @@ -2161,32 +2227,12 @@ mod tests { cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); } - fn make_test_thread(title: &str, updated_at: DateTime) -> agent::DbThread { - agent::DbThread { - title: title.to_string().into(), - messages: Vec::new(), - updated_at, - detailed_summary: None, - initial_project_snapshot: None, - cumulative_token_usage: Default::default(), - request_token_usage: Default::default(), - model: None, - profile: None, - imported: false, - subagent_context: None, - speed: None, - thinking_enabled: false, - thinking_effort: None, - draft_prompt: None, - ui_scroll_position: None, - } - } - async fn init_test_project( worktree_path: &str, cx: &mut TestAppContext, @@ -2237,45 +2283,72 @@ mod tests { path_list: &PathList, cx: &mut gpui::VisualTestContext, ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for i in 0..count { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - make_test_thread( - &format!("Thread {}", i + 1), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + format!("Thread {}", i + 1).into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); } - async fn save_thread_to_store( + async fn save_test_thread_metadata( session_id: &acp::SessionId, + path_list: PathList, + cx: &mut TestAppContext, + ) { + save_thread_metadata( + session_id.clone(), + "Test".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list, + cx, + ) + .await; + } + + async fn save_named_thread_metadata( + session_id: &str, + title: &str, path_list: &PathList, cx: &mut gpui::VisualTestContext, ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - session_id.clone(), - make_test_thread( - "Test", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(session_id)), + SharedString::from(title.to_string()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); } + async fn save_thread_metadata( + session_id: acp::SessionId, + title: SharedString, + updated_at: DateTime, + path_list: PathList, + cx: &mut TestAppContext, + ) { + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + let task = cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + }); + task.await.unwrap(); + } + fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { cx.run_until_parked(); sidebar.update_in(cx, |sidebar, window, cx| { @@ -2388,33 +2461,24 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Fix crash in project panel", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-2")), - make_test_thread( - "Add inline diff view", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix crash in project panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-2")), + "Add inline diff view".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); @@ -2439,20 +2503,15 @@ mod tests { // Single workspace with a thread let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-a1")), - make_test_thread( - "Thread A1", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-a1")), + "Thread A1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); @@ -2554,7 +2613,7 @@ mod tests { sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); + s.update_entries(false, cx); }); cx.run_until_parked(); @@ -2567,7 +2626,7 @@ mod tests { sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); + s.update_entries(false, cx); }); cx.run_until_parked(); @@ -2580,7 +2639,7 @@ mod tests { // Click collapse - should go back to showing 5 threads sidebar.update_in(cx, |s, _window, cx| { s.expanded_groups.remove(&path_list); - s.update_entries(cx); + s.update_entries(false, cx); }); cx.run_until_parked(); @@ -2661,7 +2720,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-1")), - cwd: None, + work_dirs: None, title: Some("Completed thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2684,7 +2743,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-2")), - cwd: None, + work_dirs: None, title: Some("Running thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2707,7 +2766,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-3")), - cwd: None, + work_dirs: None, title: Some("Error thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2730,7 +2789,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-4")), - cwd: None, + work_dirs: None, title: Some("Waiting thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -2753,7 +2812,7 @@ mod tests { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-5")), - cwd: None, + work_dirs: None, title: Some("Notified thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), @@ -3213,7 +3272,7 @@ mod tests { send_message(&panel, cx); let session_id_a = active_session_id(&panel, cx); - save_thread_to_store(&session_id_a, &path_list, cx).await; + save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; cx.update(|_, cx| { connection.send_update( @@ -3232,7 +3291,7 @@ mod tests { send_message(&panel, cx); let session_id_b = active_session_id(&panel, cx); - save_thread_to_store(&session_id_b, &path_list, cx).await; + save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; cx.run_until_parked(); @@ -3259,7 +3318,7 @@ mod tests { send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); - save_thread_to_store(&session_id_a, &path_list_a, cx).await; + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; cx.update(|_, cx| { connection_a.send_update( @@ -3323,25 +3382,20 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("t-1", "Fix crash in project panel", 3), ("t-2", "Add inline diff view", 2), ("t-3", "Refactor settings module", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3381,20 +3435,15 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Fix Crash In Project Panel", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix Crash In Project Panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); // Lowercase query matches mixed-case title. @@ -3428,21 +3477,16 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3481,24 +3525,19 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_a.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; } // Add a second workspace. @@ -3513,18 +3552,14 @@ mod tests { ("b1", "Refactor sidebar layout", 3), ("b2", "Fix typo in README", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_b.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3580,24 +3615,19 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_a.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; } // Add a second workspace. @@ -3612,18 +3642,14 @@ mod tests { ("b1", "Refactor sidebar layout", 3), ("b2", "Fix typo in README", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_b.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3701,7 +3727,6 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); // Create 8 threads. The oldest one has a unique name and will be // behind View More (only 5 shown by default). @@ -3711,18 +3736,14 @@ mod tests { } else { format!("Thread {}", i + 1) }; - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - make_test_thread( - &title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3758,20 +3779,15 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Important thread", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Important thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); // User focuses the sidebar and collapses the group using keyboard: @@ -3804,25 +3820,20 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); for (id, title, hour) in [ ("t-1", "Fix crash in panel", 3), ("t-2", "Fix lint warnings", 2), ("t-3", "Add new feature", 1), ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; } cx.run_until_parked(); @@ -3876,20 +3887,15 @@ mod tests { cx.run_until_parked(); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("hist-1")), - make_test_thread( - "Historical Thread", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("hist-1")), + "Historical Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -3938,32 +3944,25 @@ mod tests { let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("t-1")), - make_test_thread( - "Thread A", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("t-2")), - make_test_thread( - "Thread B", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-1")), + "Thread A".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-2")), + "Thread B".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4018,7 +4017,7 @@ mod tests { send_message(&panel, cx); let session_id = active_session_id(&panel, cx); - save_thread_to_store(&session_id, &path_list, cx).await; + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; cx.run_until_parked(); assert_eq!( @@ -4066,7 +4065,7 @@ mod tests { open_thread_with_connection(&panel_a, connection_a, cx); send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); - save_thread_to_store(&session_id_a, &path_list_a, cx).await; + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; // Add a second workspace with its own agent panel. let fs = cx.update(|_, cx| ::global(cx)); @@ -4105,7 +4104,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_a.clone(), - cwd: None, + work_dirs: None, title: Some("Test".into()), updated_at: None, created_at: None, @@ -4153,7 +4152,7 @@ mod tests { send_message(&panel_b, cx); let session_id_b = active_session_id(&panel_b, cx); let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - save_thread_to_store(&session_id_b, &path_list_b, cx).await; + save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; cx.run_until_parked(); // Opening a thread in a non-active workspace should NOT change @@ -4173,7 +4172,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_b.clone(), - cwd: None, + work_dirs: None, title: Some("Thread B".into()), updated_at: None, created_at: None, @@ -4232,7 +4231,7 @@ mod tests { open_thread_with_connection(&panel_b, connection_b2, cx); send_message(&panel_b, cx); let session_id_b2 = active_session_id(&panel_b, cx); - save_thread_to_store(&session_id_b2, &path_list_b, cx).await; + save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; cx.run_until_parked(); // Workspace A is still active, so focused_thread stays on session_id_a. @@ -4281,28 +4280,6 @@ mod tests { }); } - async fn save_named_thread( - session_id: &str, - title: &str, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(session_id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - } - async fn init_test_project_with_git( worktree_path: &str, cx: &mut TestAppContext, @@ -4346,8 +4323,8 @@ mod tests { let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await; - save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await; + save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4375,7 +4352,7 @@ mod tests { // Save a thread against a worktree path that doesn't exist yet. let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await; + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4467,8 +4444,8 @@ mod tests { let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread("thread-b", "Thread B", &paths_b, cx).await; + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4596,7 +4573,7 @@ mod tests { // Save a thread for the worktree path (no workspace for it). let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4713,8 +4690,8 @@ mod tests { let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread("thread-main", "Main Thread", &paths_main, cx).await; - save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await; + save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); @@ -4786,7 +4763,7 @@ mod tests { // Save a thread with path_list pointing to project-b. let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); let session_id = acp::SessionId::new(Arc::from("archived-1")); - save_thread_to_store(&session_id, &path_list_b, cx).await; + save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; // Ensure workspace A is active. multi_workspace.update_in(cx, |mw, window, cx| { @@ -4805,7 +4782,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id.clone(), - cwd: Some("/project-b".into()), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), title: Some("Archived Thread".into()), updated_at: None, created_at: None, @@ -4866,7 +4843,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("unknown-session")), - cwd: Some(std::path::PathBuf::from("/project-b")), + work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), title: Some("CWD Thread".into()), updated_at: None, created_at: None, @@ -4927,7 +4904,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("no-context-session")), - cwd: None, + work_dirs: None, title: Some("Contextless Thread".into()), updated_at: None, created_at: None, @@ -4971,7 +4948,6 @@ mod tests { // open workspace. let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); - save_thread_to_store(&session_id, &path_list_b, cx).await; assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), @@ -4984,7 +4960,7 @@ mod tests { Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: Some(path_list_b), title: Some("New WS Thread".into()), updated_at: None, created_at: None, diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 05a6b0925fb9151cc18d7096c8bf4f2674054073..7b986d045dddbf25fbe940a3b783c4f145781e8b 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -1,7 +1,8 @@ use acp_thread::{AgentConnection, StubAgentConnection}; use agent_client_protocol as acp; use agent_servers::{AgentServer, AgentServerDelegate}; -use gpui::{Entity, SharedString, Task, TestAppContext, VisualTestContext}; +use gpui::{Entity, Task, TestAppContext, VisualTestContext}; +use project::AgentId; use settings::SettingsStore; use std::any::Any; use std::rc::Rc; @@ -37,7 +38,7 @@ where ui::IconName::Ai } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Test".into() } diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 1ca763cb6a64f1d1b680e31c1ac55a4717762157..48d0b11b00103bbcf8399e6f7f77f8804051a465 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -408,7 +408,7 @@ mod tests { fn test_session(session_id: &str, title: &str) -> AgentSessionInfo { AgentSessionInfo { session_id: acp::SessionId::new(session_id), - cwd: None, + work_dirs: None, title: Some(title.to_string().into()), updated_at: None, created_at: None, @@ -608,7 +608,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, created_at: None, @@ -641,7 +641,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, created_at: None, @@ -671,7 +671,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, created_at: None, @@ -704,7 +704,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: None, updated_at: None, created_at: None, @@ -741,7 +741,7 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Server Title".into()), updated_at: None, created_at: None, @@ -775,7 +775,7 @@ mod tests { let session_id = acp::SessionId::new("known-session"); let sessions = vec![AgentSessionInfo { session_id, - cwd: None, + work_dirs: None, title: Some("Original".into()), updated_at: None, created_at: None, diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs index 092169efbf57f2947f2532e4a599e7b4935dc539..6961f78884d2fb5fb95830d91dad940ca9dc48e9 100644 --- a/crates/agent_ui/src/thread_history_view.rs +++ b/crates/agent_ui/src/thread_history_view.rs @@ -755,7 +755,7 @@ impl RenderOnce for HistoryEntryElement { panel.load_agent_thread( agent, entry.session_id.clone(), - entry.cwd.clone(), + entry.work_dirs.clone(), entry.title.clone(), true, window, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..66a9e05fbb294f253b3b25b782b35f3d503304e4 --- /dev/null +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -0,0 +1,528 @@ +use std::{path::Path, sync::Arc}; + +use agent::{ThreadStore, ZED_AGENT_ID}; +use agent_client_protocol as acp; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use collections::HashMap; +use db::{ + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, + sqlez_macros::sql, +}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use gpui::{AppContext as _, Entity, Global, Subscription, Task}; +use project::AgentId; +use ui::{App, Context, SharedString}; +use workspace::PathList; + +pub fn init(cx: &mut App) { + ThreadMetadataStore::init_global(cx); + + if cx.has_flag::() { + migrate_thread_metadata(cx); + } + cx.observe_flag::(|has_flag, cx| { + if has_flag { + migrate_thread_metadata(cx); + } + }) + .detach(); +} + +/// Migrate existing thread metadata from native agent thread store to the new metadata storage. +/// +/// TODO: Remove this after N weeks of shipping the sidebar +fn migrate_thread_metadata(cx: &mut App) { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + let list = store.list(cx); + cx.spawn(async move |this, cx| { + let Ok(list) = list.await else { + return; + }; + if list.is_empty() { + this.update(cx, |this, cx| { + let metadata = ThreadStore::global(cx) + .read(cx) + .entries() + .map(|entry| ThreadMetadata { + session_id: entry.id, + agent_id: None, + title: entry.title, + updated_at: entry.updated_at, + created_at: entry.created_at, + folder_paths: entry.folder_paths, + }) + .collect::>(); + for entry in metadata { + this.save(entry, cx).detach_and_log_err(cx); + } + }) + .ok(); + } + }) + .detach(); + }); +} + +struct GlobalThreadMetadataStore(Entity); +impl Global for GlobalThreadMetadataStore {} + +/// Lightweight metadata for any thread (native or ACP), enough to populate +/// the sidebar list and route to the correct load path when clicked. +#[derive(Debug, Clone)] +pub struct ThreadMetadata { + pub session_id: acp::SessionId, + /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. + pub agent_id: Option, + pub title: SharedString, + pub updated_at: DateTime, + pub created_at: Option>, + pub folder_paths: PathList, +} + +pub struct ThreadMetadataStore { + db: ThreadMetadataDb, + session_subscriptions: HashMap, +} + +impl ThreadMetadataStore { + #[cfg(not(any(test, feature = "test-support")))] + pub fn init_global(cx: &mut App) { + if cx.has_global::() { + return; + } + + let db = THREAD_METADATA_DB.clone(); + let thread_store = cx.new(|cx| Self::new(db, cx)); + cx.set_global(GlobalThreadMetadataStore(thread_store)); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn init_global(cx: &mut App) { + let thread = std::thread::current(); + let test_name = thread.name().unwrap_or("unknown_test"); + let db_name = format!("THREAD_METADATA_DB_{}", test_name); + let db = smol::block_on(db::open_test_db::(&db_name)); + let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx)); + cx.set_global(GlobalThreadMetadataStore(thread_store)); + } + + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|store| store.0.clone()) + } + + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub fn list(&self, cx: &App) -> Task>> { + let db = self.db.clone(); + cx.background_spawn(async move { + let s = db.list()?; + Ok(s) + }) + } + + pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context) -> Task> { + if !cx.has_flag::() { + return Task::ready(Ok(())); + } + + let db = self.db.clone(); + cx.spawn(async move |this, cx| { + db.save(metadata).await?; + this.update(cx, |_this, cx| cx.notify()) + }) + } + + pub fn delete( + &mut self, + session_id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + if !cx.has_flag::() { + return Task::ready(Ok(())); + } + + let db = self.db.clone(); + cx.spawn(async move |this, cx| { + db.delete(session_id).await?; + this.update(cx, |_this, cx| cx.notify()) + }) + } + + fn new(db: ThreadMetadataDb, cx: &mut Context) -> Self { + let weak_store = cx.weak_entity(); + + cx.observe_new::(move |thread, _window, cx| { + let thread_entity = cx.entity(); + + cx.on_release({ + let weak_store = weak_store.clone(); + move |thread, cx| { + weak_store + .update(cx, |store, _cx| { + store.session_subscriptions.remove(thread.session_id()); + }) + .ok(); + } + }) + .detach(); + + weak_store + .update(cx, |this, cx| { + let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update); + this.session_subscriptions + .insert(thread.session_id().clone(), subscription); + }) + .ok(); + }) + .detach(); + + Self { + db, + session_subscriptions: HashMap::default(), + } + } + + fn handle_thread_update( + &mut self, + thread: Entity, + event: &acp_thread::AcpThreadEvent, + cx: &mut Context, + ) { + match event { + acp_thread::AcpThreadEvent::NewEntry + | acp_thread::AcpThreadEvent::EntryUpdated(_) + | acp_thread::AcpThreadEvent::TitleUpdated => { + let metadata = Self::metadata_for_acp_thread(thread.read(cx), cx); + self.save(metadata, cx).detach_and_log_err(cx); + } + _ => {} + } + } + + fn metadata_for_acp_thread(thread: &acp_thread::AcpThread, cx: &App) -> ThreadMetadata { + let session_id = thread.session_id().clone(); + let title = thread.title(); + let updated_at = Utc::now(); + + let agent_id = thread.connection().agent_id(); + + let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + None + } else { + Some(agent_id) + }; + + let folder_paths = { + let project = thread.project().read(cx); + let paths: Vec> = project + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect(); + PathList::new(&paths) + }; + + ThreadMetadata { + session_id, + agent_id, + title, + created_at: Some(updated_at), // handled by db `ON CONFLICT` + updated_at, + folder_paths, + } + } +} + +impl Global for ThreadMetadataStore {} + +#[derive(Clone)] +struct ThreadMetadataDb(ThreadSafeConnection); + +impl Domain for ThreadMetadataDb { + const NAME: &str = stringify!(ThreadMetadataDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_id TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + )]; +} + +db::static_connection!(THREAD_METADATA_DB, ThreadMetadataDb, []); + +impl ThreadMetadataDb { + /// List all sidebar thread metadata, ordered by updated_at descending. + pub fn list(&self) -> anyhow::Result> { + self.select::( + "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \ + FROM sidebar_threads \ + ORDER BY updated_at DESC" + )?() + } + + /// Upsert metadata for a thread. + pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> { + let id = row.session_id.0.clone(); + let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string()); + let title = row.title.to_string(); + let updated_at = row.updated_at.to_rfc3339(); + let created_at = row.created_at.map(|dt| dt.to_rfc3339()); + let serialized = row.folder_paths.serialize(); + let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() { + (None, None) + } else { + (Some(serialized.paths), Some(serialized.order)) + }; + + self.write(move |conn| { + let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(session_id) DO UPDATE SET \ + agent_id = excluded.agent_id, \ + title = excluded.title, \ + updated_at = excluded.updated_at, \ + folder_paths = excluded.folder_paths, \ + folder_paths_order = excluded.folder_paths_order"; + let mut stmt = Statement::prepare(conn, sql)?; + let mut i = stmt.bind(&id, 1)?; + i = stmt.bind(&agent_id, i)?; + i = stmt.bind(&title, i)?; + i = stmt.bind(&updated_at, i)?; + i = stmt.bind(&created_at, i)?; + i = stmt.bind(&folder_paths, i)?; + stmt.bind(&folder_paths_order, i)?; + stmt.exec() + }) + .await + } + + /// Delete metadata for a single thread. + pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> { + let id = session_id.0.clone(); + self.write(move |conn| { + let mut stmt = + Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?; + stmt.bind(&id, 1)?; + stmt.exec() + }) + .await + } +} + +impl Column for ThreadMetadata { + fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> { + let (id, next): (Arc, i32) = Column::column(statement, start_index)?; + let (agent_id, next): (Option, i32) = Column::column(statement, next)?; + let (title, next): (String, i32) = Column::column(statement, next)?; + let (updated_at_str, next): (String, i32) = Column::column(statement, next)?; + let (created_at_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_order_str, next): (Option, i32) = + Column::column(statement, next)?; + + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); + let created_at = created_at_str + .as_deref() + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)); + + let folder_paths = folder_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: folder_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + Ok(( + ThreadMetadata { + session_id: acp::SessionId::new(id), + agent_id: agent_id.map(|id| AgentId::new(id)), + title: title.into(), + updated_at, + created_at, + folder_paths, + }, + next, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent::DbThread; + use gpui::TestAppContext; + + fn make_db_thread(title: &str, updated_at: DateTime) -> DbThread { + DbThread { + title: title.to_string().into(), + messages: Vec::new(), + updated_at, + detailed_summary: None, + initial_project_snapshot: None, + cumulative_token_usage: Default::default(), + request_token_usage: Default::default(), + model: None, + profile: None, + imported: false, + subagent_context: None, + speed: None, + thinking_enabled: false, + thinking_effort: None, + draft_prompt: None, + ui_scroll_position: None, + } + } + + #[gpui::test] + async fn test_migrate_thread_metadata(cx: &mut TestAppContext) { + cx.update(|cx| { + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + // Verify the list is empty before migration + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 0); + + let now = Utc::now(); + + // Populate the native ThreadStore via save_thread + let save1 = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("session-1"), + make_db_thread("Thread 1", now), + PathList::default(), + cx, + ) + }) + }); + save1.await.unwrap(); + cx.run_until_parked(); + + let save2 = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("session-2"), + make_db_thread("Thread 2", now), + PathList::default(), + cx, + ) + }) + }); + save2.await.unwrap(); + cx.run_until_parked(); + + // Run migration + cx.update(|cx| { + migrate_thread_metadata(cx); + }); + + cx.run_until_parked(); + + // Verify the metadata was migrated + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 2); + + let metadata1 = list + .iter() + .find(|m| m.session_id.0.as_ref() == "session-1") + .expect("session-1 should be in migrated metadata"); + assert_eq!(metadata1.title.as_ref(), "Thread 1"); + assert!(metadata1.agent_id.is_none()); + + let metadata2 = list + .iter() + .find(|m| m.session_id.0.as_ref() == "session-2") + .expect("session-2 should be in migrated metadata"); + assert_eq!(metadata2.title.as_ref(), "Thread 2"); + assert!(metadata2.agent_id.is_none()); + } + + #[gpui::test] + async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) { + cx.update(|cx| { + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + // Pre-populate the metadata store with existing data + let existing_metadata = ThreadMetadata { + session_id: acp::SessionId::new("existing-session"), + agent_id: None, + title: "Existing Thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + folder_paths: PathList::default(), + }; + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(existing_metadata, cx).detach(); + }); + }); + + cx.run_until_parked(); + + // Add an entry to native thread store that should NOT be migrated + let save_task = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("native-session"), + make_db_thread("Native Thread", Utc::now()), + PathList::default(), + cx, + ) + }) + }); + save_task.await.unwrap(); + cx.run_until_parked(); + + // Run migration - should skip because metadata store is not empty + cx.update(|cx| { + migrate_thread_metadata(cx); + }); + + cx.run_until_parked(); + + // Verify only the existing metadata is present (migration was skipped) + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].session_id.0.as_ref(), "existing-session"); + } +} diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index ce5cae4830be732cbc6ca0156d61eb3c48dae888..237a6c539c6669df0df535ae91a7ba9fa99acf9f 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -16,7 +16,7 @@ use gpui::{ }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::{AgentServerStore, ExternalAgentServerName}; +use project::{AgentId, AgentServerStore}; use theme::ActiveTheme; use ui::{ ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, @@ -530,9 +530,9 @@ impl ThreadsArchiveView { (IconName::ChevronDown, Color::Muted) }; - let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent { + let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent { let store = agent_server_store.read(cx); - let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + let icon = store.agent_icon(&id); if let Some(icon) = icon { Icon::from_external_svg(icon) @@ -584,24 +584,24 @@ impl ThreadsArchiveView { let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); struct AgentMenuItem { - id: ExternalAgentServerName, + id: AgentId, display_name: SharedString, } let agent_items = agent_server_store .external_agents() - .map(|name| { + .map(|agent_id| { let display_name = agent_server_store - .agent_display_name(name) + .agent_display_name(agent_id) .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(agent_id)) .map(|a| a.name().clone()) }) - .unwrap_or_else(|| name.0.clone()); + .unwrap_or_else(|| agent_id.0.clone()); AgentMenuItem { - id: name.clone(), + id: agent_id.clone(), display_name, } }) @@ -614,7 +614,7 @@ impl ThreadsArchiveView { let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|store| store.agent(&item.id)) .and_then(|a| a.icon_path().cloned()) }); @@ -627,7 +627,7 @@ impl ThreadsArchiveView { entry = entry.icon_color(Color::Muted).handler({ let this = this.clone(); let agent = Agent::Custom { - name: item.id.0.clone(), + id: item.id.clone(), }; move |window, cx| { this.update(cx, |this, cx| { diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index ee214e07ffb526f1c4ef89cc9301b4ea7e8d6ebf..7b6a563582abe89022d9d1684275dc850d28b23b 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -2,7 +2,7 @@ use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; -use project::agent_server_store::GEMINI_NAME; +use project::agent_server_store::GEMINI_ID; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -39,7 +39,7 @@ impl AcpOnboardingModal { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }, window, cx, diff --git a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs index 3a9010b0a155873e658946b4155f09f8867e498a..c8ae51850325d674ae45eac22891cdcd0c948465 100644 --- a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs @@ -2,7 +2,7 @@ use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; -use project::agent_server_store::CLAUDE_AGENT_NAME; +use project::agent_server_store::CLAUDE_AGENT_ID; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -39,7 +39,7 @@ impl ClaudeCodeOnboardingModal { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, window, cx, diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index 7b9f822a539c8d1e0a29bdef0bccee5d4a55721e..b49cc4d53f50eeb5ea10216867257332c5354cb4 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -50,6 +50,7 @@ use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal}; use language_model::{LanguageModelRegistry, SelectedModel}; use project::Project; use settings::SettingsStore; +use util::path_list::PathList; use crate::headless::AgentCliAppState; @@ -370,7 +371,11 @@ async fn run_agent( let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = match cx - .update(|cx| connection.clone().new_session(project, workdir, cx)) + .update(|cx| { + connection + .clone() + .new_session(project, PathList::new(&[workdir]), cx) + }) .await { Ok(t) => t, diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 79d6e52097d17cadc0271cb09de4ab283c6d93b8..b0a7e965f093afead16e2e9f2b5f7df44298a314 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -11,14 +11,14 @@ use http_client::{AsyncBody, HttpClient}; use serde::Deserialize; use settings::Settings as _; -use crate::DisableAiSettings; +use crate::{AgentId, DisableAiSettings}; const REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; const REFRESH_THROTTLE_DURATION: Duration = Duration::from_secs(60 * 60); #[derive(Clone, Debug)] pub struct RegistryAgentMetadata { - pub id: SharedString, + pub id: AgentId, pub name: SharedString, pub description: SharedString, pub version: SharedString, @@ -55,7 +55,7 @@ impl RegistryAgent { } } - pub fn id(&self) -> &SharedString { + pub fn id(&self) -> &AgentId { &self.metadata().id } @@ -167,8 +167,8 @@ impl AgentRegistryStore { &self.agents } - pub fn agent(&self, id: &str) -> Option<&RegistryAgent> { - self.agents.iter().find(|agent| agent.id().as_ref() == id) + pub fn agent(&self, id: &AgentId) -> Option<&RegistryAgent> { + self.agents.iter().find(|agent| agent.id() == id) } pub fn is_fetching(&self) -> bool { @@ -364,7 +364,7 @@ async fn build_registry_agents( .await?; let metadata = RegistryAgentMetadata { - id: entry.id.into(), + id: AgentId::new(entry.id), name: entry.name.into(), description: entry.description.into(), version: entry.version.into(), diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 4a7c2b03a4e03ddfa31bed24254ebe275a17c224..d5acacb912d085121c4c370046c9c7bd734c817c 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -61,28 +61,43 @@ impl std::fmt::Debug for AgentServerCommand { } } -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExternalAgentServerName(pub SharedString); +#[derive( + Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(transparent)] +pub struct AgentId(pub SharedString); + +impl AgentId { + pub fn new(id: impl Into) -> Self { + AgentId(id.into()) + } +} -impl std::fmt::Display for ExternalAgentServerName { +impl std::fmt::Display for AgentId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } -impl From<&'static str> for ExternalAgentServerName { +impl From<&'static str> for AgentId { fn from(value: &'static str) -> Self { - ExternalAgentServerName(value.into()) + AgentId(value.into()) } } -impl From for SharedString { - fn from(value: ExternalAgentServerName) -> Self { +impl From for SharedString { + fn from(value: AgentId) -> Self { value.0 } } -impl std::borrow::Borrow for ExternalAgentServerName { +impl AsRef for AgentId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::borrow::Borrow for AgentId { fn borrow(&self) -> &str { &self.0 } @@ -163,7 +178,7 @@ impl ExternalAgentEntry { pub struct AgentServerStore { state: AgentServerStoreState, - pub external_agents: HashMap, + pub external_agents: HashMap, } pub struct AgentServersUpdated; @@ -228,7 +243,7 @@ impl AgentServerStore { .as_ref() .map(|path| SharedString::from(path.clone())); let icon = icon_path; - let agent_server_name = ExternalAgentServerName(agent_name.clone().into()); + let agent_server_name = AgentId(agent_name.clone().into()); self.external_agents .entry(agent_server_name.clone()) .and_modify(|entry| { @@ -285,13 +300,13 @@ impl AgentServerStore { cx.emit(AgentServersUpdated); } - pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_icon(&self, name: &AgentId) -> Option { self.external_agents .get(name) .and_then(|entry| entry.icon.clone()) } - pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_source(&self, name: &AgentId) -> Option { self.external_agents.get(name).map(|entry| entry.source) } } @@ -337,7 +352,7 @@ pub fn resolve_extension_icon_path( } impl AgentServerStore { - pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_display_name(&self, name: &AgentId) -> Option { self.external_agents .get(name) .and_then(|entry| entry.display_name.clone()) @@ -424,7 +439,7 @@ impl AgentServerStore { // Insert extension agents before custom/registry so registry entries override extensions. for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() { - let name = ExternalAgentServerName(agent_name.clone().into()); + let name = AgentId(agent_name.clone().into()); let mut env = env.clone(); if let Some(settings_env) = new_settings @@ -463,7 +478,7 @@ impl AgentServerStore { for (name, settings) in new_settings.iter() { match settings { CustomAgentServerSettings::Custom { command, .. } => { - let agent_name = ExternalAgentServerName(name.clone().into()); + let agent_name = AgentId(name.clone().into()); self.external_agents.insert( agent_name.clone(), ExternalAgentEntry::new( @@ -485,7 +500,7 @@ impl AgentServerStore { continue; }; - let agent_name = ExternalAgentServerName(name.clone().into()); + let agent_name = AgentId(name.clone().into()); match agent { RegistryAgent::Binary(agent) => { if !agent.supports_current_platform { @@ -650,7 +665,7 @@ impl AgentServerStore { pub fn get_external_agent( &mut self, - name: &ExternalAgentServerName, + name: &AgentId, ) -> Option<&mut (dyn ExternalAgentServer + 'static)> { self.external_agents .get_mut(name) @@ -668,7 +683,7 @@ impl AgentServerStore { } } - pub fn external_agents(&self) -> impl Iterator { + pub fn external_agents(&self) -> impl Iterator { self.external_agents.keys() } @@ -777,12 +792,12 @@ impl AgentServerStore { .names .into_iter() .map(|name| { - let agent_name = ExternalAgentServerName(name.into()); + let agent_id = AgentId(name.into()); let (icon, display_name, source) = metadata - .remove(&agent_name) + .remove(&agent_id) .or_else(|| { AgentRegistryStore::try_global(cx) - .and_then(|store| store.read(cx).agent(&agent_name.0)) + .and_then(|store| store.read(cx).agent(&agent_id)) .map(|s| { ( s.icon_path().cloned(), @@ -795,13 +810,13 @@ impl AgentServerStore { let agent = RemoteExternalAgentServer { project_id: *project_id, upstream_client: upstream_client.clone(), - name: agent_name.clone(), + name: agent_id.clone(), new_version_available_tx: new_version_available_txs - .remove(&agent_name) + .remove(&agent_id) .flatten(), }; ( - agent_name, + agent_id, ExternalAgentEntry::new( Box::new(agent) as Box, source, @@ -877,10 +892,7 @@ impl AgentServerStore { Ok(()) } - pub fn get_extension_id_for_agent( - &mut self, - name: &ExternalAgentServerName, - ) -> Option> { + pub fn get_extension_id_for_agent(&mut self, name: &AgentId) -> Option> { self.external_agents.get_mut(name).and_then(|entry| { entry .server @@ -894,7 +906,7 @@ impl AgentServerStore { struct RemoteExternalAgentServer { project_id: u64, upstream_client: Entity, - name: ExternalAgentServerName, + name: AgentId, new_version_available_tx: Option>>, } @@ -1434,9 +1446,9 @@ impl ExternalAgentServer for LocalCustomAgent { } } -pub const GEMINI_NAME: &str = "gemini"; -pub const CLAUDE_AGENT_NAME: &str = "claude-acp"; -pub const CODEX_NAME: &str = "codex-acp"; +pub const GEMINI_ID: &str = "gemini"; +pub const CLAUDE_AGENT_ID: &str = "claude-acp"; +pub const CODEX_ID: &str = "codex-acp"; #[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)] pub struct AllAgentServersSettings(pub HashMap); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ed8884cd68c6df32375686dd5ceb41b21cbb5cdd..d26f60350b5656b1730993ff76e07c31139c41da 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -43,9 +43,7 @@ use crate::{ worktree_store::WorktreeIdCounter, }; pub use agent_registry_store::{AgentRegistryStore, RegistryAgent}; -pub use agent_server_store::{ - AgentServerStore, AgentServersUpdated, ExternalAgentServerName, ExternalAgentSource, -}; +pub use agent_server_store::{AgentId, AgentServerStore, AgentServersUpdated, ExternalAgentSource}; pub use git_store::{ ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate, git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}, diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index 40961cd0267db9effc897376de9531d5ceb6f463..38da460023ebb6c4d24dd02f21928db7e3cd54e3 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -27,7 +27,7 @@ impl ExternalAgentServer for NoopExternalAgent { #[test] fn external_agent_server_name_display() { - let name = ExternalAgentServerName(SharedString::from("Ext: Tool")); + let name = AgentId(SharedString::from("Ext: Tool")); let mut s = String::new(); write!(&mut s, "{name}").unwrap(); assert_eq!(s, "Ext: Tool"); @@ -39,7 +39,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { // Seed with a couple of agents that will be replaced by extensions store.external_agents.insert( - ExternalAgentServerName(SharedString::from("foo-agent")), + AgentId(SharedString::from("foo-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -48,7 +48,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("bar-agent")), + AgentId(SharedString::from("bar-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -57,7 +57,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("custom")), + AgentId(SharedString::from("custom")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index b45f76fbd6835f0cf94f8622df10c2eee3b3c9d3..1824fbec0d172e2bac626e726d305883818d51ad 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -9,14 +9,14 @@ use std::{any::Any, path::PathBuf, sync::Arc}; #[test] fn extension_agent_constructs_proper_display_names() { // Verify the display name format for extension-provided agents - let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent")); + let name1 = AgentId(SharedString::from("Extension: Agent")); assert!(name1.0.contains(": ")); - let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent")); + let name2 = AgentId(SharedString::from("MyExt: MyAgent")); assert_eq!(name2.0, "MyExt: MyAgent"); // Non-extension agents shouldn't have the separator - let custom = ExternalAgentServerName(SharedString::from("custom")); + let custom = AgentId(SharedString::from("custom")); assert!(!custom.0.contains(": ")); } @@ -47,7 +47,7 @@ fn sync_removes_only_extension_provided_agents() { // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") store.external_agents.insert( - ExternalAgentServerName(SharedString::from("Ext1: Agent1")), + AgentId(SharedString::from("Ext1: Agent1")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Extension, @@ -56,7 +56,7 @@ fn sync_removes_only_extension_provided_agents() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("Ext2: Agent2")), + AgentId(SharedString::from("Ext2: Agent2")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Extension, @@ -65,7 +65,7 @@ fn sync_removes_only_extension_provided_agents() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("custom-agent")), + AgentId(SharedString::from("custom-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -84,7 +84,7 @@ fn sync_removes_only_extension_provided_agents() { assert!( store .external_agents - .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent"))) + .contains_key(&AgentId(SharedString::from("custom-agent"))) ); } @@ -117,7 +117,7 @@ fn archive_launcher_constructs_with_all_fields() { }; // Verify display name construction - let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent")); + let expected_name = AgentId(SharedString::from("GitHub Agent")); assert_eq!(expected_name.0, "GitHub Agent"); } @@ -170,7 +170,7 @@ async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAp fn sync_extension_agents_registers_archive_launcher() { use extension::AgentServerManifestEntry; - let expected_name = ExternalAgentServerName(SharedString::from("Release Agent")); + let expected_name = AgentId(SharedString::from("Release Agent")); assert_eq!(expected_name.0, "Release Agent"); // Verify the manifest entry structure for archive-based installation diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 7d605c7924a7d9c25a89634ca7339a457fb99ae4..bd012e43dd0c073d78822a5e831af1d78503e8ab 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -5,7 +5,7 @@ use std::{ use crate::paths::SanitizedPath; use itertools::Itertools; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; /// A list of absolute paths, in a specific order. /// @@ -23,7 +23,7 @@ pub struct PathList { order: Arc<[usize]>, } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct SerializedPathList { pub paths: String, pub order: String, @@ -119,19 +119,6 @@ impl PathList { } } -impl Serialize for PathList { - fn serialize(&self, serializer: S) -> Result { - self.paths.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for PathList { - fn deserialize>(deserializer: D) -> Result { - let paths: Vec = Vec::deserialize(deserializer)?; - Ok(PathList::new(&paths)) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 37642b012edcd133dfe770a4c57c5404658582b5..310632648053036162491a54d346eb5c98f13994 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -103,10 +103,11 @@ use { feature_flags::FeatureFlagAppExt as _, git_ui::project_diff::ProjectDiff, gpui::{ - Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, - VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size, + Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext, + WindowBounds, WindowHandle, WindowOptions, point, px, size, }, image::RgbaImage, + project::AgentId, project_panel::ProjectPanel, settings::{NotifyWhenAgentWaiting, Settings as _}, settings_ui::SettingsWindow, @@ -1958,7 +1959,7 @@ impl AgentServer for StubAgentServer { ui::IconName::ZedAssistant } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Visual Test Agent".into() } diff --git a/docs/acp-threads-in-sidebar-plan.md b/docs/acp-threads-in-sidebar-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..e4a23418d49bb3ad7cd688f5110341edc5c3abf2 --- /dev/null +++ b/docs/acp-threads-in-sidebar-plan.md @@ -0,0 +1,580 @@ +# Plan: Show ACP Threads in the Sidebar (Revised) + +## Problem + +The sidebar currently only shows **Zed-native agent threads** (from `ThreadStore`/`ThreadsDatabase`). ACP threads (Claude Code, Codex, Gemini, etc.) are invisible in the sidebar once they're no longer live. + +## Root Cause + +`ThreadStore` and `ThreadsDatabase` only persist metadata for native threads. When `rebuild_contents` populates the sidebar, it reads from `ThreadStore` for historical threads and overlays live info from the `AgentPanel` — but non-native threads never get written to `ThreadStore`, so once they stop being live, they disappear. + +## Solution Overview (Revised) + +**Key change from the original plan:** We completely remove the sidebar's dependency on `ThreadStore`. Instead, the `Sidebar` itself owns a **single, unified persistence layer** — a new `SidebarDb` domain stored in the workspace DB — that tracks metadata for _all_ thread types (native and ACP). The sidebar becomes the single source of truth for what threads appear in the list. + +### Why Remove the ThreadStore Dependency? + +1. **Single responsibility** — The sidebar is the only consumer of "which threads to show in the list." Having it depend on `ThreadStore` (which exists primarily for native agent save/load) creates an indirect coupling that makes ACP integration awkward. +2. **No merge logic** — The original plan required merging native `ThreadStore` data with a separate `AcpThreadMetadataDb` in `ThreadStore::reload`. By moving all sidebar metadata into one place, there's nothing to merge. +3. **Simpler data flow** — Writers (native agent, ACP connections) push metadata to the sidebar DB. The sidebar reads from one table. No cross-crate coordination needed. +4. **ThreadStore stays focused** — `ThreadStore` continues to manage native thread blob storage (save/load message data) without being polluted with sidebar display concerns. + +### Architecture + +``` + ┌─────────────────────┐ ┌─────────────────────────┐ + │ NativeAgent │ │ ACP Connections │ + │ (on save_thread) │ │ (on create/update/list) │ + └──────────┬──────────┘ └──────────┬──────────────┘ + │ │ + │ save_sidebar_thread() │ + └──────────┬─────────────────┘ + ▼ + ┌───────────────────┐ + │ SidebarDb │ + │ (workspace DB) │ + │ sidebar_threads │ + └────────┬──────────┘ + │ + ▼ + ┌───────────────────┐ + │ Sidebar │ + │ rebuild_contents │ + └───────────────────┘ +``` + +--- + +## Step 1: Create `SidebarDb` Domain in `sidebar.rs` + +**File:** `crates/agent_ui/src/sidebar.rs` + +Add a `SidebarDb` domain using `db::static_connection!`, co-located in the sidebar module (or a small `persistence` submodule within `sidebar.rs` if it helps organization, but keeping it in the same file is fine for now). + +### Schema + +```rust +use db::{ + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, + sqlez_macros::sql, +}; + +/// Lightweight metadata for any thread (native or ACP), enough to populate +/// the sidebar list and route to the correct load path when clicked. +#[derive(Debug, Clone)] +pub struct SidebarThreadRow { + pub session_id: acp::SessionId, + /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. + pub agent_name: Option, + pub title: SharedString, + pub updated_at: DateTime, + pub created_at: Option>, + pub folder_paths: PathList, +} + +pub struct SidebarDb(ThreadSafeConnection); + +impl Domain for SidebarDb { + const NAME: &str = stringify!(SidebarDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_name TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + )]; +} + +db::static_connection!(SIDEBAR_DB, SidebarDb, []); +``` + +### CRUD Methods + +```rust +impl SidebarDb { + /// Upsert metadata for a thread (native or ACP). + pub async fn save(&self, row: &SidebarThreadRow) -> Result<()> { + let id = row.session_id.0.clone(); + let agent_name = row.agent_name.clone(); + let title = row.title.to_string(); + let updated_at = row.updated_at.to_rfc3339(); + let created_at = row.created_at.map(|dt| dt.to_rfc3339()); + let serialized = row.folder_paths.serialize(); + let (fp, fpo) = if row.folder_paths.is_empty() { + (None, None) + } else { + (Some(serialized.paths), Some(serialized.order)) + }; + + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "INSERT INTO sidebar_threads(session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(session_id) DO UPDATE SET + agent_name = excluded.agent_name, + title = excluded.title, + updated_at = excluded.updated_at, + folder_paths = excluded.folder_paths, + folder_paths_order = excluded.folder_paths_order", + )?; + let mut i = stmt.bind(&id, 1)?; + i = stmt.bind(&agent_name, i)?; + i = stmt.bind(&title, i)?; + i = stmt.bind(&updated_at, i)?; + i = stmt.bind(&created_at, i)?; + i = stmt.bind(&fp, i)?; + stmt.bind(&fpo, i)?; + stmt.exec() + }) + .await + } + + /// List all sidebar thread metadata, ordered by updated_at descending. + pub fn list(&self) -> Result> { + self.select::( + "SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + ORDER BY updated_at DESC" + )?(()) + } + + /// List threads for a specific folder path set. + pub fn list_for_paths(&self, paths: &PathList) -> Result> { + let serialized = paths.serialize(); + self.select_bound::(sql!( + SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + WHERE folder_paths = ? + ORDER BY updated_at DESC + ))?(serialized.paths) + } + + /// Look up a single thread by session ID. + pub fn get(&self, session_id: &acp::SessionId) -> Result> { + let id = session_id.0.clone(); + self.select_row_bound::, SidebarThreadRow>(sql!( + SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + WHERE session_id = ? + ))?(id) + } + + /// Return the total number of rows in the table. + pub fn count(&self) -> Result { + let count: (i32, i32) = self.select_row(sql!( + SELECT COUNT(*) FROM sidebar_threads + ))?(())?.unwrap_or_default(); + Ok(count.0 as usize) + } + + /// Delete metadata for a single thread. + pub async fn delete(&self, session_id: acp::SessionId) -> Result<()> { + let id = session_id.0; + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM sidebar_threads WHERE session_id = ?", + )?; + stmt.bind(&id, 1)?; + stmt.exec() + }) + .await + } + + /// Delete all thread metadata. + pub async fn delete_all(&self) -> Result<()> { + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM sidebar_threads", + )?; + stmt.exec() + }) + .await + } +} +``` + +### `Column` Implementation + +```rust +impl Column for SidebarThreadRow { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let (id, next): (Arc, i32) = Column::column(statement, start_index)?; + let (agent_name, next): (Option, i32) = Column::column(statement, next)?; + let (title, next): (String, i32) = Column::column(statement, next)?; + let (updated_at_str, next): (String, i32) = Column::column(statement, next)?; + let (created_at_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_order_str, next): (Option, i32) = Column::column(statement, next)?; + + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); + let created_at = created_at_str + .as_deref() + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)); + + let folder_paths = folder_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: folder_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + Ok(( + SidebarThreadRow { + session_id: acp::SessionId::new(id), + agent_name, + title: title.into(), + updated_at, + created_at, + folder_paths, + }, + next, + )) + } +} +``` + +**Key points:** + +- `SIDEBAR_DB` is a `LazyLock` static — initialized on first use, no manual connection management. +- The `agent_name` column is `NULL` for native Zed threads and a string like `"claude-code"` for ACP agents. This replaces the `agent_type` field from the original plan. +- The DB file lives alongside other `static_connection!` databases. +- `ThreadsDatabase` and `ThreadStore` are **completely unchanged** by this step. + +--- + +## Step 2: Replace `ThreadStore` Reads in `rebuild_contents` with `SidebarDb` Reads + +**File:** `crates/agent_ui/src/sidebar.rs` + +### Remove `ThreadStore` Dependency + +1. **Remove** `ThreadStore::global(cx)` and `ThreadStore::try_global(cx)` from `Sidebar::new` and `rebuild_contents`. +2. **Remove** the `cx.observe_in(&thread_store, ...)` subscription that triggers `update_entries` when `ThreadStore` changes. +3. **Replace** `thread_store.read(cx).threads_for_paths(&path_list)` calls with `SIDEBAR_DB.list_for_paths(&path_list)` (or read all rows once at the top of `rebuild_contents` and index them in memory, which is simpler and avoids repeated DB calls). + +### New Data Flow in `rebuild_contents` + +```rust +fn rebuild_contents(&mut self, cx: &App) { + // ... existing workspace iteration setup ... + + // Read ALL sidebar thread metadata once, index by folder_paths. + let all_sidebar_threads = SIDEBAR_DB.list().unwrap_or_default(); + let mut threads_by_paths: HashMap> = HashMap::new(); + for row in all_sidebar_threads { + threads_by_paths + .entry(row.folder_paths.clone()) + .or_default() + .push(row); + } + + for (ws_index, workspace) in workspaces.iter().enumerate() { + // ... existing absorbed-workspace logic ... + + let path_list = workspace_path_list(workspace, cx); + + if should_load_threads { + let mut seen_session_ids: HashSet = HashSet::new(); + + // Read from SidebarDb instead of ThreadStore + if let Some(rows) = threads_by_paths.get(&path_list) { + for row in rows { + seen_session_ids.insert(row.session_id.clone()); + let (agent, icon) = match &row.agent_name { + None => (Agent::NativeAgent, IconName::ZedAgent), + Some(name) => ( + Agent::Custom { name: name.clone().into() }, + IconName::ZedAgent, // placeholder, resolved in Step 5 + ), + }; + threads.push(ThreadEntry { + agent, + session_info: AgentSessionInfo { + session_id: row.session_id.clone(), + cwd: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg: None, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); + } + } + + // ... existing linked git worktree logic, also reading from threads_by_paths ... + // ... existing live thread overlay logic (unchanged) ... + } + } +} +``` + +### What Changes + +- `rebuild_contents` reads from `SIDEBAR_DB` instead of `ThreadStore`. +- The `ThreadEntry.agent` field now carries `Agent::Custom { name }` for ACP threads, enabling correct routing in `activate_thread`. +- The live thread overlay logic (from `all_thread_infos_for_workspace`) is **unchanged** — it still reads from `AgentPanel` to get real-time status of running threads. + +### What Stays the Same + +- The entire workspace/absorbed-workspace/git-worktree structure. +- The live thread overlay pass. +- The notification tracking logic. +- The search/filter logic. + +--- + +## Step 3: Write Native Thread Metadata to `SidebarDb` + +**File:** `crates/agent_ui/src/sidebar.rs` and/or `crates/agent_ui/src/agent_panel.rs` + +When a native thread is saved (after conversation, on title update, etc.), we also write its metadata to `SidebarDb`. There are two approaches: + +### Option A: Subscribe to `ThreadStore` Changes (Recommended) + +Keep a one-directional sync: when `ThreadStore` finishes a `save_thread` or `reload`, the sidebar syncs the metadata to `SidebarDb`. This can be done in the sidebar's workspace subscription or by observing `ThreadStore` changes purely for the purpose of syncing (not for reading). + +```rust +// In Sidebar::subscribe_to_workspace or a dedicated sync method: +fn sync_native_threads_to_sidebar_db(&self, cx: &App) { + if let Some(thread_store) = ThreadStore::try_global(cx) { + let entries: Vec<_> = thread_store.read(cx).entries().collect(); + cx.background_spawn(async move { + for meta in entries { + SIDEBAR_DB.save(&SidebarThreadRow { + session_id: meta.id, + agent_name: None, // native + title: meta.title, + updated_at: meta.updated_at, + created_at: meta.created_at, + folder_paths: meta.folder_paths, + }).await.log_err(); + } + }).detach(); + } +} +``` + +### Option B: Write at the Point of Save + +In `AgentPanel` or wherever `thread_store.save_thread()` is called, also call `SIDEBAR_DB.save(...)`. This is more direct but requires touching more call sites. + +**Recommendation:** Option A is simpler for the initial implementation. We observe `ThreadStore` changes, diff against `SidebarDb`, and sync. Later, if we want to remove `ThreadStore` entirely from the write path for native threads, we can switch to Option B. + +--- + +## Step 4: Write ACP Thread Metadata to `SidebarDb` + +**File:** `crates/agent_ui/src/connection_view.rs` (or `agent_panel.rs`) + +When ACP sessions are created, updated, or listed, write metadata directly to `SidebarDb`: + +- **On new session creation:** After `connection.new_session()` returns the `AcpThread`, call `SIDEBAR_DB.save(...)`. +- **On title update:** ACP threads receive title updates via `SessionInfoUpdate`. When these come in, call `SIDEBAR_DB.save(...)` with the new title and updated timestamp. +- **On session list refresh:** When `AgentSessionList::list_sessions` returns for an ACP agent, bulk-sync the metadata into `SidebarDb`. + +After any write, call `cx.notify()` on the `Sidebar` entity (or use a channel/event) to trigger a `rebuild_contents`. + +### Triggering Sidebar Refresh + +Since the sidebar no longer observes `ThreadStore`, we need a mechanism to trigger `rebuild_contents` after DB writes. Options: + +1. **Emit an event from `AgentPanel`** — The sidebar already subscribes to `AgentPanelEvent`. Add a new variant like `AgentPanelEvent::ThreadMetadataChanged` and emit it after saving to `SidebarDb`. +2. **Use `cx.notify()` directly** — If the save happens within a `Sidebar` method, just call `self.update_entries(cx)`. +3. **Observe a lightweight signal entity** — A simple `Entity<()>` that gets notified after DB writes. + +**Recommendation:** Option 1 (emit from `AgentPanel`) is cleanest since the sidebar already subscribes to panel events. + +--- + +## Step 5: Handle Agent Icon Resolution for ACP Threads + +**File:** `crates/agent_ui/src/sidebar.rs` + +For ACP threads in the sidebar, we need the correct agent icon. The `agent_name` string stored in `SidebarDb` maps to an agent in the `AgentServerStore`, which has icon info. + +In `rebuild_contents`, after building the initial thread list from `SidebarDb`, resolve icons for ACP threads: + +```rust +// For ACP threads, look up the icon from the agent server store +if let Some(name) = &row.agent_name { + if let Some(agent_server_store) = /* get from workspace */ { + // resolve icon from agent_server_store using name + } +} +``` + +--- + +## Step 6: Handle Delete Operations Correctly + +**File:** `crates/agent_ui/src/sidebar.rs` + +When the user deletes a thread from the sidebar: + +- **All threads** → Delete from `SidebarDb` via `SIDEBAR_DB.delete(session_id)`. +- **Native threads** → _Also_ delete from `ThreadStore`/`ThreadsDatabase` (to clean up the blob data). +- **ACP threads** → Optionally notify the ACP server via `AgentSessionList::delete_session`. + +The `agent_name` field on `SidebarThreadRow` (or the `Agent` enum on `ThreadEntry`) tells us which path to take. + +When the user clears all history: + +```rust +// Delete all sidebar metadata +SIDEBAR_DB.delete_all().await?; +// Also clear native thread blobs +thread_store.delete_threads(cx); +// Optionally notify ACP servers +``` + +--- + +## Step 7: Handle `activate_thread` Routing + +**File:** `crates/agent_ui/src/sidebar.rs`, `crates/agent_ui/src/agent_panel.rs` + +In `activate_thread`, branch on the `Agent` variant: + +- `Agent::NativeAgent` → Call `panel.load_agent_thread(Agent::NativeAgent, session_id, ...)` (current behavior). +- `Agent::Custom { name }` → Call `panel.load_agent_thread(Agent::Custom { name }, session_id, ...)` so it routes to the correct `AgentConnection::load_session`. + +This is already partially set up — `activate_thread` takes an `Agent` parameter. The key change is that `ThreadEntry` now carries the correct `Agent` variant based on `SidebarThreadRow.agent_name`. + +--- + +## Step 8: Handle `activate_archived_thread` Without ThreadStore + +**File:** `crates/agent_ui/src/sidebar.rs` + +Currently, `activate_archived_thread` looks up `saved_path_list` from `ThreadStore`: + +```rust +let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| { + thread_store + .read(cx) + .thread_from_session_id(&session_info.session_id) + .map(|thread| thread.folder_paths.clone()) +}); +``` + +Replace this with a targeted `SidebarDb::get` lookup (single-row SELECT, no full table scan): + +```rust +let saved_path_list = SIDEBAR_DB + .get(&session_info.session_id) + .ok() + .flatten() + .map(|row| row.folder_paths); +``` + +--- + +## Step 9: Error Handling for Offline Agents + +When an ACP thread is clicked but the agent server is not running: + +- Show a toast/notification explaining the agent is offline. +- Keep the metadata in the sidebar (don't remove it). +- Optionally offer to start the agent server. + +--- + +## Step 10: Migration — Backfill Existing Native Threads + +On first launch after this change, the `SidebarDb` will be empty while `ThreadsDatabase` has existing native threads. We need a one-time backfill: + +```rust +// In Sidebar::new or a dedicated init method: +fn backfill_native_threads_if_needed(cx: &App) { + if SIDEBAR_DB.count() > 0 { + return; // Already populated + } + + if let Some(thread_store) = ThreadStore::try_global(cx) { + let entries: Vec<_> = thread_store.read(cx).entries().collect(); + cx.background_spawn(async move { + for meta in entries { + SIDEBAR_DB.save(&SidebarThreadRow { + session_id: meta.id, + agent_name: None, + title: meta.title, + updated_at: meta.updated_at, + created_at: meta.created_at, + folder_paths: meta.folder_paths, + }).await.log_err(); + } + }).detach(); + } +} +``` + +--- + +## Summary of Files to Change + +| File | Changes | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/agent_ui/Cargo.toml` | Add `db.workspace = true`, `sqlez.workspace = true`, `sqlez_macros.workspace = true`, `chrono.workspace = true` dependencies | +| `crates/agent_ui/src/sidebar.rs` | **Main changes.** Add `SidebarDb` domain + `SIDEBAR_DB` static + `SidebarThreadRow`. Replace all `ThreadStore` reads in `rebuild_contents` with `SidebarDb` reads. Update `activate_archived_thread`. Add native thread sync logic. Add backfill on first run. | +| `crates/agent_ui/src/agent_panel.rs` | Emit `AgentPanelEvent::ThreadMetadataChanged` after thread saves. Potentially write ACP metadata to `SidebarDb` here. | +| `crates/agent_ui/src/connection_view.rs` | Write ACP metadata to `SidebarDb` on session creation, title updates, and session list refreshes. | + +## What Is NOT Changed + +| File / Area | Why | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `threads` table schema | No migration needed — native blob persistence is completely untouched | +| `ThreadsDatabase` methods | `save_thread_sync`, `load_thread`, `list_threads`, `delete_thread`, `delete_threads` — all unchanged | +| `ThreadStore` struct/methods | Stays exactly as-is. It's still used for native thread blob save/load. The sidebar just no longer reads from it for display. | +| `NativeAgent::load_thread` / `open_thread` | These deserialize `DbThread` blobs — completely unaffected | +| `crates/acp_thread/` | No new persistence module needed there (unlike the original plan) | +| `crates/agent/src/db.rs` | `DbThreadMetadata` is unchanged — no `agent_type` field added | + +## Execution Order + +1. **SidebarDb domain** (Step 1) — Create `SidebarDb`, `SidebarThreadRow`, `SIDEBAR_DB` static, CRUD methods in `sidebar.rs`. +2. **Replace reads** (Step 2) — Swap `ThreadStore` reads in `rebuild_contents` for `SidebarDb` reads. +3. **Native write path** (Step 3) — Sync native thread metadata from `ThreadStore` into `SidebarDb`. +4. **ACP write path** (Step 4) — Write ACP thread metadata to `SidebarDb` from connection views. +5. **Icon resolution** (Step 5) — Resolve ACP agent icons in the sidebar. +6. **Delete path** (Step 6) — Route deletes to `SidebarDb` + native blob cleanup + ACP server notification. +7. **Activate routing** (Step 7) — Ensure `activate_thread` routes correctly based on `Agent` variant. +8. **Archive fix** (Step 8) — Update `activate_archived_thread` to use `SidebarDb`. +9. **Migration** (Step 10) — Backfill existing native threads on first run. +10. **Polish** (Step 9) — Error handling for offline agents. + +## Key Differences from Original Plan + +| Aspect | Original Plan | Revised Plan | +| ------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| **Where ACP metadata lives** | New `AcpThreadMetadataDb` in `crates/acp_thread/` | `SidebarDb` in `crates/agent_ui/src/sidebar.rs` | +| **Where sidebar reads from** | `ThreadStore` (which merges native + ACP) | `SidebarDb` directly (single source) | +| **ThreadStore changes** | Added `agent_type` to `DbThreadMetadata`, merge logic in `reload`, new save/delete methods | **None** — ThreadStore is untouched | +| **`crates/agent/src/db.rs` changes** | Added `agent_type: Option` to `DbThreadMetadata` | **None** | +| **Merge complexity** | Two data sources merged in `ThreadStore::reload` | No merge — one table, one read | +| **Crate dependencies** | `acp_thread` gains `db` dependency | `agent_ui` gains `db` dependency (more natural — it's a UI persistence concern) |