From 9afeb4e11d80c479ea81a8579faa2798c4887293 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 5 Mar 2026 12:22:28 -0800 Subject: [PATCH] Implement new Multi Agent UI (#50534) 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: Eric Co-authored-by: cameron Co-authored-by: Danilo Leal Co-authored-by: Anthony Eid Co-authored-by: John Tur --- Cargo.lock | 10 +- assets/icons/new_thread.svg | 4 + assets/icons/open_folder.svg | 4 + assets/keymaps/default-linux.json | 3 + assets/keymaps/default-macos.json | 3 + assets/keymaps/default-windows.json | 3 + crates/acp_thread/src/connection.rs | 5 +- crates/agent/src/agent.rs | 12 +- crates/agent/src/db.rs | 59 +- crates/agent/src/thread_store.rs | 4 + crates/agent_ui/src/agent_panel.rs | 307 +- crates/agent_ui/src/agent_ui.rs | 2 + crates/agent_ui/src/connection_view.rs | 14 + .../src/connection_view/thread_view.rs | 23 + crates/agent_ui/src/message_editor.rs | 2 +- crates/agent_ui/src/test_support.rs | 98 + crates/icons/src/icons.rs | 2 + crates/project_panel/src/project_panel.rs | 7 +- crates/recent_projects/src/recent_projects.rs | 22 +- crates/sidebar/Cargo.toml | 20 +- crates/sidebar/src/sidebar.rs | 3900 ++++++++++++----- crates/ui/src/components/ai/thread_item.rs | 96 +- crates/util/src/path_list.rs | 2 +- crates/workspace/src/welcome.rs | 2 +- crates/workspace/src/workspace.rs | 49 +- crates/zed/Cargo.toml | 1 - crates/zed/src/visual_test_runner.rs | 268 +- crates/zed/src/zed.rs | 5 +- crates/zed/src/zed/app_menus.rs | 2 +- 29 files changed, 3655 insertions(+), 1274 deletions(-) create mode 100644 assets/icons/new_thread.svg create mode 100644 assets/icons/open_folder.svg create mode 100644 crates/agent_ui/src/test_support.rs diff --git a/Cargo.lock b/Cargo.lock index ae4b1ba74f85a0ac8e0f5b081add26959c07eca9..4dbd905beb51bda4f5ff061179d144d9cd255e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15778,22 +15778,26 @@ name = "sidebar" version = "0.1.0" dependencies = [ "acp_thread", + "agent", + "agent-client-protocol", "agent_ui", + "assistant_text_thread", "chrono", "editor", "feature_flags", "fs", - "fuzzy", "gpui", - "picker", + "language_model", + "menu", "project", "recent_projects", + "serde_json", "settings", "theme", "ui", - "ui_input", "util", "workspace", + "zed_actions", ] [[package]] diff --git a/assets/icons/new_thread.svg b/assets/icons/new_thread.svg new file mode 100644 index 0000000000000000000000000000000000000000..19b8fa25ea30ed47a57a5d5f83d62f2b4b56b61e --- /dev/null +++ b/assets/icons/new_thread.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/open_folder.svg b/assets/icons/open_folder.svg new file mode 100644 index 0000000000000000000000000000000000000000..c4aa32b29cc1048fd4ecd8b1b4d32b68ae0a8ad3 --- /dev/null +++ b/assets/icons/open_folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 7e01245ec62b2590a1c88fef5946b7d06463968d..0b354ef1c039c2fe7dde2f20bb30ef71f067e84d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -673,6 +673,9 @@ "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", + "left": "agents_sidebar::CollapseSelectedEntry", + "right": "agents_sidebar::ExpandSelectedEntry", + "enter": "menu::Confirm", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 43d6419575fc698110cd5a033c01127ac6543f9a..052475ddb981c4db5495914096ffd72dee54d80f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -741,6 +741,9 @@ "use_key_equivalents": true, "bindings": { "cmd-n": "multi_workspace::NewWorkspaceInWindow", + "left": "agents_sidebar::CollapseSelectedEntry", + "right": "agents_sidebar::ExpandSelectedEntry", + "enter": "menu::Confirm", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 22541368cecfc6a645e2b8b7ce55a6711491a012..ef2b339951382a44433372b34e7e62b082428362 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -677,6 +677,9 @@ "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", + "left": "agents_sidebar::CollapseSelectedEntry", + "right": "agents_sidebar::ExpandSelectedEntry", + "enter": "menu::Confirm", }, }, { diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 0becded53762be7c96789b0d31191fd9cbc02bfe..773508f1c898c39d713d5779c82384caf8f190ec 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -496,6 +496,7 @@ mod test_support { //! - `create_test_png_base64` for generating test images use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; use action_log::ActionLog; use collections::HashMap; @@ -621,7 +622,9 @@ mod test_support { _cwd: &Path, cx: &mut gpui::App, ) -> Task>> { - let session_id = acp::SessionId::new(self.sessions.lock().len().to_string()); + static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0); + let session_id = + acp::SessionId::new(NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst).to_string()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 5421538ca736028a4ea7290c09ef81036e055b81..77c7ab02eb9e61a270c2e5679a6972fc817e0ce0 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1490,16 +1490,6 @@ impl NativeAgentSessionList { } } - fn to_session_info(entry: DbThreadMetadata) -> AgentSessionInfo { - AgentSessionInfo { - session_id: entry.id, - cwd: None, - title: Some(entry.title), - updated_at: Some(entry.updated_at), - meta: None, - } - } - pub fn thread_store(&self) -> &Entity { &self.thread_store } @@ -1515,7 +1505,7 @@ impl AgentSessionList for NativeAgentSessionList { .thread_store .read(cx) .entries() - .map(Self::to_session_info) + .map(|entry| AgentSessionInfo::from(&entry)) .collect(); Task::ready(Ok(AgentSessionListResponse::new(sessions))) } diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 10ecb643b9a17dd6b02b47a416c526a662d12632..2c9b33e4efc4f22059e2914589ca6c635b51c0e5 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -32,11 +32,24 @@ pub struct DbThreadMetadata { #[serde(alias = "summary")] pub title: SharedString, pub updated_at: DateTime, + pub created_at: Option>, /// The workspace folder paths this thread was created against, sorted /// lexicographically. Used for grouping threads by project in the sidebar. pub folder_paths: PathList, } +impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo { + fn from(meta: &DbThreadMetadata) -> Self { + Self { + session_id: meta.id.clone(), + cwd: None, + title: Some(meta.title.clone()), + updated_at: Some(meta.updated_at), + meta: None, + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct DbThread { pub title: SharedString, @@ -408,6 +421,17 @@ impl ThreadsDatabase { s().ok(); } + if let Ok(mut s) = connection.exec(indoc! {" + ALTER TABLE threads ADD COLUMN created_at TEXT; + "}) + { + if s().is_ok() { + connection.exec(indoc! {" + UPDATE threads SET created_at = updated_at WHERE created_at IS NULL + "})?()?; + } + } + let db = Self { executor, connection: Arc::new(Mutex::new(connection)), @@ -458,8 +482,19 @@ impl ThreadsDatabase { let data_type = DataType::Zstd; let data = compressed; - let mut insert = connection.exec_bound::<(Arc, Option>, Option, Option, String, String, DataType, Vec)>(indoc! {" - INSERT OR REPLACE INTO threads (id, parent_id, folder_paths, folder_paths_order, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + let created_at = Utc::now().to_rfc3339(); + + let mut insert = connection.exec_bound::<(Arc, Option>, Option, Option, String, String, DataType, Vec, String)>(indoc! {" + INSERT INTO threads (id, parent_id, folder_paths, folder_paths_order, summary, updated_at, data_type, data, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO UPDATE SET + parent_id = excluded.parent_id, + folder_paths = excluded.folder_paths, + folder_paths_order = excluded.folder_paths_order, + summary = excluded.summary, + updated_at = excluded.updated_at, + data_type = excluded.data_type, + data = excluded.data "})?; insert(( @@ -471,6 +506,7 @@ impl ThreadsDatabase { updated_at, data_type, data, + created_at, ))?; Ok(()) @@ -483,14 +519,14 @@ impl ThreadsDatabase { let connection = connection.lock(); let mut select = connection - .select_bound::<(), (Arc, Option>, Option, Option, String, String)>(indoc! {" - SELECT id, parent_id, folder_paths, folder_paths_order, summary, updated_at FROM threads ORDER BY updated_at DESC + .select_bound::<(), (Arc, Option>, Option, Option, String, String, Option)>(indoc! {" + SELECT id, parent_id, folder_paths, folder_paths_order, summary, updated_at, created_at FROM threads ORDER BY updated_at DESC, created_at DESC "})?; let rows = select(())?; let mut threads = Vec::new(); - for (id, parent_id, folder_paths, folder_paths_order, summary, updated_at) in rows { + for (id, parent_id, folder_paths, folder_paths_order, summary, updated_at, created_at) in rows { let folder_paths = folder_paths .map(|paths| { PathList::deserialize(&util::path_list::SerializedPathList { @@ -499,11 +535,18 @@ impl ThreadsDatabase { }) }) .unwrap_or_default(); + let created_at = created_at + .as_deref() + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)); + threads.push(DbThreadMetadata { id: acp::SessionId::new(id), parent_session_id: parent_id.map(acp::SessionId::new), title: summary.into(), updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), + created_at, folder_paths, }); } @@ -652,7 +695,7 @@ mod tests { } #[gpui::test] - async fn test_list_threads_orders_by_updated_at(cx: &mut TestAppContext) { + async fn test_list_threads_orders_by_created_at(cx: &mut TestAppContext) { let database = ThreadsDatabase::new(cx.executor()).unwrap(); let older_id = session_id("thread-a"); @@ -713,6 +756,10 @@ mod tests { entries[0].updated_at, Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap() ); + assert!( + entries[0].created_at.is_some(), + "created_at should be populated" + ); } #[test] diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index e26820ddacc3132d42946de3b27d25f4424fae02..961be1da4c09890691adbd5448d7678b2808fe7b 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -22,6 +22,10 @@ impl ThreadStore { cx.global::().0.clone() } + pub fn try_global(cx: &App) -> Option> { + cx.try_global::().map(|g| g.0.clone()) + } + pub fn new(cx: &mut Context) -> Self { let this = Self { threads: Vec::new(), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index af78f49509c81493e5084113e1a50c5a8b79bd5b..19ffc0ff5961140ee43ed6869308c4dea9115054 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9,7 +9,7 @@ use std::{ time::Duration, }; -use acp_thread::{AcpThread, AgentSessionInfo, MentionUri}; +use acp_thread::{AcpThread, AgentSessionInfo, MentionUri, ThreadStatus}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; @@ -52,6 +52,7 @@ use assistant_slash_command::SlashCommandWorkingSet; use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary}; use client::UserStore; use cloud_api_types::Plan; +use collections::HashMap; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; @@ -553,7 +554,7 @@ pub struct AgentPanel { focus_handle: FocusHandle, active_view: ActiveView, previous_view: Option, - _active_view_observation: Option, + background_threads: HashMap>, new_thread_menu_handle: PopoverMenuHandle, start_thread_in_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, @@ -573,6 +574,7 @@ pub struct AgentPanel { show_trust_workspace_message: bool, last_configuration_error_telemetry: Option, on_boarding_upsell_dismissed: AtomicBool, + _active_view_observation: Option, } impl AgentPanel { @@ -877,7 +879,7 @@ impl AgentPanel { focus_handle: cx.focus_handle(), context_server_registry, previous_view: None, - _active_view_observation: None, + background_threads: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), start_thread_in_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -900,6 +902,7 @@ impl AgentPanel { show_trust_workspace_message: false, last_configuration_error_telemetry: None, on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()), + _active_view_observation: None, }; // Initial sync of agent servers from extensions @@ -995,7 +998,7 @@ impl AgentPanel { } } - fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { + pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { self.new_agent_thread(AgentType::NativeAgent, window, cx); } @@ -1650,6 +1653,53 @@ impl AgentPanel { } } + /// Returns the primary thread views for all retained connections: the + pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool { + self.background_threads.contains_key(session_id) + } + + /// active thread plus any background threads that are still running or + /// completed but unseen. + pub fn parent_threads(&self, cx: &App) -> Vec> { + let mut views = Vec::new(); + + if let Some(server_view) = self.as_active_server_view() { + if let Some(thread_view) = server_view.read(cx).parent_thread(cx) { + views.push(thread_view); + } + } + + for server_view in self.background_threads.values() { + if let Some(thread_view) = server_view.read(cx).parent_thread(cx) { + views.push(thread_view); + } + } + + views + } + + fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context) { + let ActiveView::AgentThread { server_view } = old_view else { + return; + }; + + let Some(thread_view) = server_view.read(cx).parent_thread(cx) else { + return; + }; + + let thread = &thread_view.read(cx).thread; + let (status, session_id) = { + let thread = thread.read(cx); + (thread.status(), thread.session_id().clone()) + }; + + if status != ThreadStatus::Generating { + return; + } + + self.background_threads.insert(session_id, server_view); + } + pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { ActiveView::AgentThread { server_view, .. } => { @@ -1688,18 +1738,21 @@ impl AgentPanel { let current_is_config = matches!(self.active_view, ActiveView::Configuration); let new_is_config = matches!(new_view, ActiveView::Configuration); - let current_is_special = current_is_history || current_is_config; - let new_is_special = new_is_history || new_is_config; + let current_is_overlay = current_is_history || current_is_config; + let new_is_overlay = new_is_history || new_is_config; - if current_is_uninitialized || (current_is_special && !new_is_special) { + if current_is_uninitialized || (current_is_overlay && !new_is_overlay) { self.active_view = new_view; - } else if !current_is_special && new_is_special { + } else if !current_is_overlay && new_is_overlay { self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view)); } else { - if !new_is_special { - self.previous_view = None; + let old_view = std::mem::replace(&mut self.active_view, new_view); + if !new_is_overlay { + if let Some(previous) = self.previous_view.take() { + self.retain_running_thread(previous, cx); + } } - self.active_view = new_view; + self.retain_running_thread(old_view, cx); } // Subscribe to the active ThreadView's events (e.g. FirstSendRequested) @@ -1969,6 +2022,36 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + let session_id = thread.session_id.clone(); + if let Some(server_view) = self.background_threads.remove(&session_id) { + self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx); + return; + } + + if let ActiveView::AgentThread { server_view } = &self.active_view { + if server_view + .read(cx) + .active_thread() + .map(|t| t.read(cx).id.clone()) + == Some(session_id.clone()) + { + return; + } + } + + if let Some(ActiveView::AgentThread { server_view }) = &self.previous_view { + if server_view + .read(cx) + .active_thread() + .map(|t| t.read(cx).id.clone()) + == Some(session_id.clone()) + { + let view = self.previous_view.take().unwrap(); + self.set_active_view(view, true, window, cx); + return; + } + } + let Some(agent) = self.selected_external_agent() else { return; }; @@ -2012,6 +2095,20 @@ impl AgentPanel { ) }); + cx.observe(&server_view, |this, server_view, cx| { + let is_active = this + .as_active_server_view() + .is_some_and(|active| active.entity_id() == server_view.entity_id()); + if is_active { + cx.emit(AgentPanelEvent::ActiveViewChanged); + this.serialize(cx); + } else { + cx.emit(AgentPanelEvent::BackgroundThreadChanged); + } + cx.notify(); + }) + .detach(); + self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx); } @@ -2545,6 +2642,7 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { pub enum AgentPanelEvent { ActiveViewChanged, + BackgroundThreadChanged, } impl EventEmitter for AgentPanel {} @@ -4233,6 +4331,15 @@ impl Dismissable for TrialEndUpsell { /// Test-only helper methods #[cfg(any(test, feature = "test-support"))] impl AgentPanel { + pub fn test_new( + workspace: &Workspace, + text_thread_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(workspace, text_thread_store, None, window, cx) + } + /// Opens an external thread using an arbitrary AgentServer. /// /// This is a test-only helper that allows visual tests and integration tests @@ -4324,6 +4431,8 @@ impl AgentPanel { mod tests { use super::*; use crate::connection_view::tests::{StubAgentServer, init_test}; + use crate::test_support::{active_session_id, open_thread_with_connection, send_message}; + use acp_thread::{StubAgentConnection, ThreadStatus}; use assistant_text_thread::TextThreadStore; use feature_flags::FeatureFlagAppExt; use fs::FakeFs; @@ -4517,6 +4626,182 @@ mod tests { cx.run_until_parked(); } + async fn setup_panel(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + (panel, cx) + } + + #[gpui::test] + async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection_a.clone(), &mut cx); + send_message(&panel, &mut cx); + + let session_id_a = active_session_id(&panel, &cx); + + // Send a chunk to keep thread A generating (don't end the turn). + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Verify thread A is generating. + panel.read_with(&cx, |panel, cx| { + let thread = panel.active_agent_thread(cx).unwrap(); + assert_eq!(thread.read(cx).status(), ThreadStatus::Generating); + assert!(panel.background_threads.is_empty()); + }); + + // Open a new thread B — thread A should be retained in background. + let connection_b = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection_b, &mut cx); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!( + panel.background_threads.len(), + 1, + "Running thread A should be retained in background_views" + ); + assert!( + panel.background_threads.contains_key(&session_id_a), + "Background view should be keyed by thread A's session ID" + ); + }); + } + + #[gpui::test] + async fn test_idle_thread_dropped_when_navigating_away(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); + open_thread_with_connection(&panel, connection_a, &mut cx); + send_message(&panel, &mut cx); + + let weak_view_a = panel.read_with(&cx, |panel, _cx| { + panel.active_thread_view().unwrap().downgrade() + }); + + // Thread A should be idle (auto-completed via set_next_prompt_updates). + panel.read_with(&cx, |panel, cx| { + let thread = panel.active_agent_thread(cx).unwrap(); + assert_eq!(thread.read(cx).status(), ThreadStatus::Idle); + }); + + // Open a new thread B — thread A should NOT be retained. + let connection_b = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection_b, &mut cx); + + panel.read_with(&cx, |panel, _cx| { + assert!( + panel.background_threads.is_empty(), + "Idle thread A should not be retained in background_views" + ); + }); + + // Verify the old ConnectionView entity was dropped (no strong references remain). + assert!( + weak_view_a.upgrade().is_none(), + "Idle ConnectionView should have been dropped" + ); + } + + #[gpui::test] + async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection_a.clone(), &mut cx); + send_message(&panel, &mut cx); + + let session_id_a = active_session_id(&panel, &cx); + + // Keep thread A generating. + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Open thread B — thread A goes to background. + let connection_b = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection_b, &mut cx); + + let session_id_b = active_session_id(&panel, &cx); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.background_threads.len(), 1); + assert!(panel.background_threads.contains_key(&session_id_a)); + }); + + // Load thread A back via load_agent_thread — should promote from background. + panel.update_in(&mut cx, |panel, window, cx| { + panel.load_agent_thread( + AgentSessionInfo { + session_id: session_id_a.clone(), + cwd: None, + title: None, + updated_at: None, + meta: None, + }, + window, + cx, + ); + }); + + // Thread A should now be the active view, promoted from background. + let active_session = active_session_id(&panel, &cx); + assert_eq!( + active_session, session_id_a, + "Thread A should be the active thread after promotion" + ); + + panel.read_with(&cx, |panel, _cx| { + assert!( + !panel.background_threads.contains_key(&session_id_a), + "Promoted thread A should no longer be in background_views" + ); + assert!( + !panel.background_threads.contains_key(&session_id_b), + "Thread B (idle) should not have been retained in background_views" + ); + }); + } + #[gpui::test] async fn test_thread_target_local_project(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 5ae2d677ba6dd4622127b39938f2bf005e7fcab9..caecce3d0282e33daf8164fb17f48bd53be60b9f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -25,6 +25,8 @@ mod slash_command; mod slash_command_picker; mod terminal_codegen; mod terminal_inline_assistant; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; mod text_thread_editor; mod text_thread_history; mod thread_history; diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 07e34ccd56f0bd867135fe62894a5a3ff388c85e..e7e9403e052f6578ab20982fbb27c7c6a29d1a80 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -956,6 +956,18 @@ impl ConnectionView { .unwrap_or_else(|| agent_name.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())) + .or_else(|| { + project::AgentRegistryStore::try_global(cx).and_then(|store| { + store + .read(cx) + .agent(self.agent.name().as_ref()) + .and_then(|a| a.icon_path().cloned()) + }) + }); let weak = cx.weak_entity(); cx.new(|cx| { @@ -965,6 +977,7 @@ impl ConnectionView { conversation, weak, agent_icon, + agent_icon_from_external_svg, agent_name, agent_display_name, self.workspace.clone(), @@ -1360,6 +1373,7 @@ impl ConnectionView { } }); } + cx.notify(); } AcpThreadEvent::PromptCapabilitiesUpdated => { if let Some(active) = self.thread_view(&thread_id) { diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 8a1a7d2ea5b0f01ba559e83051861b9d6324985f..af8675f9466bf2ee4372f15978db1f76804e8971 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -206,6 +206,7 @@ pub struct ThreadView { pub(crate) conversation: Entity, pub server_view: WeakEntity, pub agent_icon: IconName, + pub agent_icon_from_external_svg: Option, pub agent_name: SharedString, pub focus_handle: FocusHandle, pub workspace: WeakEntity, @@ -293,6 +294,7 @@ impl ThreadView { conversation: Entity, server_view: WeakEntity, agent_icon: IconName, + agent_icon_from_external_svg: Option, agent_name: SharedString, agent_display_name: SharedString, workspace: WeakEntity, @@ -424,6 +426,7 @@ impl ThreadView { conversation, server_view, agent_icon, + agent_icon_from_external_svg, agent_name, workspace, entry_view_state, @@ -934,6 +937,7 @@ impl ThreadView { let session_id = self.thread.read(cx).session_id().clone(); let parent_session_id = self.thread.read(cx).parent_session_id().cloned(); let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id(); + let is_first_message = self.thread.read(cx).entries().is_empty(); let thread = self.thread.downgrade(); self.is_loading_contents = true; @@ -974,6 +978,25 @@ impl ThreadView { .ok(); } }); + if is_first_message { + let text: String = contents + .iter() + .filter_map(|block| match block { + acp::ContentBlock::Text(text_content) => Some(text_content.text.as_str()), + _ => None, + }) + .collect::>() + .join(" "); + let text = text.lines().next().unwrap_or("").trim(); + if !text.is_empty() { + let title: SharedString = util::truncate_and_trailoff(text, 20).into(); + thread + .update(cx, |thread, cx| thread.set_title(title, cx))? + .await + .log_err(); + } + } + let turn_start_time = Instant::now(); let send = thread.update(cx, |thread, cx| { thread.action_log().update(cx, |action_log, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index c75d0479b7bf16229cc487544d2c87403b3da430..6ce0b7e356dc75f1c3d4db0f318d1978a37d00cc 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1425,7 +1425,7 @@ impl MessageEditor { }); } - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.set_text(text, window, cx); diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs new file mode 100644 index 0000000000000000000000000000000000000000..05a6b0925fb9151cc18d7096c8bf4f2674054073 --- /dev/null +++ b/crates/agent_ui/src/test_support.rs @@ -0,0 +1,98 @@ +use acp_thread::{AgentConnection, StubAgentConnection}; +use agent_client_protocol as acp; +use agent_servers::{AgentServer, AgentServerDelegate}; +use gpui::{Entity, SharedString, Task, TestAppContext, VisualTestContext}; +use settings::SettingsStore; +use std::any::Any; +use std::rc::Rc; + +use crate::AgentPanel; +use crate::agent_panel; + +pub struct StubAgentServer { + connection: C, +} + +impl StubAgentServer { + pub fn new(connection: C) -> Self { + Self { connection } + } +} + +impl StubAgentServer { + pub fn default_response() -> Self { + let conn = StubAgentConnection::new(); + conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Default response".into()), + )]); + Self::new(conn) + } +} + +impl AgentServer for StubAgentServer +where + C: 'static + AgentConnection + Send + Clone, +{ + fn logo(&self) -> ui::IconName { + ui::IconName::Ai + } + + fn name(&self) -> SharedString { + "Test".into() + } + + fn connect( + &self, + _delegate: AgentServerDelegate, + _cx: &mut gpui::App, + ) -> Task>> { + Task::ready(Ok(Rc::new(self.connection.clone()))) + } + + fn into_any(self: Rc) -> Rc { + self + } +} + +pub fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + release_channel::init("0.0.0".parse().unwrap(), cx); + agent_panel::init(cx); + }); +} + +pub fn open_thread_with_connection( + panel: &Entity, + connection: StubAgentConnection, + cx: &mut VisualTestContext, +) { + panel.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::new(connection)), + window, + cx, + ); + }); + cx.run_until_parked(); +} + +pub fn send_message(panel: &Entity, cx: &mut VisualTestContext) { + let thread_view = panel.read_with(cx, |panel, cx| panel.as_active_thread_view(cx).unwrap()); + let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + thread_view.update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); +} + +pub fn active_session_id(panel: &Entity, cx: &VisualTestContext) -> acp::SessionId { + panel.read_with(cx, |panel, cx| { + let thread = panel.active_agent_thread(cx).unwrap(); + thread.read(cx).session_id().clone() + }) +} diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 07204548ff5f2884bb4a5429267a02981ab3e78f..3536e73a9db6247a798145f186ae20d2efe29da5 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -176,7 +176,9 @@ pub enum IconName { Mic, MicMute, Minimize, + NewThread, Notepad, + OpenFolder, Option, PageDown, PageUp, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 082086d6a0a946e610be4c96e50d626b7000bda4..d647676834e9847ac697f1b51fc61bc1b2425adf 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6879,14 +6879,17 @@ impl Render for ProjectPanel { Button::new("open_project", "Open Project") .full_width() .key_binding(KeyBinding::for_action_in( - &workspace::Open, + &workspace::Open::default(), &focus_handle, cx, )) .on_click(cx.listener(|this, _, window, cx| { this.workspace .update(cx, |_, cx| { - window.dispatch_action(workspace::Open.boxed_clone(), cx); + window.dispatch_action( + workspace::Open::default().boxed_clone(), + cx, + ); }) .log_err(); })), diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index e7a8358da6f8daa1778837073129e70b04ee2317..548e08eccb49c19551984e6acdd086d78927d614 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1253,17 +1253,16 @@ impl PickerDelegate for RecentProjectsDelegate { .gap_1() .border_t_1() .border_color(cx.theme().colors().border_variant) - .child( + .child({ + let open_action = workspace::Open { + create_new_window: self.create_new_window, + }; Button::new("open_local_folder", "Open Local Project") - .key_binding(KeyBinding::for_action_in( - &workspace::Open, - &focus_handle, - cx, - )) - .on_click(|_, window, cx| { - window.dispatch_action(workspace::Open.boxed_clone(), cx) - }), - ) + .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) + .on_click(move |_, window, cx| { + window.dispatch_action(open_action.boxed_clone(), cx) + }) + }) .child( Button::new("open_remote_folder", "Open Remote Project") .key_binding(KeyBinding::for_action( @@ -1354,6 +1353,7 @@ impl PickerDelegate for RecentProjectsDelegate { ) .menu({ let focus_handle = focus_handle.clone(); + let create_new_window = self.create_new_window; move |window, cx| { Some(ContextMenu::build(window, cx, { @@ -1362,7 +1362,7 @@ impl PickerDelegate for RecentProjectsDelegate { menu.context(focus_handle) .action( "Open Local Project", - workspace::Open.boxed_clone(), + workspace::Open { create_new_window }.boxed_clone(), ) .action( "Open Remote Project", diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 6165a41c68894df9ad60110663562df713a24470..f0722a5791f6eecf873703bc5337890329d310c8 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -13,30 +13,38 @@ path = "src/sidebar.rs" [features] default = [] -test-support = [] [dependencies] acp_thread.workspace = true +agent.workspace = true +agent-client-protocol.workspace = true agent_ui.workspace = true chrono.workspace = true +editor.workspace = true fs.workspace = true -fuzzy.workspace = true gpui.workspace = true -picker.workspace = true +menu.workspace = true project.workspace = true recent_projects.workspace = true +settings.workspace = true theme.workspace = true ui.workspace = true -ui_input.workspace = true util.workspace = true workspace.workspace = true +zed_actions.workspace = true [dev-dependencies] +acp_thread = { workspace = true, features = ["test-support"] } +agent = { workspace = true, features = ["test-support"] } +agent_ui = { workspace = true, features = ["test-support"] } +assistant_text_thread = { workspace = true, features = ["test-support"] } editor.workspace = true +language_model = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true, features = ["test-support"] } +serde_json.workspace = true feature_flags.workspace = true fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } \ No newline at end of file diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index e8fc3876e29aae6d7a3490bd7a80245c8c3bab1c..8c68a332162d990503bf1e4881a69611f4b31c8c 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,706 +1,189 @@ use acp_thread::ThreadStatus; -use agent_ui::{AgentPanel, AgentPanelEvent}; -use chrono::{Datelike, Local, NaiveDate, TimeDelta}; - -use fs::Fs; -use fuzzy::StringMatchCandidate; +use agent::ThreadStore; +use agent_client_protocol as acp; +use agent_ui::{AgentPanel, AgentPanelEvent, NewThread}; +use chrono::Utc; +use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ - App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, - Subscription, Task, Window, px, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, + Pixels, Render, SharedString, Subscription, TextStyle, WeakEntity, Window, actions, list, + prelude::*, px, relative, rems, }; -use picker::{Picker, PickerDelegate}; +use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; -use recent_projects::{RecentProjectEntry, get_recent_projects}; -use std::fmt::Display; - +use settings::Settings; use std::collections::{HashMap, HashSet}; - -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use theme::ActiveTheme; +use std::mem; +use theme::{ActiveTheme, ThemeSettings}; use ui::utils::TRAFFIC_LIGHT_PADDING; use ui::{ - AgentThreadStatus, Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip, - prelude::*, + AgentThreadStatus, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, PopoverMenu, Tab, + ThreadItem, Tooltip, WithScrollbar, prelude::*, }; -use ui_input::ErasedEditor; -use util::ResultExt as _; +use util::path_list::PathList; use workspace::{ - FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar, - SidebarEvent, ToggleWorkspaceSidebar, Workspace, + FocusWorkspaceSidebar, MultiWorkspace, Sidebar as WorkspaceSidebar, SidebarEvent, + ToggleWorkspaceSidebar, Workspace, }; +use zed_actions::editor::{MoveDown, MoveUp}; + +actions!( + agents_sidebar, + [ + /// Collapses the selected entry in the workspace sidebar. + CollapseSelectedEntry, + /// Expands the selected entry in the workspace sidebar. + ExpandSelectedEntry, + ] +); + +const DEFAULT_WIDTH: Pixels = px(320.0); +const MIN_WIDTH: Pixels = px(200.0); +const MAX_WIDTH: Pixels = px(800.0); +const DEFAULT_THREADS_SHOWN: usize = 5; #[derive(Clone, Debug)] -struct AgentThreadInfo { +struct ActiveThreadInfo { + session_id: acp::SessionId, title: SharedString, status: AgentThreadStatus, icon: IconName, + icon_from_external_svg: Option, + is_background: bool, } -const DEFAULT_WIDTH: Pixels = px(320.0); -const MIN_WIDTH: Pixels = px(200.0); -const MAX_WIDTH: Pixels = px(800.0); -const MAX_MATCHES: usize = 100; - -#[derive(Clone)] -struct WorkspaceThreadEntry { - index: usize, - worktree_label: SharedString, - full_path: SharedString, - thread_info: Option, -} - -impl WorkspaceThreadEntry { - fn new(index: usize, workspace: &Entity, cx: &App) -> Self { - let workspace_ref = workspace.read(cx); - - let worktrees: Vec<_> = workspace_ref - .worktrees(cx) - .filter(|worktree| worktree.read(cx).is_visible()) - .map(|worktree| worktree.read(cx).abs_path()) - .collect(); - - let worktree_names: Vec = worktrees - .iter() - .filter_map(|path| { - path.file_name() - .map(|name| name.to_string_lossy().to_string()) - }) - .collect(); - - let worktree_label: SharedString = if worktree_names.is_empty() { - format!("Workspace {}", index + 1).into() - } else { - worktree_names.join(", ").into() - }; - - let full_path: SharedString = worktrees - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect::>() - .join("\n") - .into(); - - let thread_info = Self::thread_info(workspace, cx); - +impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { + fn from(info: &ActiveThreadInfo) -> Self { Self { - index, - worktree_label, - full_path, - thread_info, - } - } - - fn thread_info(workspace: &Entity, cx: &App) -> Option { - let agent_panel = workspace.read(cx).panel::(cx)?; - let agent_panel_ref = agent_panel.read(cx); - - let thread_view = agent_panel_ref.as_active_thread_view(cx)?.read(cx); - let thread = thread_view.thread.read(cx); - - let icon = thread_view.agent_icon; - let title = thread.title(); - - let status = if thread.is_waiting_for_confirmation() { - AgentThreadStatus::WaitingForConfirmation - } else if thread.had_error() { - AgentThreadStatus::Error - } else { - match thread.status() { - ThreadStatus::Generating => AgentThreadStatus::Running, - ThreadStatus::Idle => AgentThreadStatus::Completed, - } - }; - Some(AgentThreadInfo { - title, - status, - icon, - }) - } -} - -#[derive(Clone)] -enum SidebarEntry { - Separator(SharedString), - WorkspaceThread(WorkspaceThreadEntry), - RecentProject(RecentProjectEntry), -} - -impl SidebarEntry { - fn searchable_text(&self) -> &str { - match self { - SidebarEntry::Separator(_) => "", - SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(), - SidebarEntry::RecentProject(entry) => entry.name.as_ref(), + session_id: info.session_id.clone(), + cwd: None, + title: Some(info.title.clone()), + updated_at: Some(Utc::now()), + meta: None, } } } -#[derive(Clone)] -struct SidebarMatch { - entry: SidebarEntry, - positions: Vec, +#[derive(Clone, Debug)] +#[allow(dead_code)] +enum ListEntry { + ProjectHeader { + path_list: PathList, + label: SharedString, + highlight_positions: Vec, + }, + Thread { + session_info: acp_thread::AgentSessionInfo, + icon: IconName, + icon_from_external_svg: Option, + status: AgentThreadStatus, + diff_stats: Option<(usize, usize)>, + workspace_index: usize, + is_live: bool, + is_background: bool, + highlight_positions: Vec, + }, + ViewMore { + path_list: PathList, + remaining_count: usize, + }, + NewThread { + path_list: PathList, + }, } -struct WorkspacePickerDelegate { - multi_workspace: Entity, - entries: Vec, - active_workspace_index: usize, - workspace_thread_count: usize, - /// All recent projects including what's filtered out of entries - /// used to add unopened projects to entries on rebuild - recent_projects: Vec, - recent_project_thread_titles: HashMap, - matches: Vec, - selected_index: usize, - query: String, - hovered_thread_item: Option, - notified_workspaces: HashSet, +#[derive(Default)] +struct SidebarContents { + entries: Vec, + notified_threads: HashSet, } -impl WorkspacePickerDelegate { - fn new(multi_workspace: Entity) -> Self { - Self { - multi_workspace, - entries: Vec::new(), - active_workspace_index: 0, - workspace_thread_count: 0, - recent_projects: Vec::new(), - recent_project_thread_titles: HashMap::new(), - matches: Vec::new(), - selected_index: 0, - query: String::new(), - hovered_thread_item: None, - notified_workspaces: HashSet::new(), - } +impl SidebarContents { + fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool { + self.notified_threads.contains(session_id) } +} - fn set_entries( - &mut self, - workspace_threads: Vec, - active_workspace_index: usize, - cx: &App, - ) { - if let Some(hovered_index) = self.hovered_thread_item { - let still_exists = workspace_threads - .iter() - .any(|thread| thread.index == hovered_index); - if !still_exists { - self.hovered_thread_item = None; - } - } - - let old_statuses: HashMap = self - .entries - .iter() - .filter_map(|entry| match entry { - SidebarEntry::WorkspaceThread(thread) => thread - .thread_info - .as_ref() - .map(|info| (thread.index, info.status)), - _ => None, - }) - .collect(); +fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { + let mut positions = Vec::new(); + let mut query_chars = query.chars().peekable(); - for thread in &workspace_threads { - if let Some(info) = &thread.thread_info { - if info.status == AgentThreadStatus::Completed - && thread.index != active_workspace_index - { - if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) { - self.notified_workspaces.insert(thread.index); - } - } + for (byte_idx, candidate_char) in candidate.char_indices() { + if let Some(&query_char) = query_chars.peek() { + if candidate_char.eq_ignore_ascii_case(&query_char) { + positions.push(byte_idx); + query_chars.next(); } + } else { + break; } - - if self.active_workspace_index != active_workspace_index { - self.notified_workspaces.remove(&active_workspace_index); - } - self.active_workspace_index = active_workspace_index; - self.workspace_thread_count = workspace_threads.len(); - self.rebuild_entries(workspace_threads, cx); } - fn set_recent_projects(&mut self, recent_projects: Vec, cx: &App) { - self.recent_project_thread_titles.clear(); - - self.recent_projects = recent_projects; - - let workspace_threads: Vec = self - .entries - .iter() - .filter_map(|entry| match entry { - SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()), - _ => None, - }) - .collect(); - self.rebuild_entries(workspace_threads, cx); - } - - fn open_workspace_path_sets(&self, cx: &App) -> Vec>> { - self.multi_workspace - .read(cx) - .workspaces() - .iter() - .map(|workspace| { - let mut paths = workspace.read(cx).root_paths(cx); - paths.sort(); - paths - }) - .collect() - } - - fn rebuild_entries(&mut self, workspace_threads: Vec, cx: &App) { - let open_path_sets = self.open_workspace_path_sets(cx); - - self.entries.clear(); - - if !workspace_threads.is_empty() { - self.entries - .push(SidebarEntry::Separator("Active Workspaces".into())); - for thread in workspace_threads { - self.entries.push(SidebarEntry::WorkspaceThread(thread)); - } - } - - let recent: Vec<_> = self - .recent_projects - .iter() - .filter(|project| { - let mut project_paths: Vec<&Path> = - project.paths.iter().map(|p| p.as_path()).collect(); - project_paths.sort(); - !open_path_sets.iter().any(|open_paths| { - open_paths.len() == project_paths.len() - && open_paths - .iter() - .zip(&project_paths) - .all(|(a, b)| a.as_ref() == *b) - }) - }) - .cloned() - .collect(); - - if !recent.is_empty() { - let today = Local::now().naive_local().date(); - let mut current_bucket: Option = None; - - for project in recent { - let entry_date = project.timestamp.with_timezone(&Local).naive_local().date(); - let bucket = TimeBucket::from_dates(today, entry_date); - - if current_bucket != Some(bucket) { - current_bucket = Some(bucket); - self.entries - .push(SidebarEntry::Separator(bucket.to_string().into())); - } - - self.entries.push(SidebarEntry::RecentProject(project)); - } - } + if query_chars.peek().is_none() { + Some(positions) + } else { + None } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; +fn workspace_path_list_and_label( + workspace: &Entity, + cx: &App, +) -> (PathList, SharedString) { + let workspace_ref = workspace.read(cx); + let mut paths = Vec::new(); + let mut names = Vec::new(); + + for worktree in workspace_ref.worktrees(cx) { + let worktree_ref = worktree.read(cx); + if !worktree_ref.is_visible() { + continue; } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), + let abs_path = worktree_ref.abs_path(); + paths.push(abs_path.to_path_buf()); + if let Some(name) = abs_path.file_name() { + names.push(name.to_string_lossy().to_string()); } } -} -fn open_recent_project(paths: Vec, window: &mut Window, cx: &mut App) { - let Some(handle) = window.window_handle().downcast::() else { - return; + let label: SharedString = if names.is_empty() { + // TODO: Can we do something better in this case? + "Empty Workspace".into() + } else { + names.join(", ").into() }; - cx.defer(move |cx| { - if let Some(task) = handle - .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, window, cx) - }) - .log_err() - { - task.detach_and_log_err(cx); - } - }); + (PathList::new(&paths), label) } -impl PickerDelegate for WorkspacePickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { - match self.matches.get(ix) { - Some(SidebarMatch { - entry: SidebarEntry::Separator(_), - .. - }) => false, - _ => true, - } - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search…".into() - } - - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - if self.query.is_empty() { - None - } else { - Some("No threads match your search.".into()) - } - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let query_changed = self.query != query; - self.query = query.clone(); - if query_changed { - self.hovered_thread_item = None; - } - let entries = self.entries.clone(); - - if query.is_empty() { - self.matches = entries - .into_iter() - .map(|entry| SidebarMatch { - entry, - positions: Vec::new(), - }) - .collect(); - - let separator_offset = if self.workspace_thread_count > 0 { - 1 - } else { - 0 - }; - self.selected_index = (self.active_workspace_index + separator_offset) - .min(self.matches.len().saturating_sub(1)); - return Task::ready(()); - } - - let executor = cx.background_executor().clone(); - cx.spawn_in(window, async move |picker, cx| { - let matches = cx - .background_spawn(async move { - let data_entries: Vec<(usize, &SidebarEntry)> = entries - .iter() - .enumerate() - .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_))) - .collect(); - - let candidates: Vec = data_entries - .iter() - .enumerate() - .map(|(candidate_index, (_, entry))| { - StringMatchCandidate::new(candidate_index, entry.searchable_text()) - }) - .collect(); - - let search_matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await; - - let mut workspace_matches = Vec::new(); - let mut project_matches = Vec::new(); - - for search_match in search_matches { - let (original_index, _) = data_entries[search_match.candidate_id]; - let entry = entries[original_index].clone(); - let sidebar_match = SidebarMatch { - positions: search_match.positions, - entry: entry.clone(), - }; - match entry { - SidebarEntry::WorkspaceThread(_) => { - workspace_matches.push(sidebar_match) - } - SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match), - SidebarEntry::Separator(_) => {} - } - } - - let mut result = Vec::new(); - if !workspace_matches.is_empty() { - result.push(SidebarMatch { - entry: SidebarEntry::Separator("Active Workspaces".into()), - positions: Vec::new(), - }); - result.extend(workspace_matches); - } - if !project_matches.is_empty() { - result.push(SidebarMatch { - entry: SidebarEntry::Separator("Recent Projects".into()), - positions: Vec::new(), - }); - result.extend(project_matches); - } - result - }) - .await; - - picker - .update_in(cx, |picker, _window, _cx| { - picker.delegate.matches = matches; - if picker.delegate.matches.is_empty() { - picker.delegate.selected_index = 0; - } else { - let first_selectable = picker - .delegate - .matches - .iter() - .position(|m| !matches!(m.entry, SidebarEntry::Separator(_))) - .unwrap_or(0); - picker.delegate.selected_index = first_selectable; - } - }) - .log_err(); +fn workspace_index_for_path_list( + workspaces: &[Entity], + path_list: &PathList, + cx: &App, +) -> Option { + workspaces + .iter() + .enumerate() + .find_map(|(index, workspace)| { + let (candidate, _) = workspace_path_list_and_label(workspace, cx); + (candidate == *path_list).then_some(index) }) - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(selected_match) = self.matches.get(self.selected_index) else { - return; - }; - - match &selected_match.entry { - SidebarEntry::Separator(_) => {} - SidebarEntry::WorkspaceThread(thread_entry) => { - let target_index = thread_entry.index; - self.multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate_index(target_index, window, cx); - }); - } - SidebarEntry::RecentProject(project_entry) => { - let paths = project_entry.paths.clone(); - open_recent_project(paths, window, cx); - } - } - } - - fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} - - fn render_match( - &self, - index: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let match_entry = self.matches.get(index)?; - let SidebarMatch { entry, positions } = match_entry; - - match entry { - SidebarEntry::Separator(title) => Some( - v_flex() - .when(index > 0, |this| { - this.mt_1() - .gap_2() - .child(Divider::horizontal().color(DividerColor::BorderFaded)) - }) - .child(ListSubHeader::new(title.clone()).inset(true)) - .into_any_element(), - ), - SidebarEntry::WorkspaceThread(thread_entry) => { - let worktree_label = thread_entry.worktree_label.clone(); - let full_path = thread_entry.full_path.clone(); - let thread_info = thread_entry.thread_info.clone(); - let workspace_index = thread_entry.index; - let multi_workspace = self.multi_workspace.clone(); - let workspace_count = self.multi_workspace.read(cx).workspaces().len(); - let is_hovered = self.hovered_thread_item == Some(workspace_index); - - let remove_btn = IconButton::new( - format!("remove-workspace-{}", workspace_index), - IconName::Close, - ) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Remove Workspace")) - .on_click({ - let multi_workspace = multi_workspace; - move |_, window, cx| { - multi_workspace.update(cx, |mw, cx| { - mw.remove_workspace(workspace_index, window, cx); - }); - } - }); - - let has_notification = self.notified_workspaces.contains(&workspace_index); - let thread_subtitle = thread_info.as_ref().map(|info| info.title.clone()); - let status = thread_info - .as_ref() - .map_or(AgentThreadStatus::default(), |info| info.status); - let running = matches!( - status, - AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation - ); - - Some( - ThreadItem::new( - ("workspace-item", thread_entry.index), - thread_subtitle.unwrap_or("New Thread".into()), - ) - .icon( - thread_info - .as_ref() - .map_or(IconName::ZedAgent, |info| info.icon), - ) - .running(running) - .generation_done(has_notification) - .status(status) - .selected(selected) - .worktree(worktree_label.clone()) - .worktree_highlight_positions(positions.clone()) - .when(workspace_count > 1, |item| item.action_slot(remove_btn)) - .hovered(is_hovered) - .on_hover(cx.listener(move |picker, is_hovered, _window, cx| { - let mut changed = false; - if *is_hovered { - if picker.delegate.hovered_thread_item != Some(workspace_index) { - picker.delegate.hovered_thread_item = Some(workspace_index); - changed = true; - } - } else if picker.delegate.hovered_thread_item == Some(workspace_index) { - picker.delegate.hovered_thread_item = None; - changed = true; - } - if changed { - cx.notify(); - } - })) - .when(!full_path.is_empty(), |this| { - this.tooltip(move |_, cx| { - Tooltip::with_meta(worktree_label.clone(), None, full_path.clone(), cx) - }) - }) - .into_any_element(), - ) - } - SidebarEntry::RecentProject(project_entry) => { - let name = project_entry.name.clone(); - let full_path = project_entry.full_path.clone(); - let item_id: SharedString = - format!("recent-project-{:?}", project_entry.workspace_id).into(); - - Some( - ThreadItem::new(item_id, name.clone()) - .icon(IconName::Folder) - .selected(selected) - .highlight_positions(positions.clone()) - .tooltip(move |_, cx| { - Tooltip::with_meta(name.clone(), None, full_path.clone(), cx) - }) - .into_any_element(), - ) - } - } - } - - fn render_editor( - &self, - editor: &Arc, - window: &mut Window, - cx: &mut Context>, - ) -> Div { - h_flex() - .h(Tab::container_height(cx)) - .w_full() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(editor.render(window, cx)) - } } pub struct Sidebar { - multi_workspace: Entity, + multi_workspace: WeakEntity, width: Pixels, - picker: Entity>, - _subscription: Subscription, + focus_handle: FocusHandle, + filter_editor: Entity, + list_state: ListState, + contents: SidebarContents, + selection: Option, + collapsed_groups: HashSet, + expanded_groups: HashSet, + _subscriptions: Vec, _project_subscriptions: Vec, _agent_panel_subscriptions: Vec, - _thread_subscriptions: Vec, - #[cfg(any(test, feature = "test-support"))] - test_thread_infos: HashMap, - #[cfg(any(test, feature = "test-support"))] - test_recent_project_thread_titles: HashMap, - _fetch_recent_projects: Task<()>, + _thread_store_subscription: Option, } impl EventEmitter for Sidebar {} @@ -711,15 +194,17 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) -> Self { - let delegate = WorkspacePickerDelegate::new(multi_workspace.clone()); - let picker = cx.new(|cx| { - Picker::list(delegate, window, cx) - .max_height(None) - .show_scrollbar(true) - .modal(false) + let focus_handle = cx.focus_handle(); + cx.on_focus_in(&focus_handle, window, Self::focus_in) + .detach(); + + let filter_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads…", window, cx); + editor }); - let subscription = cx.observe_in( + let observe_subscription = cx.observe_in( &multi_workspace, window, |this, _multi_workspace, window, cx| { @@ -727,38 +212,46 @@ impl Sidebar { }, ); - let fetch_recent_projects = { - let picker = picker.downgrade(); - let fs = ::global(cx); - cx.spawn_in(window, async move |_this, cx| { - let projects = get_recent_projects(None, None, fs).await; - - cx.update(|window, cx| { - if let Some(picker) = picker.upgrade() { - picker.update(cx, |picker, cx| { - picker.delegate.set_recent_projects(projects, cx); - let query = picker.query(cx); - picker.update_matches(query, window, cx); + let filter_subscription = cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { + if let editor::EditorEvent::BufferEdited = event { + let query = this.filter_editor.read(cx).text(cx); + if !query.is_empty() { + this.selection.take(); + } + this.rebuild_contents(cx); + this.list_state.reset(this.contents.entries.len()); + 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) + } }); - } - }) - .log_err(); - }) - }; + } + cx.notify(); + } + }); let mut this = Self { - multi_workspace, + multi_workspace: multi_workspace.downgrade(), width: DEFAULT_WIDTH, - picker, - _subscription: subscription, + focus_handle, + filter_editor, + list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), + contents: SidebarContents::default(), + selection: None, + collapsed_groups: HashSet::new(), + expanded_groups: HashSet::new(), + _subscriptions: vec![observe_subscription, filter_subscription], _project_subscriptions: Vec::new(), _agent_panel_subscriptions: Vec::new(), - _thread_subscriptions: Vec::new(), - #[cfg(any(test, feature = "test-support"))] - test_thread_infos: HashMap::new(), - #[cfg(any(test, feature = "test-support"))] - test_recent_project_thread_titles: HashMap::new(), - _fetch_recent_projects: fetch_recent_projects, + _thread_store_subscription: None, }; this.update_entries(window, cx); this @@ -769,8 +262,10 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) -> Vec { - let projects: Vec<_> = self - .multi_workspace + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return Vec::new(); + }; + let projects: Vec<_> = multi_workspace .read(cx) .workspaces() .iter() @@ -796,80 +291,15 @@ impl Sidebar { .collect() } - fn build_workspace_thread_entries( - &self, - multi_workspace: &MultiWorkspace, - cx: &App, - ) -> (Vec, usize) { - #[allow(unused_mut)] - let mut entries: Vec = multi_workspace - .workspaces() - .iter() - .enumerate() - .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx)) - .collect(); - - #[cfg(any(test, feature = "test-support"))] - for (index, info) in &self.test_thread_infos { - if let Some(entry) = entries.get_mut(*index) { - entry.thread_info = Some(info.clone()); - } - } - - (entries, multi_workspace.active_workspace_index()) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn set_test_recent_projects( - &self, - projects: Vec, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, _cx| { - picker.delegate.recent_projects = projects; - }); - } - - #[cfg(any(test, feature = "test-support"))] - pub fn set_test_thread_info( - &mut self, - index: usize, - title: SharedString, - status: AgentThreadStatus, - ) { - self.test_thread_infos.insert( - index, - AgentThreadInfo { - title, - status, - icon: IconName::ZedAgent, - }, - ); - } - - #[cfg(any(test, feature = "test-support"))] - pub fn set_test_recent_project_thread_title( - &mut self, - full_path: SharedString, - title: SharedString, - cx: &mut Context, - ) { - self.test_recent_project_thread_titles - .insert(full_path.clone(), title.clone()); - self.picker.update(cx, |picker, _cx| { - picker - .delegate - .recent_project_thread_titles - .insert(full_path, title); - }); - } - fn subscribe_to_agent_panels( &mut self, window: &mut Window, cx: &mut Context, ) -> Vec { - let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return Vec::new(); + }; + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().to_vec(); workspaces .iter() @@ -883,8 +313,6 @@ impl Sidebar { }, ) } else { - // Panel hasn't loaded yet — observe the workspace so we - // re-subscribe once the panel appears on its dock. cx.observe_in(workspace, window, |this, _, window, cx| { this.update_entries(window, cx); }) @@ -893,376 +321,2816 @@ impl Sidebar { .collect() } - fn subscribe_to_threads( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Vec { - let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); - - workspaces - .iter() - .filter_map(|workspace| { - let agent_panel = workspace.read(cx).panel::(cx)?; - let thread = agent_panel.read(cx).active_agent_thread(cx)?; - Some(cx.observe_in(&thread, window, |this, _, window, cx| { + fn subscribe_to_thread_store(&mut self, window: &mut Window, cx: &mut Context) { + if self._thread_store_subscription.is_some() { + return; + } + if let Some(thread_store) = ThreadStore::try_global(cx) { + self._thread_store_subscription = + Some(cx.observe_in(&thread_store, window, |this, _, window, cx| { this.update_entries(window, cx); - })) + })); + } + } + + fn all_thread_infos_for_workspace( + workspace: &Entity, + cx: &App, + ) -> Vec { + let Some(agent_panel) = workspace.read(cx).panel::(cx) else { + return Vec::new(); + }; + let agent_panel_ref = agent_panel.read(cx); + + agent_panel_ref + .parent_threads(cx) + .into_iter() + .map(|thread_view| { + let thread_view_ref = thread_view.read(cx); + let thread = thread_view_ref.thread.read(cx); + + let icon = thread_view_ref.agent_icon; + let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); + let title = thread.title(); + let session_id = thread.session_id().clone(); + let is_background = agent_panel_ref.is_background_thread(&session_id); + + let status = if thread.is_waiting_for_confirmation() { + AgentThreadStatus::WaitingForConfirmation + } else if thread.had_error() { + AgentThreadStatus::Error + } else { + match thread.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + } + }; + + ActiveThreadInfo { + session_id, + title, + status, + icon, + icon_from_external_svg, + is_background, + } }) .collect() } - /// Reconciles the sidebar's displayed entries with the current state of all - /// workspaces and their agent threads. + fn rebuild_contents(&mut self, cx: &App) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let mw = multi_workspace.read(cx); + let workspaces = mw.workspaces().to_vec(); + let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); + let active_workspace_index = active_workspace + .and_then(|active| { + workspaces + .iter() + .position(|w| w.entity_id() == active.entity_id()) + }) + .unwrap_or(0); + + let thread_store = ThreadStore::try_global(cx); + let query = self.filter_editor.read(cx).text(cx); + + let previous = mem::take(&mut self.contents); + + let old_statuses: HashMap = previous + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::Thread { + session_info, + status, + is_live: true, + .. + } => Some((session_info.session_id.clone(), *status)), + _ => None, + }) + .collect(); + + let mut entries = Vec::new(); + let mut notified_threads = previous.notified_threads; + + for (index, workspace) in workspaces.iter().enumerate() { + let (path_list, label) = workspace_path_list_and_label(workspace, cx); + + let is_collapsed = self.collapsed_groups.contains(&path_list); + let should_load_threads = !is_collapsed || !query.is_empty(); + + let mut threads: Vec = Vec::new(); + + if should_load_threads { + if let Some(ref thread_store) = thread_store { + for meta in thread_store.read(cx).threads_for_paths(&path_list) { + threads.push(ListEntry::Thread { + session_info: meta.into(), + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::default(), + diff_stats: None, + workspace_index: index, + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + }); + } + } + + let live_infos = Self::all_thread_infos_for_workspace(workspace, cx); + + for info in &live_infos { + let Some(existing) = threads.iter_mut().find(|t| { + matches!(t, ListEntry::Thread { session_info, .. } if session_info.session_id == info.session_id) + }) else { + continue; + }; + + if let ListEntry::Thread { + session_info, + status, + icon, + icon_from_external_svg, + workspace_index: _, + is_live, + is_background, + .. + } = existing + { + session_info.title = Some(info.title.clone()); + *status = info.status; + *icon = info.icon; + *icon_from_external_svg = info.icon_from_external_svg.clone(); + *is_live = true; + *is_background = info.is_background; + } + } + + // Update notification state for live threads. + for thread in &threads { + if let ListEntry::Thread { + workspace_index, + session_info, + status, + is_background, + .. + } = thread + { + let session_id = &session_info.session_id; + if *is_background && *status == AgentThreadStatus::Completed { + notified_threads.insert(session_id.clone()); + } else if *status == AgentThreadStatus::Completed + && *workspace_index != active_workspace_index + && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) + { + notified_threads.insert(session_id.clone()); + } + + if *workspace_index == active_workspace_index && !*is_background { + notified_threads.remove(session_id); + } + } + } + + threads.sort_by(|a, b| { + let a_time = match a { + ListEntry::Thread { session_info, .. } => session_info.updated_at, + _ => unreachable!(), + }; + let b_time = match b { + ListEntry::Thread { session_info, .. } => session_info.updated_at, + _ => unreachable!(), + }; + b_time.cmp(&a_time) + }); + } + + if !query.is_empty() { + let mut matched_threads = Vec::new(); + for mut thread in threads { + if let ListEntry::Thread { + session_info, + highlight_positions, + .. + } = &mut thread + { + let title = session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or(""); + if let Some(positions) = fuzzy_match_positions(&query, title) { + *highlight_positions = positions; + matched_threads.push(thread); + } + } + } + + let workspace_highlight_positions = + fuzzy_match_positions(&query, &label).unwrap_or_default(); + + if matched_threads.is_empty() && workspace_highlight_positions.is_empty() { + continue; + } + + entries.push(ListEntry::ProjectHeader { + path_list: path_list.clone(), + label, + highlight_positions: workspace_highlight_positions, + }); + entries.extend(matched_threads); + } else { + entries.push(ListEntry::ProjectHeader { + path_list: path_list.clone(), + label, + highlight_positions: Vec::new(), + }); + + if is_collapsed { + continue; + } + + let total = threads.len(); + let show_view_more = + total > DEFAULT_THREADS_SHOWN && !self.expanded_groups.contains(&path_list); + + let count = if show_view_more { + DEFAULT_THREADS_SHOWN + } else { + total + }; + + entries.extend(threads.into_iter().take(count)); + + if show_view_more { + entries.push(ListEntry::ViewMore { + path_list: path_list.clone(), + remaining_count: total - DEFAULT_THREADS_SHOWN, + }); + } + + if total == 0 { + entries.push(ListEntry::NewThread { + path_list: path_list.clone(), + }); + } + } + } + + // Prune stale entries from notified_threads. + let current_session_ids: HashSet<&acp::SessionId> = entries + .iter() + .filter_map(|e| match e { + ListEntry::Thread { session_info, .. } => Some(&session_info.session_id), + _ => None, + }) + .collect(); + notified_threads.retain(|id| current_session_ids.contains(id)); + + self.contents = SidebarContents { + entries, + notified_threads, + }; + } + fn update_entries(&mut self, window: &mut Window, cx: &mut Context) { let multi_workspace = self.multi_workspace.clone(); cx.defer_in(window, move |this, window, cx| { - if !this.multi_workspace.read(cx).multi_workspace_enabled(cx) { + let Some(multi_workspace) = multi_workspace.upgrade() else { + return; + }; + if !multi_workspace.read(cx).multi_workspace_enabled(cx) { return; } this._project_subscriptions = this.subscribe_to_projects(window, cx); this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx); - this._thread_subscriptions = this.subscribe_to_threads(window, cx); - let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| { - this.build_workspace_thread_entries(multi_workspace, cx) - }); + this.subscribe_to_thread_store(window, cx); - let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); - this.picker.update(cx, |picker, cx| { - picker.delegate.set_entries(entries, active_index, cx); - let query = picker.query(cx); - picker.update_matches(query, window, cx); - }); - let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); - if had_notifications != has_notifications { - multi_workspace.update(cx, |_, cx| cx.notify()); + let had_notifications = this.has_notifications(cx); + + this.rebuild_contents(cx); + + this.list_state.reset(this.contents.entries.len()); + + if let Some(selection) = this.selection { + if selection >= this.contents.entries.len() { + this.selection = this.contents.entries.len().checked_sub(1); + } } - }); - } -} -impl WorkspaceSidebar for Sidebar { - fn width(&self, _cx: &App) -> Pixels { - self.width - } + if had_notifications != this.has_notifications(cx) { + multi_workspace.update(cx, |_, cx| { + cx.notify(); + }); + } - fn set_width(&mut self, width: Option, cx: &mut Context) { - self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); - cx.notify(); + cx.notify(); + }); } - fn has_notifications(&self, cx: &App) -> bool { - !self.picker.read(cx).delegate.notified_workspaces.is_empty() - } -} + fn render_list_entry( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(entry) = self.contents.entries.get(ix) else { + return div().into_any_element(); + }; + let is_focused = self.focus_handle.is_focused(window) + || self.filter_editor.focus_handle(cx).is_focused(window); + let is_selected = is_focused && self.selection == Some(ix); + + let is_group_header_after_first = + ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. }); + + let rendered = match entry { + ListEntry::ProjectHeader { + path_list, + label, + highlight_positions, + } => self.render_project_header( + ix, + path_list, + label, + highlight_positions, + is_selected, + cx, + ), + ListEntry::Thread { + session_info, + icon, + icon_from_external_svg, + status, + workspace_index, + highlight_positions, + .. + } => self.render_thread( + ix, + session_info, + *icon, + icon_from_external_svg.clone(), + *status, + *workspace_index, + highlight_positions, + is_selected, + cx, + ), + ListEntry::ViewMore { + path_list, + remaining_count, + } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx), + ListEntry::NewThread { path_list } => { + self.render_new_thread(ix, path_list, is_selected, cx) + } + }; -impl Focusable for Sidebar { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.read(cx).focus_handle(cx) + if is_group_header_after_first { + v_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(rendered) + .into_any_element() + } else { + rendered + } } -} - -impl Render for Sidebar { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let titlebar_height = ui::utils::platform_title_bar_height(window); - let ui_font = theme::setup_ui_font(window, cx); - let is_focused = self.focus_handle(cx).is_focused(window); - let focus_tooltip_label = if is_focused { - "Focus Workspace" + fn render_project_header( + &self, + ix: usize, + path_list: &PathList, + label: &SharedString, + highlight_positions: &[usize], + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let id = SharedString::from(format!("project-header-{}", ix)); + let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix)); + let group = SharedString::from(format!("group-{}", ix)); + + let is_collapsed = self.collapsed_groups.contains(path_list); + let disclosure_icon = if is_collapsed { + IconName::ChevronRight } else { - "Focus Sidebar" + IconName::ChevronDown }; + let path_list_for_new_thread = path_list.clone(); + let path_list_for_remove = path_list.clone(); + let path_list_for_toggle = path_list.clone(); + let workspace_count = self + .multi_workspace + .upgrade() + .map_or(0, |mw| mw.read(cx).workspaces().len()); - v_flex() - .id("workspace-sidebar") - .key_context("WorkspaceSidebar") - .font(ui_font) - .h_full() - .w(self.width) - .bg(cx.theme().colors().surface_background) - .border_r_1() - .border_color(cx.theme().colors().border) + ListItem::new(id) + .group_name(&group) + .toggle_state(is_selected) .child( h_flex() - .flex_none() - .h(titlebar_height) - .w_full() - .mt_px() - .pb_px() - .pr_1() - .when_else( - cfg!(target_os = "macos") && !window.is_fullscreen(), - |this| this.pl(px(TRAFFIC_LIGHT_PADDING)), - |this| this.pl_2(), - ) - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child({ - let focus_handle = cx.focus_handle(); - IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Close Sidebar")) - .child(KeyBinding::for_action_in( - &ToggleWorkspaceSidebar, - &focus_handle, - cx, - )), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new(focus_tooltip_label)) - .child(KeyBinding::for_action_in( - &FocusWorkspaceSidebar, - &focus_handle, - cx, - )), - ) - .into_any_element() - })) - .on_click(cx.listener(|_this, _, _window, cx| { - cx.emit(SidebarEvent::Close); - })) + .px_1() + .py_1p5() + .gap_0p5() + .child(if highlight_positions.is_empty() { + Label::new(label.clone()) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } else { + HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() }) .child( - IconButton::new("new-workspace", IconName::Plus) + div().visible_on_hover(group).child( + Icon::new(disclosure_icon) + .size(IconSize::Small) + .color(Color::Muted), + ), + ), + ) + .end_hover_slot( + h_flex() + .gap_0p5() + .child( + IconButton::new(ib_id, IconName::NewThread) .icon_size(IconSize::Small) - .tooltip(|_window, cx| { - Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.create_workspace(window, cx); - }); + .icon_color(Color::Muted) + .tooltip(Tooltip::text("New Thread")) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.create_new_thread(&path_list_for_new_thread, window, cx); })), - ), + ) + .when(workspace_count > 1, |this| { + this.child( + IconButton::new( + SharedString::from(format!("project-header-remove-{}", ix)), + IconName::Close, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Remove Project")) + .on_click(cx.listener( + move |this, _, window, cx| { + this.remove_workspace(&path_list_for_remove, window, cx); + }, + )), + ) + }), ) - .child(self.picker.clone()) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.toggle_collapse(&path_list_for_toggle, window, cx); + })) + .into_any_element() } -} -#[cfg(test)] -mod tests { - use super::*; - use feature_flags::FeatureFlagAppExt as _; - use fs::FakeFs; - use gpui::TestAppContext; - use settings::SettingsStore; + fn remove_workspace( + &mut self, + path_list: &PathList, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + let Some(workspace_index) = workspace_index_for_path_list(&workspaces, path_list, cx) + else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.remove_workspace(workspace_index, window, cx); + }); + } + + fn toggle_collapse( + &mut self, + path_list: &PathList, + window: &mut Window, + cx: &mut Context, + ) { + if self.collapsed_groups.contains(path_list) { + self.collapsed_groups.remove(path_list); + } else { + self.collapsed_groups.insert(path_list.clone()); + } + self.update_entries(window, cx); + } + + fn focus_in(&mut self, _window: &mut Window, cx: &mut Context) { + if self.selection.is_none() && !self.contents.entries.is_empty() { + self.selection = Some(0); + cx.notify(); + } + } + + fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + if self.reset_filter_editor_text(window, cx) { + self.update_entries(window, cx); + } else { + self.focus_handle.focus(window, cx); + } + } + + fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.filter_editor.update(cx, |editor, cx| { + if editor.buffer().read(cx).len(cx).0 > 0 { + editor.set_text("", window, cx); + true + } else { + false + } + }) + } + + fn filter_query(&self, cx: &App) -> String { + self.filter_editor.read(cx).text(cx) + } + + fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { + self.select_next(&SelectNext, window, cx); + } + + fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { + self.select_previous(&SelectPrevious, window, cx); + } + + fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { + let next = match self.selection { + Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1, + None if !self.contents.entries.is_empty() => 0, + _ => return, + }; + self.selection = Some(next); + self.list_state.scroll_to_reveal_item(next); + cx.notify(); + } + + fn select_previous( + &mut self, + _: &SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + let prev = match self.selection { + Some(ix) if ix > 0 => ix - 1, + None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1, + _ => return, + }; + self.selection = Some(prev); + self.list_state.scroll_to_reveal_item(prev); + cx.notify(); + } + + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + if !self.contents.entries.is_empty() { + self.selection = Some(0); + self.list_state.scroll_to_reveal_item(0); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + if let Some(last) = self.contents.entries.len().checked_sub(1) { + self.selection = Some(last); + self.list_state.scroll_to_reveal_item(last); + cx.notify(); + } + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let Some(ix) = self.selection else { return }; + let Some(entry) = self.contents.entries.get(ix) else { + return; + }; + + match entry { + ListEntry::ProjectHeader { path_list, .. } => { + let path_list = path_list.clone(); + self.toggle_collapse(&path_list, window, cx); + } + ListEntry::Thread { + session_info, + workspace_index, + .. + } => { + let session_info = session_info.clone(); + let workspace_index = *workspace_index; + self.activate_thread(session_info, workspace_index, window, cx); + } + ListEntry::ViewMore { path_list, .. } => { + let path_list = path_list.clone(); + self.expanded_groups.insert(path_list); + self.update_entries(window, cx); + } + ListEntry::NewThread { path_list } => { + let path_list = path_list.clone(); + self.create_new_thread(&path_list, window, cx); + } + } + } + + fn activate_thread( + &mut self, + session_info: acp_thread::AgentSessionInfo, + workspace_index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate_index(workspace_index, window, cx); + }); + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + if let Some(workspace) = workspaces.get(workspace_index) { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + agent_panel.update(cx, |panel, cx| { + panel.load_agent_thread(session_info, window, cx); + }); + } + } + } + + fn expand_selected_entry( + &mut self, + _: &ExpandSelectedEntry, + window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { return }; + + match self.contents.entries.get(ix) { + Some(ListEntry::ProjectHeader { path_list, .. }) => { + if self.collapsed_groups.contains(path_list) { + let path_list = path_list.clone(); + self.collapsed_groups.remove(&path_list); + self.update_entries(window, cx); + } else if ix + 1 < self.contents.entries.len() { + self.selection = Some(ix + 1); + self.list_state.scroll_to_reveal_item(ix + 1); + cx.notify(); + } + } + _ => {} + } + } + + fn collapse_selected_entry( + &mut self, + _: &CollapseSelectedEntry, + window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { return }; + + match self.contents.entries.get(ix) { + Some(ListEntry::ProjectHeader { path_list, .. }) => { + if !self.collapsed_groups.contains(path_list) { + let path_list = path_list.clone(); + self.collapsed_groups.insert(path_list); + self.update_entries(window, cx); + } + } + Some( + ListEntry::Thread { .. } | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, + ) => { + for i in (0..ix).rev() { + if let Some(ListEntry::ProjectHeader { path_list, .. }) = + self.contents.entries.get(i) + { + let path_list = path_list.clone(); + self.selection = Some(i); + self.collapsed_groups.insert(path_list); + self.update_entries(window, cx); + break; + } + } + } + None => {} + } + } + + fn render_thread( + &self, + ix: usize, + session_info: &acp_thread::AgentSessionInfo, + icon: IconName, + icon_from_external_svg: Option, + status: AgentThreadStatus, + workspace_index: usize, + highlight_positions: &[usize], + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let has_notification = self.contents.is_thread_notified(&session_info.session_id); + + let title: SharedString = session_info + .title + .clone() + .unwrap_or_else(|| "Untitled".into()); + let session_info = session_info.clone(); + + let id = SharedString::from(format!("thread-entry-{}", ix)); + ThreadItem::new(id, title) + .icon(icon) + .when_some(icon_from_external_svg, |this, svg| { + this.custom_icon_from_external_svg(svg) + }) + .highlight_positions(highlight_positions.to_vec()) + .status(status) + .notified(has_notification) + .selected(is_selected) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.activate_thread(session_info.clone(), workspace_index, window, cx); + })) + .into_any_element() + } + + fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: rems(0.875).into(), + font_weight: settings.ui_font.weight, + font_style: FontStyle::Normal, + line_height: relative(1.3), + ..Default::default() + }; + + EditorElement::new( + &self.filter_editor, + EditorStyle { + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } + + fn render_view_more( + &self, + ix: usize, + path_list: &PathList, + remaining_count: usize, + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let path_list = path_list.clone(); + let id = SharedString::from(format!("view-more-{}", ix)); + + let count = format!("({})", remaining_count); + + ListItem::new(id) + .toggle_state(is_selected) + .child( + h_flex() + .px_1() + .py_1p5() + .gap_1p5() + .child( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new("View More")) + .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.expanded_groups.insert(path_list.clone()); + this.update_entries(window, cx); + })) + .into_any_element() + } + + fn create_new_thread( + &mut self, + path_list: &PathList, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + let workspace_index = workspace_index_for_path_list(&workspaces, path_list, cx); + + let Some(workspace_index) = workspace_index else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate_index(workspace_index, window, cx); + }); + + if let Some(workspace) = workspaces.get(workspace_index) { + workspace.update(cx, |workspace, cx| { + if let Some(agent_panel) = workspace.panel::(cx) { + agent_panel.update(cx, |panel, cx| { + panel.new_thread(&NewThread, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + } + } + + fn render_new_thread( + &self, + ix: usize, + path_list: &PathList, + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let path_list = path_list.clone(); + + div() + .w_full() + .p_2() + .child( + Button::new( + SharedString::from(format!("new-thread-btn-{}", ix)), + "New Thread", + ) + .full_width() + .style(ButtonStyle::Outlined) + .icon(IconName::Plus) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .toggle_state(is_selected) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.create_new_thread(&path_list, window, cx); + })), + ) + .into_any_element() + } +} + +impl WorkspaceSidebar for Sidebar { + fn width(&self, _cx: &App) -> Pixels { + self.width + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + cx.notify(); + } + + fn has_notifications(&self, _cx: &App) -> bool { + !self.contents.notified_threads.is_empty() + } +} + +impl Focusable for Sidebar { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.filter_editor.focus_handle(cx) + } +} + +impl Render for Sidebar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let titlebar_height = ui::utils::platform_title_bar_height(window); + let ui_font = theme::setup_ui_font(window, cx); + let is_focused = self.focus_handle.is_focused(window) + || self.filter_editor.focus_handle(cx).is_focused(window); + let has_query = !self.filter_query(cx).is_empty(); + + let focus_tooltip_label = if is_focused { + "Focus Workspace" + } else { + "Focus Sidebar" + }; + + v_flex() + .id("workspace-sidebar") + .key_context("WorkspaceSidebar") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::expand_selected_entry)) + .on_action(cx.listener(Self::collapse_selected_entry)) + .on_action(cx.listener(Self::cancel)) + .font(ui_font) + .h_full() + .w(self.width) + .bg(cx.theme().colors().surface_background) + .border_r_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .flex_none() + .h(titlebar_height) + .w_full() + .mt_px() + .pb_px() + .pr_1() + .when_else( + cfg!(target_os = "macos") && !window.is_fullscreen(), + |this| this.pl(px(TRAFFIC_LIGHT_PADDING)), + |this| this.pl_2(), + ) + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child({ + let focus_handle_toggle = self.focus_handle.clone(); + let focus_handle_focus = self.focus_handle.clone(); + IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Close Sidebar")) + .child(KeyBinding::for_action_in( + &ToggleWorkspaceSidebar, + &focus_handle_toggle, + cx, + )), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new(focus_tooltip_label)) + .child(KeyBinding::for_action_in( + &FocusWorkspaceSidebar, + &focus_handle_focus, + cx, + )), + ) + .into_any_element() + })) + .on_click(cx.listener(|_this, _, _window, cx| { + cx.emit(SidebarEvent::Close); + })) + }) + .child({ + let workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().downgrade()); + let focus_handle = workspace + .as_ref() + .and_then(|w| w.upgrade()) + .map(|w| w.read(cx).focus_handle(cx)) + .unwrap_or_else(|| cx.focus_handle()); + + PopoverMenu::new("sidebar-recent-projects-menu") + .menu(move |window, cx| { + let workspace = workspace.clone()?; + Some(recent_projects::RecentProjects::popover( + workspace, + false, + focus_handle.clone(), + window, + cx, + )) + }) + .trigger_with_tooltip( + IconButton::new("new-workspace", IconName::OpenFolder) + .icon_size(IconSize::Small), + |_window, cx| { + Tooltip::for_action( + "Open Recent Project", + &zed_actions::OpenRecent { + create_new_window: false, + }, + cx, + ) + }, + ) + .anchor(gpui::Corner::TopLeft) + }), + ) + .child( + h_flex() + .flex_none() + .p_2() + .h(Tab::container_height(cx)) + .gap_1p5() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.render_filter_input(cx)) + .when(has_query, |this| { + this.pr_1().child( + IconButton::new("clear_filter", IconName::Close) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_entries(window, cx); + })), + ) + }), + ) + .child( + v_flex() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .vertical_scrollbar_for(&self.list_state, window, cx), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acp_thread::StubAgentConnection; + use agent::ThreadStore; + use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; + use assistant_text_thread::TextThreadStore; + use chrono::DateTime; + use feature_flags::FeatureFlagAppExt as _; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + use std::sync::Arc; + use util::path_list::PathList; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(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, + ) -> Entity { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await + } + + fn setup_sidebar( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Entity { + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + sidebar + } + + async fn save_n_test_threads( + count: u32, + 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(); + } + cx.run_until_parked(); + } + + async fn save_thread_to_store( + session_id: &acp::SessionId, + 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(); + cx.run_until_parked(); + } + + fn open_and_focus_sidebar( + sidebar: &Entity, + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, + ) { + multi_workspace.update_in(cx, |mw, window, cx| { + mw.toggle_sidebar(window, cx); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); + } + + fn visible_entries_as_strings( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Vec { + sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let selected = if sidebar.selection == Some(ix) { + " <== selected" + } else { + "" + }; + match entry { + ListEntry::ProjectHeader { + label, + path_list, + highlight_positions: _, + .. + } => { + let icon = if sidebar.collapsed_groups.contains(path_list) { + ">" + } else { + "v" + }; + format!("{} [{}]{}", icon, label, selected) + } + ListEntry::Thread { + session_info, + status, + is_live, + .. + } => { + let title = session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or("Untitled"); + let active = if *is_live { " *" } else { "" }; + let status_str = match status { + AgentThreadStatus::Running => " (running)", + AgentThreadStatus::Error => " (error)", + AgentThreadStatus::WaitingForConfirmation => " (waiting)", + _ => "", + }; + let notified = if sidebar + .contents + .is_thread_notified(&session_info.session_id) + { + " (!)" + } else { + "" + }; + format!( + " {}{}{}{}{}", + title, active, status_str, notified, selected + ) + } + ListEntry::ViewMore { + remaining_count, .. + } => { + format!(" + View More ({}){}", remaining_count, selected) + } + ListEntry::NewThread { .. } => { + format!(" [+ New Thread]{}", selected) + } + } + }) + .collect() + }) + } + + #[gpui::test] + async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]"] + ); + } + + #[gpui::test] + async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + 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(); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + ] + ); + } + + #[gpui::test] + async fn test_workspace_lifecycle(cx: &mut TestAppContext) { + let project = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // 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(); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); + + // Add a second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Thread A1", + "v [Empty Workspace]", + " [+ New Thread]" + ] + ); + + // Remove the second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.remove_workspace(1, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); + } + + #[gpui::test] + async fn test_view_more_pagination(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(12, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Thread 12", + " Thread 11", + " Thread 10", + " Thread 9", + " Thread 8", + " + View More (7)", + ] + ); + } + + #[gpui::test] + async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Collapse + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + + // Expand + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + } + + #[gpui::test] + async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); + let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); + + sidebar.update_in(cx, |s, _window, _cx| { + s.collapsed_groups.insert(collapsed_path.clone()); + s.contents + .notified_threads + .insert(acp::SessionId::new(Arc::from("t-5"))); + s.contents.entries = vec![ + // Expanded project header + ListEntry::ProjectHeader { + path_list: expanded_path.clone(), + label: "expanded-project".into(), + highlight_positions: Vec::new(), + }, + // Thread with default (Completed) status, not active + ListEntry::Thread { + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-1")), + cwd: None, + title: Some("Completed thread".into()), + updated_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + diff_stats: None, + workspace_index: 0, + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + }, + // Active thread with Running status + ListEntry::Thread { + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-2")), + cwd: None, + title: Some("Running thread".into()), + updated_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Running, + diff_stats: None, + workspace_index: 0, + is_live: true, + is_background: false, + highlight_positions: Vec::new(), + }, + // Active thread with Error status + ListEntry::Thread { + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-3")), + cwd: None, + title: Some("Error thread".into()), + updated_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Error, + diff_stats: None, + workspace_index: 1, + is_live: true, + is_background: false, + highlight_positions: Vec::new(), + }, + // Thread with WaitingForConfirmation status, not active + ListEntry::Thread { + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-4")), + cwd: None, + title: Some("Waiting thread".into()), + updated_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::WaitingForConfirmation, + diff_stats: None, + workspace_index: 0, + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + }, + // Background thread that completed (should show notification) + ListEntry::Thread { + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-5")), + cwd: None, + title: Some("Notified thread".into()), + updated_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + diff_stats: None, + workspace_index: 1, + is_live: true, + is_background: true, + highlight_positions: Vec::new(), + }, + // View More entry + ListEntry::ViewMore { + path_list: expanded_path.clone(), + remaining_count: 42, + }, + // Collapsed project header + ListEntry::ProjectHeader { + path_list: collapsed_path.clone(), + label: "collapsed-project".into(), + highlight_positions: Vec::new(), + }, + ]; + // Select the Running thread (index 2) + s.selection = Some(2); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [expanded-project]", + " Completed thread", + " Running thread * (running) <== selected", + " Error thread * (error)", + " Waiting thread (waiting)", + " Notified thread * (!)", + " + View More (42)", + "> [collapsed-project]", + ] + ); + + // Move selection to the collapsed header + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = Some(7); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx).last().cloned(), + Some("> [collapsed-project] <== selected".to_string()), + ); + + // Clear selection + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = None; + }); + + // No entry should have the selected marker + let entries = visible_entries_as_strings(&sidebar, cx); + for entry in &entries { + assert!( + !entry.contains("<== selected"), + "unexpected selection marker in: {}", + entry + ); + } + } + + #[gpui::test] + async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Entries: [header, thread3, thread2, thread1] + // Focusing the sidebar triggers focus_in, which selects the first entry + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Move down through all entries + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // At the end, selection stays on the last entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // Move back up + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // At the top, selection stays on the first entry + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + + // SelectLast jumps to the end + cx.dispatch_action(SelectLast); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // SelectFirst jumps to the beginning + cx.dispatch_action(SelectFirst); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_keyboard_focus_in_selects_first(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Initially no selection + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Open the sidebar so it's rendered, then focus it to trigger focus_in + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Blur the sidebar, then refocus — existing selection should be preserved + cx.update(|window, _cx| { + window.blur(); + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus the sidebar — focus_in selects the header (index 0) + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Press confirm to collapse + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Confirm again to expand + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); + } + + #[gpui::test] + async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(8, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show header + 5 threads + "View More (3)" + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More (3)"))); + + // Focus sidebar (selects index 0), then navigate down to the "View More" entry (index 6) + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + for _ in 0..6 { + cx.dispatch_action(SelectNext); + } + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); + + // Confirm on "View More" to expand + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // All 8 threads should now be visible, no "View More" + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 9); // header + 8 threads + assert!(!entries.iter().any(|e| e.contains("View More"))); + } + + #[gpui::test] + async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus sidebar — focus_in selects the header (index 0). Press left to collapse. + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Press right to expand + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); + + // Press right again on already-expanded header moves selection down + cx.dispatch_action(ExpandSelectedEntry); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + } + + #[gpui::test] + async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selects header at index 0), then navigate down to the thread (child) + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1 <== selected",] + ); + + // Pressing left on a child collapses the parent group and selects it + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + } + + #[gpui::test] + async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { + let project = init_test_project("/empty-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Even an empty project has the header and a new thread button + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [empty-project]", " [+ New Thread]"] + ); + + // Focus sidebar — focus_in selects the first entry (header at 0) + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectNext moves to the new thread button + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // At the end, selection stays on the last entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // SelectPrevious goes back to the header + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selects header at 0), navigate down to the thread (index 1) + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - fn init_test(cx: &mut TestAppContext) { + // Collapse the group, which removes the thread from the list + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + + // Selection should be clamped to the last valid index (0 = header) + let selection = sidebar.read_with(cx, |s, _| s.selection); + let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); + assert!( + selection.unwrap_or(0) < entry_count, + "selection {} should be within bounds (entries: {})", + selection.unwrap_or(0), + entry_count, + ); + } + + async fn init_test_project_with_agent_panel( + worktree_path: &str, + cx: &mut TestAppContext, + ) -> Entity { + agent_ui::test_support::init_test(cx); cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await } - fn set_thread_info_and_refresh( - sidebar: &Entity, + fn add_agent_panel( + workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Entity { + workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + } + + fn setup_sidebar_with_agent_panel( multi_workspace: &Entity, - index: usize, - title: &str, - status: AgentThreadStatus, + project: &Entity, cx: &mut gpui::VisualTestContext, - ) { - sidebar.update_in(cx, |s, _window, _cx| { - s.set_test_thread_info(index, SharedString::from(title.to_string()), status); + ) -> (Entity, Entity) { + let sidebar = setup_sidebar(multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let panel = add_agent_panel(&workspace, project, cx); + (sidebar, panel) + } + + #[gpui::test] + async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Open thread A and keep it generating. + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection_a.clone(), cx); + send_message(&panel, cx); + + let session_id_a = active_session_id(&panel, cx); + save_thread_to_store(&session_id_a, &path_list, cx).await; + + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); }); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - } - fn has_notifications(sidebar: &Entity, cx: &mut gpui::VisualTestContext) -> bool { - sidebar.read_with(cx, |s, cx| s.has_notifications(cx)) + // Open thread B (idle, default response) — thread A goes to background. + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection_b, cx); + send_message(&panel, cx); + + let session_id_b = active_session_id(&panel, cx); + save_thread_to_store(&session_id_b, &path_list, cx).await; + + cx.run_until_parked(); + + let mut entries = visible_entries_as_strings(&sidebar, cx); + entries[1..].sort(); + assert_eq!( + entries, + vec!["v [my-project]", " Hello *", " Hello * (running)",] + ); } #[gpui::test] - async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs, [], cx).await; + async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Open thread on workspace A and keep it generating. + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel_a, connection_a.clone(), 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; + + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Add a second workspace and activate it (making workspace A the background). + let fs = cx.update(|_, cx| ::global(cx)); + let project_b = project::Project::test(fs, [], cx).await; + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + cx.run_until_parked(); + + // Thread A is still running; no notification yet. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Hello * (running)", + "v [Empty Workspace]", + " [+ New Thread]", + ] + ); + + // Complete thread A's turn (transition Running → Completed). + connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); + cx.run_until_parked(); + + // The completed background thread shows a notification indicator. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Hello * (!)", + "v [Empty Workspace]", + " [+ New Thread]", + ] + ); + } + + fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, window, cx| { + window.focus(&sidebar.filter_editor.focus_handle(cx), cx); + sidebar.filter_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + }); + cx.run_until_parked(); + } + #[gpui::test] + async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + 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(); + } + cx.run_until_parked(); - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + " Refactor settings module", + ] + ); + + // User types "diff" in the search box — only the matching thread remains, + // with its workspace header preserved for context. + type_in_search(&sidebar, "diff", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Add inline diff view <== selected",] + ); + + // User changes query to something with no matches — list is empty. + type_in_search(&sidebar, "nonexistent", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + } + + #[gpui::test] + async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { + // Scenario: A user remembers a thread title but not the exact casing. + // Search should match case-insensitively so they can still find it. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + 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(); + cx.run_until_parked(); + + // Lowercase query matches mixed-case title. + type_in_search(&sidebar, "fix crash", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + + // Uppercase query also matches the same title. + type_in_search(&sidebar, "FIX CRASH", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + } + + #[gpui::test] + async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { + // Scenario: A user searches, finds what they need, then presses Escape + // to dismiss the filter and see the full list again. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + 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(); + } + cx.run_until_parked(); + + // Confirm the full list is showing. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread", " Beta thread",] + ); + + // User types a search query to filter down. + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread <== selected",] + ); + + // User presses Escape — filter clears, full list is restored. + cx.dispatch_action(Cancel); cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Alpha thread <== selected", + " Beta thread", + ] + ); + } + + #[gpui::test] + async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + 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(); + } - // Create a second workspace and switch to it so workspace 0 is background. + // Add a second workspace. multi_workspace.update_in(cx, |mw, window, cx| { mw.create_workspace(window, cx); }); cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("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(); + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar", + " Add tests for editor", + "v [Empty Workspace]", + " Refactor sidebar layout", + " Fix typo in README", + ] + ); + + // "sidebar" matches a thread in each workspace — both headers stay visible. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + "v [Empty Workspace]", + " Refactor sidebar layout", + ] + ); + + // "typo" only matches in the second workspace — the first header disappears. + type_in_search(&sidebar, "typo", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [Empty Workspace]", " Fix typo in README <== selected",] + ); + + // "project-a" matches the first workspace name — the header appears alone + // without any child threads (none of them match "project-a"). + type_in_search(&sidebar, "project-a", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a] <== selected"] + ); + } + + #[gpui::test] + async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { + let project_a = init_test_project("/alpha-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + 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(); + } + + // Add a second workspace. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); + mw.create_workspace(window, cx); }); cx.run_until_parked(); - assert!( - !has_notifications(&sidebar, cx), - "should have no notifications initially" + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("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(); + } + cx.run_until_parked(); + + // "alpha" matches the workspace name "alpha-project" but no thread titles. + // The workspace header should appear with no child threads. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project] <== selected"] ); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Running, - cx, + // "sidebar" matches thread titles in both workspaces but not workspace names. + // Both headers appear with their matching threads. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + "v [Empty Workspace]", + " Refactor sidebar layout", + ] ); - assert!( - !has_notifications(&sidebar, cx), - "Running status alone should not create a notification" + // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r + // doesn't match) — but does not match either workspace name or any thread. + // Actually let's test something simpler: a query that matches both a workspace + // name AND some threads in that workspace. Matching threads should still appear. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + "v [Empty Workspace]", + " Fix typo in README", + ] ); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Completed, - cx, + // A query that matches a workspace name AND a thread in that same workspace. + // Both the header (highlighted) and the matching thread should appear. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project] <== selected"] ); - assert!( - has_notifications(&sidebar, cx), - "Running → Completed transition should create a notification" + // Now search for something that matches only a workspace name when there + // are also threads with matching titles — the non-matching workspace's + // threads should still appear if their titles match. + type_in_search(&sidebar, "alp", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project] <== selected"] ); } #[gpui::test] - async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs, [], cx).await; - + async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, 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). + for i in 0..8u32 { + let title = if i == 0 { + "Hidden gem thread".to_string() + } 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(); + } cx.run_until_parked(); - // Workspace 0 is the active workspace — thread completes while - // the user is already looking at it. - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Running, - cx, + // Confirm the thread is not visible and View More is shown. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("View More")), + "should have View More button" ); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Completed, - cx, + assert!( + !entries.iter().any(|e| e.contains("Hidden gem")), + "Hidden gem should be behind View More" ); + // User searches for the hidden thread — it appears, and View More is gone. + type_in_search(&sidebar, "hidden gem", cx); + let filtered = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + filtered, + vec!["v [my-project]", " Hidden gem thread <== selected",] + ); assert!( - !has_notifications(&sidebar, cx), - "should not notify for the workspace the user is already looking at" + !filtered.iter().any(|e| e.contains("View More")), + "View More should not appear when filtering" ); } #[gpui::test] - async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs, [], cx).await; + async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + 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(); + cx.run_until_parked(); + + // User focuses the sidebar and collapses the group using keyboard: + // select the header, then press Confirm to toggle collapse. + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // User types a search — the thread appears even though its group is collapsed. + type_in_search(&sidebar, "important", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]", " Important thread <== selected",] + ); + } + + #[gpui::test] + async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + 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(); + } + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, &multi_workspace, cx); + + // User types "fix" — two threads match. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + + // Selection starts on the first matching thread. User presses + // SelectNext to move to the second match. + cx.dispatch_action(SelectNext); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel", + " Fix lint warnings <== selected", + ] + ); + + // User can also jump back with SelectPrevious. + cx.dispatch_action(SelectPrevious); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + } + #[gpui::test] + async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) - }); multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); + mw.create_workspace(window, cx); }); cx.run_until_parked(); - // Create a second workspace so we can switch away and back. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, 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("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(); + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Switch to workspace 1 so workspace 0 becomes a background workspace. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Historical Thread", + "v [Empty Workspace]", + " [+ New Thread]", + ] + ); + + // Switch to workspace 1 so we can verify the confirm switches back. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_index(1, window, cx); }); cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); - // Thread on workspace 0 transitions Running → Completed while - // the user is looking at workspace 1. - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Running, - cx, + // Confirm on the historical (non-live) thread at index 1. + // Before the fix, workspace_index was Option and historical + // threads had None, so activate_thread early-returned without + // switching the workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 ); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Completed, - cx, + } + + #[gpui::test] + async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + 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(); + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread A", " Thread B",] ); - assert!( - has_notifications(&sidebar, cx), - "background workspace completion should create a notification" + // Keyboard confirm preserves selection. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.selection), + Some(1) ); - // Switching back to workspace 0 should clear the notification. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); + // Click handlers clear selection to None so no highlight lingers + // after a click regardless of focus state. The hover style provides + // visual feedback during mouse interaction instead. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = None; + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + sidebar.toggle_collapse(&path_list, window, cx); }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + + // When the user tabs back into the sidebar, focus_in restores + // selection to the first entry for keyboard navigation. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.focus_in(window, cx); + }); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.selection), + Some(0) + ); + } + + #[gpui::test] + async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_thread_to_store(&session_id, &path_list, cx).await; cx.run_until_parked(); - assert!( - !has_notifications(&sidebar, cx), - "notification should be cleared when workspace becomes active" + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate the agent generating a title. The notification chain is: + // AcpThread::set_title emits TitleUpdated → + // ConnectionView::handle_thread_event calls cx.notify() → + // AgentPanel observer fires and emits AgentPanelEvent → + // Sidebar subscription calls update_entries / rebuild_contents. + // + // Before the fix, handle_thread_event did NOT call cx.notify() for + // TitleUpdated, so the AgentPanel observer never fired and the + // sidebar kept showing the old title. + let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + thread.update(cx, |thread, cx| { + thread + .set_title("Friendly Greeting with AI".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Friendly Greeting with AI *"] ); } } diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 6cc710690ea0103bf2de4253bc405eb52be5af69..52d91e09824077738bde6be75122b0bf7b9e3d52 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -3,7 +3,7 @@ use crate::{ prelude::*, }; -use gpui::{AnyView, ClickEvent, SharedString}; +use gpui::{AnyView, ClickEvent, Hsla, SharedString}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -18,10 +18,10 @@ pub enum AgentThreadStatus { pub struct ThreadItem { id: ElementId, icon: IconName, + custom_icon_from_external_svg: Option, title: SharedString, timestamp: SharedString, - running: bool, - generation_done: bool, + notified: bool, status: AgentThreadStatus, selected: bool, hovered: bool, @@ -41,10 +41,10 @@ impl ThreadItem { Self { id: id.into(), icon: IconName::ZedAgent, + custom_icon_from_external_svg: None, title: title.into(), timestamp: "".into(), - running: false, - generation_done: false, + notified: false, status: AgentThreadStatus::default(), selected: false, hovered: false, @@ -70,13 +70,13 @@ impl ThreadItem { self } - pub fn running(mut self, running: bool) -> Self { - self.running = running; + pub fn custom_icon_from_external_svg(mut self, svg: impl Into) -> Self { + self.custom_icon_from_external_svg = Some(svg.into()); self } - pub fn generation_done(mut self, generation_done: bool) -> Self { - self.generation_done = generation_done; + pub fn notified(mut self, notified: bool) -> Self { + self.notified = notified; self } @@ -155,49 +155,34 @@ impl RenderOnce for ThreadItem { // }; let icon_container = || h_flex().size_4().justify_center(); - let agent_icon = Icon::new(self.icon) - .color(Color::Muted) - .size(IconSize::Small); + let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg { + Icon::from_external_svg(custom_svg) + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(self.icon) + .color(Color::Muted) + .size(IconSize::Small) + }; - let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation { - Some( - IconDecoration::new( - IconDecorationKind::Triangle, - cx.theme().colors().surface_background, - cx, - ) - .color(cx.theme().status().warning) + let decoration = |icon: IconDecorationKind, color: Hsla| { + IconDecoration::new(icon, cx.theme().colors().surface_background, cx) + .color(color) .position(gpui::Point { x: px(-2.), y: px(-2.), - }), - ) + }) + }; + + let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation { + Some(decoration( + IconDecorationKind::Triangle, + cx.theme().status().warning, + )) } else if self.status == AgentThreadStatus::Error { - Some( - IconDecoration::new( - IconDecorationKind::X, - cx.theme().colors().surface_background, - cx, - ) - .color(cx.theme().status().error) - .position(gpui::Point { - x: px(-2.), - y: px(-2.), - }), - ) - } else if self.generation_done { - Some( - IconDecoration::new( - IconDecorationKind::Dot, - cx.theme().colors().surface_background, - cx, - ) - .color(cx.theme().colors().text_accent) - .position(gpui::Point { - x: px(-2.), - y: px(-2.), - }), - ) + Some(decoration(IconDecorationKind::X, cx.theme().status().error)) + } else if self.notified { + Some(decoration(IconDecorationKind::Dot, clr.text_accent)) } else { None }; @@ -208,9 +193,11 @@ impl RenderOnce for ThreadItem { icon_container().child(agent_icon) }; - let running_or_action = self.running || (self.hovered && self.action_slot.is_some()); - - // let has_no_changes = self.added.is_none() && self.removed.is_none(); + let is_running = matches!( + self.status, + AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation + ); + let running_or_action = is_running || (self.hovered && self.action_slot.is_some()); let title = self.title; let highlight_positions = self.highlight_positions; @@ -225,6 +212,7 @@ impl RenderOnce for ThreadItem { v_flex() .id(self.id.clone()) .cursor_pointer() + .w_full() .map(|this| { if self.worktree.is_some() { this.p_2() @@ -255,7 +243,7 @@ impl RenderOnce for ThreadItem { this.child( h_flex() .gap_1() - .when(self.running, |this| { + .when(is_running, |this| { this.child( icon_container() .child(SpinnerLabel::new().color(Color::Accent)), @@ -347,12 +335,12 @@ impl Component for ThreadItem { .into_any_element(), ), single_example( - "Generation Done", + "Notified", container() .child( ThreadItem::new("ti-2", "Refine thread view scrolling behavior") .timestamp("12:12 AM") - .generation_done(true), + .notified(true), ) .into_any_element(), ), @@ -383,7 +371,7 @@ impl Component for ThreadItem { ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) .timestamp("7:30 PM") - .running(true), + .status(AgentThreadStatus::Running), ) .into_any_element(), ), diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 1f923769780de2ae7f1dc18d3334020960ff3bb6..7d605c7924a7d9c25a89634ca7339a457fb99ae4 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// other path lists without regard to the order of the paths. /// /// The paths can be retrieved in the original order using `ordered_paths()`. -#[derive(Default, PartialEq, Eq, Debug, Clone)] +#[derive(Default, PartialEq, Eq, Hash, Debug, Clone)] pub struct PathList { /// The paths, in lexicographic order. paths: Arc<[PathBuf]>, diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 1caa5b56e5f38db00ad59a4aca3a2a830ee023b7..1a16b731b44db9e1678bba9c316e388139d39058 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -151,7 +151,7 @@ const CONTENT: (Section<4>, Section<3>) = ( SectionEntry { icon: IconName::FolderOpen, title: "Open Project", - action: &Open, + action: &Open::DEFAULT, }, SectionEntry { icon: IconName::CloudDownload, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 32c019af1d3e4956fe1609d2e388abb309bc7630..aba2fc9d98ed6e2178a925029ae7e040004cb102 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -209,6 +209,34 @@ pub trait DebuggerProvider { fn active_thread_state(&self, cx: &App) -> Option; } +/// Opens a file or directory. +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = workspace)] +pub struct Open { + /// When true, opens in a new window. When false, adds to the current + /// window as a new workspace (multi-workspace). + #[serde(default = "Open::default_create_new_window")] + pub create_new_window: bool, +} + +impl Open { + pub const DEFAULT: Self = Self { + create_new_window: true, + }; + + /// Used by `#[serde(default)]` on the `create_new_window` field so that + /// the serde default and `Open::DEFAULT` stay in sync. + fn default_create_new_window() -> bool { + Self::DEFAULT.create_new_window + } +} + +impl Default for Open { + fn default() -> Self { + Self::DEFAULT + } +} + actions!( workspace, [ @@ -254,8 +282,6 @@ actions!( NewSearch, /// Opens a new window. NewWindow, - /// Opens a file or directory. - Open, /// Opens multiple files. OpenFiles, /// Opens the current location in terminal. @@ -626,7 +652,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { - prompt_for_open_path_and_open(workspace, app_state, options, window, cx); + prompt_for_open_path_and_open(workspace, app_state, options, true, window, cx); }); }) .ok(); @@ -638,7 +664,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c window.activate_window(); let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { - prompt_for_open_path_and_open(workspace, app_state, options, window, cx); + prompt_for_open_path_and_open(workspace, app_state, options, true, window, cx); }); })?; anyhow::Ok(()) @@ -651,6 +677,7 @@ pub fn prompt_for_open_path_and_open( workspace: &mut Workspace, app_state: Arc, options: PathPromptOptions, + create_new_window: bool, window: &mut Window, cx: &mut Context, ) { @@ -660,10 +687,24 @@ pub fn prompt_for_open_path_and_open( window, cx, ); + let multi_workspace_handle = window.window_handle().downcast::(); cx.spawn_in(window, async move |this, cx| { let Some(paths) = paths.await.log_err().flatten() else { return; }; + if !create_new_window { + if let Some(handle) = multi_workspace_handle { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.await.log_err(); + } + return; + } + } if let Some(task) = this .update_in(cx, |this, window, cx| { this.open_workspace_for_paths(false, paths, window, cx) diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 6ea308db5a32cf82e48439c477c8bb81f02ab777..5bec10439f75a4d3188ef977cf5f3e4c4733d8c6 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -48,7 +48,6 @@ visual-tests = [ "language_model/test-support", "fs/test-support", "recent_projects/test-support", - "sidebar/test-support", "title_bar/test-support", ] diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 561f980ada5bf21e0206fa2ce72de1f3a3b54998..57d2f4462b959ebe31abd3a3ecec298977e0a877 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -42,6 +42,55 @@ fn main() { std::process::exit(1); } +#[cfg(target_os = "macos")] +fn main() { + // Set ZED_STATELESS early to prevent file system access to real config directories + // This must be done before any code accesses zed_env_vars::ZED_STATELESS + // SAFETY: We're at the start of main(), before any threads are spawned + unsafe { + std::env::set_var("ZED_STATELESS", "1"); + } + + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); + + let update_baseline = std::env::var("UPDATE_BASELINE").is_ok(); + + // Create a temporary directory for test files + // Canonicalize the path to resolve symlinks (on macOS, /var -> /private/var) + // which prevents "path does not exist" errors during worktree scanning + // Use keep() to prevent auto-cleanup - background worktree tasks may still be running + // when tests complete, so we let the OS clean up temp directories on process exit + let temp_dir = tempfile::tempdir().expect("Failed to create temp directory"); + let temp_path = temp_dir.keep(); + let canonical_temp = temp_path + .canonicalize() + .expect("Failed to canonicalize temp directory"); + let project_path = canonical_temp.join("project"); + std::fs::create_dir_all(&project_path).expect("Failed to create project directory"); + + // Create test files in the real filesystem + create_test_files(&project_path); + + let test_result = std::panic::catch_unwind(|| run_visual_tests(project_path, update_baseline)); + + // Note: We don't delete temp_path here because background worktree tasks may still + // be running. The directory will be cleaned up when the process exits or by the OS. + + match test_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + eprintln!("Visual tests failed: {}", e); + std::process::exit(1); + } + Err(_) => { + eprintln!("Visual tests panicked"); + std::process::exit(1); + } + } +} + // All macOS-specific imports grouped together #[cfg(target_os = "macos")] use { @@ -50,7 +99,6 @@ use { agent_servers::{AgentServer, AgentServerDelegate}, anyhow::{Context as _, Result}, assets::Assets, - chrono::{Duration as ChronoDuration, Utc}, editor::display_map::DisplayRow, feature_flags::FeatureFlagAppExt as _, git_ui::project_diff::ProjectDiff, @@ -60,7 +108,6 @@ use { }, image::RgbaImage, project_panel::ProjectPanel, - recent_projects::RecentProjectEntry, settings::{NotifyWhenAgentWaiting, Settings as _}, settings_ui::SettingsWindow, std::{ @@ -71,7 +118,7 @@ use { time::Duration, }, util::ResultExt as _, - workspace::{AppState, MultiWorkspace, Panel as _, Workspace, WorkspaceId}, + workspace::{AppState, MultiWorkspace, Panel as _, Workspace}, zed_actions::OpenSettingsAt, }; @@ -97,55 +144,6 @@ mod constants { #[cfg(target_os = "macos")] use constants::*; -#[cfg(target_os = "macos")] -fn main() { - // Set ZED_STATELESS early to prevent file system access to real config directories - // This must be done before any code accesses zed_env_vars::ZED_STATELESS - // SAFETY: We're at the start of main(), before any threads are spawned - unsafe { - std::env::set_var("ZED_STATELESS", "1"); - } - - env_logger::builder() - .filter_level(log::LevelFilter::Info) - .init(); - - let update_baseline = std::env::var("UPDATE_BASELINE").is_ok(); - - // Create a temporary directory for test files - // Canonicalize the path to resolve symlinks (on macOS, /var -> /private/var) - // which prevents "path does not exist" errors during worktree scanning - // Use keep() to prevent auto-cleanup - background worktree tasks may still be running - // when tests complete, so we let the OS clean up temp directories on process exit - let temp_dir = tempfile::tempdir().expect("Failed to create temp directory"); - let temp_path = temp_dir.keep(); - let canonical_temp = temp_path - .canonicalize() - .expect("Failed to canonicalize temp directory"); - let project_path = canonical_temp.join("project"); - std::fs::create_dir_all(&project_path).expect("Failed to create project directory"); - - // Create test files in the real filesystem - create_test_files(&project_path); - - let test_result = std::panic::catch_unwind(|| run_visual_tests(project_path, update_baseline)); - - // Note: We don't delete temp_path here because background worktree tasks may still - // be running. The directory will be cleaned up when the process exits or by the OS. - - match test_result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - eprintln!("Visual tests failed: {}", e); - std::process::exit(1); - } - Err(_) => { - eprintln!("Visual tests panicked"); - std::process::exit(1); - } - } -} - #[cfg(target_os = "macos")] fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> { // Create the visual test context with deterministic task scheduling @@ -2528,16 +2526,6 @@ fn run_multi_workspace_sidebar_visual_tests( std::fs::create_dir_all(&workspace1_dir)?; std::fs::create_dir_all(&workspace2_dir)?; - // Create directories for recent projects (they must exist on disk for display) - let recent1_dir = canonical_temp.join("tiny-project"); - let recent2_dir = canonical_temp.join("font-kit"); - let recent3_dir = canonical_temp.join("ideas"); - let recent4_dir = canonical_temp.join("tmp"); - std::fs::create_dir_all(&recent1_dir)?; - std::fs::create_dir_all(&recent2_dir)?; - std::fs::create_dir_all(&recent3_dir)?; - std::fs::create_dir_all(&recent4_dir)?; - // Enable the agent-v2 feature flag so multi-workspace is active cx.update(|cx| { cx.update_flags(true, vec!["agent-v2".to_string()]); @@ -2677,83 +2665,78 @@ fn run_multi_workspace_sidebar_visual_tests( cx.run_until_parked(); - // Inject recent project entries into the sidebar. - // We update the sidebar entity directly (not through the MultiWorkspace window update) - // to avoid a re-entrant read panic: rebuild_entries reads MultiWorkspace, so we can't - // be inside a MultiWorkspace update when that happens. - cx.update(|cx| { - sidebar.update(cx, |sidebar, cx| { - let now = Utc::now(); - let today_timestamp = now; - let yesterday_timestamp = now - ChronoDuration::days(1); - let past_week_timestamp = now - ChronoDuration::days(10); - let all_timestamp = now - ChronoDuration::days(60); - - let recent_projects = vec![ - RecentProjectEntry { - name: "tiny-project".into(), - full_path: recent1_dir.to_string_lossy().to_string().into(), - paths: vec![recent1_dir.clone()], - workspace_id: WorkspaceId::default(), - timestamp: today_timestamp, - }, - RecentProjectEntry { - name: "font-kit".into(), - full_path: recent2_dir.to_string_lossy().to_string().into(), - paths: vec![recent2_dir.clone()], - workspace_id: WorkspaceId::default(), - timestamp: yesterday_timestamp, - }, - RecentProjectEntry { - name: "ideas".into(), - full_path: recent3_dir.to_string_lossy().to_string().into(), - paths: vec![recent3_dir.clone()], - workspace_id: WorkspaceId::default(), - timestamp: past_week_timestamp, - }, - RecentProjectEntry { - name: "tmp".into(), - full_path: recent4_dir.to_string_lossy().to_string().into(), - paths: vec![recent4_dir.clone()], - workspace_id: WorkspaceId::default(), - timestamp: all_timestamp, - }, - ]; - sidebar.set_test_recent_projects(recent_projects, cx); - }); - }); - - // Set thread info directly on the sidebar for visual testing - cx.update(|cx| { - sidebar.update(cx, |sidebar, _cx| { - sidebar.set_test_thread_info( - 0, - "Refine thread view scrolling behavior".into(), - ui::AgentThreadStatus::Completed, - ); - sidebar.set_test_thread_info( - 1, - "Add line numbers option to FileEditBlock".into(), - ui::AgentThreadStatus::Running, - ); - }); - }); + // Save test threads to the ThreadStore for each workspace + let save_tasks = multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + let thread_store = agent::ThreadStore::global(cx); + let workspaces = multi_workspace.workspaces().to_vec(); + let mut tasks = Vec::new(); + + for (index, workspace) in workspaces.iter().enumerate() { + let workspace_ref = workspace.read(cx); + let mut paths = Vec::new(); + for worktree in workspace_ref.worktrees(cx) { + let worktree_ref = worktree.read(cx); + if worktree_ref.is_visible() { + paths.push(worktree_ref.abs_path().to_path_buf()); + } + } + let path_list = util::path_list::PathList::new(&paths); + + let (session_id, title, updated_at) = match index { + 0 => ( + "visual-test-thread-0", + "Refine thread view scrolling behavior", + chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 10, 30, 0) + .unwrap(), + ), + 1 => ( + "visual-test-thread-1", + "Add line numbers option to FileEditBlock", + chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 11, 0, 0) + .unwrap(), + ), + _ => continue, + }; + + let task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from(session_id)), + 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, + ui_scroll_position: None, + draft_prompt: None, + }, + path_list, + cx, + ) + }); + tasks.push(task); + } + tasks + }) + .context("Failed to create test threads")?; - // Set last-worked-on thread titles on some recent projects for visual testing - cx.update(|cx| { - sidebar.update(cx, |sidebar, cx| { - sidebar.set_test_recent_project_thread_title( - recent1_dir.to_string_lossy().to_string().into(), - "Fix flaky test in CI pipeline".into(), - cx, - ); - sidebar.set_test_recent_project_thread_title( - recent2_dir.to_string_lossy().to_string().into(), - "Upgrade font rendering engine".into(), - cx, - ); - }); - }); + cx.background_executor.allow_parking(); + for task in save_tasks { + cx.foreground_executor + .block_test(task) + .context("Failed to save test thread")?; + } + cx.background_executor.forbid_parking(); cx.run_until_parked(); @@ -2909,12 +2892,12 @@ impl gpui::Render for ThreadItemIconDecorationsTestView { container() .child(ThreadItem::new("ti-none", "Default idle thread").timestamp("1:00 AM")), ) - .child(section_label("Blue dot (generation done)")) + .child(section_label("Blue dot (notified)")) .child( container().child( ThreadItem::new("ti-done", "Generation completed successfully") .timestamp("1:05 AM") - .generation_done(true), + .notified(true), ), ) .child(section_label("Yellow triangle (waiting for confirmation)")) @@ -2939,18 +2922,17 @@ impl gpui::Render for ThreadItemIconDecorationsTestView { ThreadItem::new("ti-running", "Generating response...") .icon(IconName::AiClaude) .timestamp("1:20 AM") - .running(true), + .status(ui::AgentThreadStatus::Running), ), ) .child(section_label( - "Spinner + yellow triangle (running + waiting)", + "Spinner + yellow triangle (waiting for confirmation)", )) .child( container().child( ThreadItem::new("ti-running-waiting", "Running but needs confirmation") .icon(IconName::AiClaude) .timestamp("1:25 AM") - .running(true) .status(ui::AgentThreadStatus::WaitingForConfirmation), ), ) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index aeb740c5ec05f5382e3b93527bb2191cb44f9d51..c1e00b817abc8817cc81dc528c66901011f134aa 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -785,7 +785,7 @@ fn register_actions( } } }) - .register_action(|workspace, _: &workspace::Open, window, cx| { + .register_action(|workspace, action: &workspace::Open, window, cx| { telemetry::event!("Project Opened"); workspace::prompt_for_open_path_and_open( workspace, @@ -796,6 +796,7 @@ fn register_actions( multiple: true, prompt: None, }, + action.create_new_window, window, cx, ); @@ -811,6 +812,7 @@ fn register_actions( multiple: true, prompt: None, }, + true, window, cx, ); @@ -4783,6 +4785,7 @@ mod tests { "action", "activity_indicator", "agent", + "agents_sidebar", "app_menu", "assistant", "assistant2", diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index debcb605f222dc7c983b9d061803720df5ff727c..f73d703557f8f73ad380c0b7a2cb995b29f92cf1 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -125,7 +125,7 @@ pub fn app_menus(cx: &mut App) -> Vec { } else { "Open…" }, - workspace::Open, + workspace::Open::default(), ), MenuItem::action( "Open Recent...",