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