Detailed changes
@@ -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]]
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6 8H8M8 8H10M8 8V6M8 8V10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 13.7253C11.1619 13.7253 13.7253 11.162 13.7253 8.00001C13.7253 4.83803 11.1619 2.27475 8 2.27475C4.83802 2.27475 2.27474 4.83803 2.27474 8.00001C2.27474 9.04281 2.55354 10.0205 3.04068 10.8626L2.561 13.439L5.13737 12.9593C5.97948 13.4465 6.9572 13.7253 8 13.7253Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.98001 12.8001H3.20001C2.88175 12.8001 2.57652 12.6736 2.35148 12.4486C2.12644 12.2235 2.00001 11.9183 2.00001 11.6001V3.80006C2.00001 3.4818 2.12644 3.17658 2.35148 2.95154C2.57652 2.72649 2.88175 2.60006 3.20001 2.60006H5.58801C5.7887 2.5981 5.98668 2.6465 6.16383 2.74084C6.34097 2.83517 6.49163 2.97244 6.60201 3.14006L6.99801 3.86006C7.10727 4.02598 7.25602 4.16218 7.43091 4.25643C7.60579 4.35067 7.80134 4.40003 8.00001 4.40006H12.8C13.1183 4.40006 13.4235 4.52649 13.6485 4.75153C13.8736 4.97658 14 5.2818 14 5.60006V7.58006" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.8 11.6H11.6M11.6 11.6H13.4M11.6 11.6V9.79999M11.6 11.6V13.4" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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",
},
},
{
@@ -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",
},
},
{
@@ -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",
},
},
{
@@ -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<gpui::Result<Entity<AcpThread>>> {
- 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(
@@ -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<ThreadStore> {
&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)))
}
@@ -32,11 +32,24 @@ pub struct DbThreadMetadata {
#[serde(alias = "summary")]
pub title: SharedString,
pub updated_at: DateTime<Utc>,
+ pub created_at: Option<DateTime<Utc>>,
/// 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<str>, Option<Arc<str>>, Option<String>, Option<String>, String, String, DataType, Vec<u8>)>(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<str>, Option<Arc<str>>, Option<String>, Option<String>, String, String, DataType, Vec<u8>, 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<str>, Option<Arc<str>>, Option<String>, Option<String>, 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<str>, Option<Arc<str>>, Option<String>, Option<String>, String, String, Option<String>)>(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]
@@ -22,6 +22,10 @@ impl ThreadStore {
cx.global::<GlobalThreadStore>().0.clone()
}
+ pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+ cx.try_global::<GlobalThreadStore>().map(|g| g.0.clone())
+ }
+
pub fn new(cx: &mut Context<Self>) -> Self {
let this = Self {
threads: Vec::new(),
@@ -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<ActiveView>,
- _active_view_observation: Option<Subscription>,
+ background_threads: HashMap<acp::SessionId, Entity<ConnectionView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
start_thread_in_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -573,6 +574,7 @@ pub struct AgentPanel {
show_trust_workspace_message: bool,
last_configuration_error_telemetry: Option<String>,
on_boarding_upsell_dismissed: AtomicBool,
+ _active_view_observation: Option<Subscription>,
}
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<Self>) {
+ pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
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<Entity<ThreadView>> {
+ 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<Self>) {
+ 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<Entity<agent::Thread>> {
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<Self>,
) {
+ 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<PanelEvent> 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<assistant_text_thread::TextThreadStore>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<AgentPanel>, 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);
@@ -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;
@@ -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) {
@@ -206,6 +206,7 @@ pub struct ThreadView {
pub(crate) conversation: Entity<super::Conversation>,
pub server_view: WeakEntity<ConnectionView>,
pub agent_icon: IconName,
+ pub agent_icon_from_external_svg: Option<SharedString>,
pub agent_name: SharedString,
pub focus_handle: FocusHandle,
pub workspace: WeakEntity<Workspace>,
@@ -293,6 +294,7 @@ impl ThreadView {
conversation: Entity<super::Conversation>,
server_view: WeakEntity<ConnectionView>,
agent_icon: IconName,
+ agent_icon_from_external_svg: Option<SharedString>,
agent_name: SharedString,
agent_display_name: SharedString,
workspace: WeakEntity<Workspace>,
@@ -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::<Vec<_>>()
+ .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| {
@@ -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>) {
self.editor.update(cx, |editor, cx| {
editor.set_text(text, window, cx);
@@ -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<C> {
+ connection: C,
+}
+
+impl<C> StubAgentServer<C> {
+ pub fn new(connection: C) -> Self {
+ Self { connection }
+ }
+}
+
+impl StubAgentServer<StubAgentConnection> {
+ 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<C> AgentServer for StubAgentServer<C>
+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<gpui::Result<Rc<dyn AgentConnection>>> {
+ Task::ready(Ok(Rc::new(self.connection.clone())))
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ 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<AgentPanel>,
+ 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<AgentPanel>, 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<AgentPanel>, cx: &VisualTestContext) -> acp::SessionId {
+ panel.read_with(cx, |panel, cx| {
+ let thread = panel.active_agent_thread(cx).unwrap();
+ thread.read(cx).session_id().clone()
+ })
+}
@@ -176,7 +176,9 @@ pub enum IconName {
Mic,
MicMute,
Minimize,
+ NewThread,
Notepad,
+ OpenFolder,
Option,
PageDown,
PageUp,
@@ -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();
})),
@@ -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",
@@ -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"] }
@@ -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<SharedString>,
+ 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<AgentThreadInfo>,
-}
-
-impl WorkspaceThreadEntry {
- fn new(index: usize, workspace: &Entity<Workspace>, 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<String> = 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::<Vec<_>>()
- .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<Workspace>, cx: &App) -> Option<AgentThreadInfo> {
- let agent_panel = workspace.read(cx).panel::<AgentPanel>(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<usize>,
+#[derive(Clone, Debug)]
+#[allow(dead_code)]
+enum ListEntry {
+ ProjectHeader {
+ path_list: PathList,
+ label: SharedString,
+ highlight_positions: Vec<usize>,
+ },
+ Thread {
+ session_info: acp_thread::AgentSessionInfo,
+ icon: IconName,
+ icon_from_external_svg: Option<SharedString>,
+ status: AgentThreadStatus,
+ diff_stats: Option<(usize, usize)>,
+ workspace_index: usize,
+ is_live: bool,
+ is_background: bool,
+ highlight_positions: Vec<usize>,
+ },
+ ViewMore {
+ path_list: PathList,
+ remaining_count: usize,
+ },
+ NewThread {
+ path_list: PathList,
+ },
}
-struct WorkspacePickerDelegate {
- multi_workspace: Entity<MultiWorkspace>,
- entries: Vec<SidebarEntry>,
- 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<RecentProjectEntry>,
- recent_project_thread_titles: HashMap<SharedString, SharedString>,
- matches: Vec<SidebarMatch>,
- selected_index: usize,
- query: String,
- hovered_thread_item: Option<usize>,
- notified_workspaces: HashSet<usize>,
+#[derive(Default)]
+struct SidebarContents {
+ entries: Vec<ListEntry>,
+ notified_threads: HashSet<acp::SessionId>,
}
-impl WorkspacePickerDelegate {
- fn new(multi_workspace: Entity<MultiWorkspace>) -> 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<WorkspaceThreadEntry>,
- 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<usize, AgentThreadStatus> = 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<Vec<usize>> {
+ 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<RecentProjectEntry>, cx: &App) {
- self.recent_project_thread_titles.clear();
-
- self.recent_projects = recent_projects;
-
- let workspace_threads: Vec<WorkspaceThreadEntry> = 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<Vec<Arc<Path>>> {
- 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<WorkspaceThreadEntry>, 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<TimeBucket> = 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<Workspace>,
+ 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<PathBuf>, window: &mut Window, cx: &mut App) {
- let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() 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<Picker<Self>>,
- ) {
- self.selected_index = ix;
- }
-
- fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
- match self.matches.get(ix) {
- Some(SidebarMatch {
- entry: SidebarEntry::Separator(_),
- ..
- }) => false,
- _ => true,
- }
- }
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Searchβ¦".into()
- }
-
- fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
- 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<Picker<Self>>,
- ) -> 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<StringMatchCandidate> = 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<Workspace>],
+ path_list: &PathList,
+ cx: &App,
+) -> Option<usize> {
+ 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<Picker<Self>>) {
- 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<Picker<Self>>) {}
-
- fn render_match(
- &self,
- index: usize,
- selected: bool,
- _window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- 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<dyn ErasedEditor>,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> 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<MultiWorkspace>,
+ multi_workspace: WeakEntity<MultiWorkspace>,
width: Pixels,
- picker: Entity<Picker<WorkspacePickerDelegate>>,
- _subscription: Subscription,
+ focus_handle: FocusHandle,
+ filter_editor: Entity<Editor>,
+ list_state: ListState,
+ contents: SidebarContents,
+ selection: Option<usize>,
+ collapsed_groups: HashSet<PathList>,
+ expanded_groups: HashSet<PathList>,
+ _subscriptions: Vec<Subscription>,
_project_subscriptions: Vec<Subscription>,
_agent_panel_subscriptions: Vec<Subscription>,
- _thread_subscriptions: Vec<Subscription>,
- #[cfg(any(test, feature = "test-support"))]
- test_thread_infos: HashMap<usize, AgentThreadInfo>,
- #[cfg(any(test, feature = "test-support"))]
- test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
- _fetch_recent_projects: Task<()>,
+ _thread_store_subscription: Option<Subscription>,
}
impl EventEmitter<SidebarEvent> for Sidebar {}
@@ -711,15 +194,17 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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 = <dyn 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<Self>,
) -> Vec<Subscription> {
- 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<WorkspaceThreadEntry>, usize) {
- #[allow(unused_mut)]
- let mut entries: Vec<WorkspaceThreadEntry> = 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<RecentProjectEntry>,
- cx: &mut Context<Self>,
- ) {
- 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>,
- ) {
- 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<Self>,
) -> Vec<Subscription> {
- 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()
@@ -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<SharedString>,
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<SharedString>) -> 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(),
),
@@ -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]>,
@@ -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,
@@ -209,6 +209,34 @@ pub trait DebuggerProvider {
fn active_thread_state(&self, cx: &App) -> Option<ThreadStatus>;
}
+/// 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<AppState>, 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<AppState>, 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<AppState>,
options: PathPromptOptions,
+ create_new_window: bool,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
@@ -660,10 +687,24 @@ pub fn prompt_for_open_path_and_open(
window,
cx,
);
+ let multi_workspace_handle = window.window_handle().downcast::<MultiWorkspace>();
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)
@@ -48,7 +48,6 @@ visual-tests = [
"language_model/test-support",
"fs/test-support",
"recent_projects/test-support",
- "sidebar/test-support",
"title_bar/test-support",
]
@@ -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),
),
)
@@ -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",
@@ -125,7 +125,7 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
} else {
"Openβ¦"
},
- workspace::Open,
+ workspace::Open::default(),
),
MenuItem::action(
"Open Recent...",