diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 379ae675d4bbf3c2a9570365493317178f38a804..e62ff78871c65311627aab8f6a6e3c00481a0c2b 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -113,6 +113,10 @@ impl ThreadStore { pub fn entries(&self) -> impl Iterator + '_ { self.threads.iter().cloned() } + + pub fn entry_ids(&self) -> impl Iterator + '_ { + self.threads.iter().map(|t| t.id.clone()) + } } #[cfg(test)] diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index 55b8c1493cc990310dc13253ce33cbc4b71a748f..f19a2aa2626d4cbf1fc1ddd0878c5c029d403818 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -83,6 +83,10 @@ impl AgentConnectionStore { } } + pub fn project(&self) -> &Entity { + &self.project + } + pub fn entry(&self, key: &Agent) -> Option<&Entity> { self.entries.get(key) } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 2395a74c281f5b84ab1f328b175fe8385ce3fb12..531f0cfa6259e651d473e08135a68f7f44cac48d 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -33,6 +33,7 @@ mod text_thread_editor; mod text_thread_history; mod thread_history; mod thread_history_view; +mod thread_import; pub mod thread_metadata_store; pub mod threads_archive_view; mod ui; @@ -252,6 +253,16 @@ pub enum Agent { }, } +impl From for Agent { + fn from(id: AgentId) -> Self { + if id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + Self::NativeAgent + } else { + Self::Custom { id } + } + } +} + impl Agent { pub fn id(&self) -> AgentId { match self { diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 14ab1c16322594f42e9035bc070a39f4151a273e..316650a32b2f0179d9e63ea26bb5ad22cccd8021 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -78,7 +78,7 @@ use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::thread_metadata_store::SidebarThreadMetadataStore; +use crate::thread_metadata_store::ThreadMetadataStore; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, @@ -2571,7 +2571,7 @@ impl ConversationView { let task = history.update(cx, |history, cx| history.delete_session(&session_id, cx)); task.detach_and_log_err(cx); - if let Some(store) = SidebarThreadMetadataStore::try_global(cx) { + if let Some(store) = ThreadMetadataStore::try_global(cx) { store.update(cx, |store, cx| store.delete(session_id.clone(), cx)); } } @@ -4258,7 +4258,7 @@ pub(crate) mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); agent_panel::init(cx); diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs new file mode 100644 index 0000000000000000000000000000000000000000..c01403d795d9f377fa1dce0a5171f37d214b9f33 --- /dev/null +++ b/crates/agent_ui/src/thread_import.rs @@ -0,0 +1,660 @@ +use acp_thread::AgentSessionListRequest; +use agent::ThreadStore; +use agent_client_protocol as acp; +use chrono::Utc; +use collections::HashSet; +use fs::Fs; +use futures::FutureExt as _; +use gpui::{ + App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, + Render, SharedString, Task, WeakEntity, Window, +}; +use notifications::status_toast::{StatusToast, ToastIcon}; +use project::{AgentId, AgentRegistryStore, AgentServerStore}; +use ui::{Checkbox, CommonAnimationExt as _, KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use util::ResultExt; +use workspace::{ModalView, MultiWorkspace, Workspace}; + +use crate::{ + Agent, AgentPanel, + agent_connection_store::AgentConnectionStore, + thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}, +}; + +#[derive(Clone)] +struct AgentEntry { + agent_id: AgentId, + display_name: SharedString, + icon_path: Option, +} + +pub struct ThreadImportModal { + focus_handle: FocusHandle, + workspace: WeakEntity, + multi_workspace: WeakEntity, + agent_entries: Vec, + unchecked_agents: HashSet, + is_importing: bool, + last_error: Option, +} + +impl ThreadImportModal { + pub fn new( + agent_server_store: Entity, + agent_registry_store: Entity, + workspace: WeakEntity, + multi_workspace: WeakEntity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let agent_entries = agent_server_store + .read(cx) + .external_agents() + .map(|agent_id| { + let display_name = agent_server_store + .read(cx) + .agent_display_name(agent_id) + .or_else(|| { + agent_registry_store + .read(cx) + .agent(agent_id) + .map(|agent| agent.name().clone()) + }) + .unwrap_or_else(|| agent_id.0.clone()); + let icon_path = agent_server_store + .read(cx) + .agent_icon(agent_id) + .or_else(|| { + agent_registry_store + .read(cx) + .agent(agent_id) + .and_then(|agent| agent.icon_path().cloned()) + }); + + AgentEntry { + agent_id: agent_id.clone(), + display_name, + icon_path, + } + }) + .collect::>(); + + Self { + focus_handle: cx.focus_handle(), + workspace, + multi_workspace, + agent_entries, + unchecked_agents: HashSet::default(), + is_importing: false, + last_error: None, + } + } + + fn agent_ids(&self) -> Vec { + self.agent_entries + .iter() + .map(|entry| entry.agent_id.clone()) + .collect() + } + + fn set_agent_checked(&mut self, agent_id: AgentId, state: ToggleState, cx: &mut Context) { + match state { + ToggleState::Selected => { + self.unchecked_agents.remove(&agent_id); + } + ToggleState::Unselected | ToggleState::Indeterminate => { + self.unchecked_agents.insert(agent_id); + } + } + cx.notify(); + } + + fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context) { + if self.unchecked_agents.contains(&agent_id) { + self.unchecked_agents.remove(&agent_id); + } else { + self.unchecked_agents.insert(agent_id); + } + cx.notify(); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn import_threads(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { + if self.is_importing { + return; + } + + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + self.is_importing = false; + cx.notify(); + return; + }; + + let stores = resolve_agent_connection_stores(&multi_workspace, cx); + if stores.is_empty() { + log::error!("Did not find any workspaces to import from"); + self.is_importing = false; + cx.notify(); + return; + } + + self.is_importing = true; + self.last_error = None; + cx.notify(); + + let agent_ids = self + .agent_ids() + .into_iter() + .filter(|agent_id| !self.unchecked_agents.contains(agent_id)) + .collect::>(); + + let existing_sessions = ThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>(); + + let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx); + cx.spawn(async move |this, cx| { + let result = task.await; + this.update(cx, |this, cx| match result { + Ok(threads) => { + let imported_count = threads.len(); + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.save_all(threads, cx)); + this.is_importing = false; + this.last_error = None; + this.show_imported_threads_toast(imported_count, cx); + cx.emit(DismissEvent); + } + Err(error) => { + this.is_importing = false; + this.last_error = Some(error.to_string().into()); + cx.notify(); + } + }) + }) + .detach_and_log_err(cx); + } + + fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) { + let status_toast = if imported_count == 0 { + StatusToast::new("No threads found to import.", cx, |this, _cx| { + this.icon(ToastIcon::new(IconName::Info).color(Color::Info)) + }) + } else { + let message = if imported_count == 1 { + "Imported 1 thread.".to_string() + } else { + format!("Imported {imported_count} threads.") + }; + StatusToast::new(message, cx, |this, _cx| { + this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + }) + }; + + self.workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(status_toast, cx); + }) + .log_err(); + } +} + +impl EventEmitter for ThreadImportModal {} + +impl Focusable for ThreadImportModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for ThreadImportModal {} + +impl Render for ThreadImportModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let agent_rows = self + .agent_entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let is_checked = !self.unchecked_agents.contains(&entry.agent_id); + + ListItem::new(("thread-import-agent", ix)) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .start_slot( + Checkbox::new( + ("thread-import-agent-checkbox", ix), + if is_checked { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .on_click({ + let agent_id = entry.agent_id.clone(); + cx.listener(move |this, state: &ToggleState, _window, cx| { + this.set_agent_checked(agent_id.clone(), *state, cx); + }) + }), + ) + .on_click({ + let agent_id = entry.agent_id.clone(); + cx.listener(move |this, _event, _window, cx| { + this.toggle_agent_checked(agent_id.clone(), cx); + }) + }) + .child( + h_flex() + .w_full() + .gap_2() + .child(if let Some(icon_path) = entry.icon_path.clone() { + Icon::from_external_svg(icon_path) + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(IconName::Sparkle) + .color(Color::Muted) + .size(IconSize::Small) + }) + .child(Label::new(entry.display_name.clone())), + ) + }) + .collect::>(); + + let has_agents = !self.agent_entries.is_empty(); + + v_flex() + .id("thread-import-modal") + .key_context("ThreadImportModal") + .w(rems(34.)) + .elevation_3(cx) + .overflow_hidden() + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::import_threads)) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); + })) + // Header + .child( + v_flex() + .p_4() + .pb_2() + .gap_1() + .child(Headline::new("Import Threads").size(HeadlineSize::Small)) + .child( + Label::new( + "Select the agents whose threads you'd like to import. \ + Imported threads will appear in your thread archive.", + ) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + // Agent list + .child( + v_flex() + .id("thread-import-agent-list") + .px_2() + .max_h(rems(20.)) + .overflow_y_scroll() + .when(has_agents, |this| this.children(agent_rows)) + .when(!has_agents, |this| { + this.child( + div().p_4().child( + Label::new("No ACP agents available.") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + }), + ) + // Footer + .child( + h_flex() + .w_full() + .p_3() + .gap_2() + .items_center() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(div().flex_1().min_w_0().when_some( + self.last_error.clone(), + |this, error| { + this.child( + Label::new(error) + .size(LabelSize::Small) + .color(Color::Error) + .truncate(), + ) + }, + )) + .child( + h_flex() + .gap_2() + .items_center() + .when(self.is_importing, |this| { + this.child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + }) + .child( + Button::new("import-threads", "Import Threads") + .disabled(self.is_importing || !has_agents) + .key_binding( + KeyBinding::for_action(&menu::Confirm, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.import_threads(&menu::Confirm, window, cx); + })), + ), + ), + ) + } +} + +fn resolve_agent_connection_stores( + multi_workspace: &Entity, + cx: &App, +) -> Vec> { + let mut stores = Vec::new(); + let mut included_local_store = false; + + for workspace in multi_workspace.read(cx).workspaces() { + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + + // We only want to include scores from one local workspace, since we + // know that they live on the same machine + let include_store = if project.is_remote() { + true + } else if project.is_local() && !included_local_store { + included_local_store = true; + true + } else { + false + }; + + if !include_store { + continue; + } + + if let Some(panel) = workspace.panel::(cx) { + stores.push(panel.read(cx).connection_store().clone()); + } + } + + stores +} + +fn find_threads_to_import( + agent_ids: Vec, + existing_sessions: HashSet, + stores: Vec>, + cx: &mut App, +) -> Task>> { + let mut wait_for_connection_tasks = Vec::new(); + + for store in stores { + for agent_id in agent_ids.clone() { + let agent = Agent::from(agent_id.clone()); + let server = agent.server(::global(cx), ThreadStore::global(cx)); + let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx)); + wait_for_connection_tasks + .push(entry.read(cx).wait_for_connection().map(|s| (agent_id, s))); + } + } + + let mut session_list_tasks = Vec::new(); + cx.spawn(async move |cx| { + let results = futures::future::join_all(wait_for_connection_tasks).await; + for (agent, result) in results { + let Some(state) = result.log_err() else { + continue; + }; + let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else { + continue; + }; + let task = cx.update(|cx| { + list.list_sessions(AgentSessionListRequest::default(), cx) + .map(|r| (agent, r)) + }); + session_list_tasks.push(task); + } + + let mut sessions_by_agent = Vec::new(); + let results = futures::future::join_all(session_list_tasks).await; + for (agent_id, result) in results { + let Some(response) = result.log_err() else { + continue; + }; + sessions_by_agent.push((agent_id, response.sessions)); + } + + Ok(collect_importable_threads( + sessions_by_agent, + existing_sessions, + )) + }) +} + +fn collect_importable_threads( + sessions_by_agent: Vec<(AgentId, Vec)>, + mut existing_sessions: HashSet, +) -> Vec { + let mut to_insert = Vec::new(); + for (agent_id, sessions) in sessions_by_agent { + for session in sessions { + if !existing_sessions.insert(session.session_id.clone()) { + continue; + } + let Some(folder_paths) = session.work_dirs else { + continue; + }; + to_insert.push(ThreadMetadata { + session_id: session.session_id, + agent_id: agent_id.clone(), + title: session + .title + .unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into()), + updated_at: session.updated_at.unwrap_or_else(|| Utc::now()), + created_at: session.created_at, + folder_paths, + archived: true, + }); + } + } + to_insert +} + +#[cfg(test)] +mod tests { + use super::*; + use acp_thread::AgentSessionInfo; + use chrono::Utc; + use std::path::Path; + use workspace::PathList; + + fn make_session( + session_id: &str, + title: Option<&str>, + work_dirs: Option, + updated_at: Option>, + created_at: Option>, + ) -> AgentSessionInfo { + AgentSessionInfo { + session_id: acp::SessionId::new(session_id), + title: title.map(|t| SharedString::from(t.to_string())), + work_dirs, + updated_at, + created_at, + meta: None, + } + } + + #[test] + fn test_collect_skips_sessions_already_in_existing_set() { + let existing = HashSet::from_iter(vec![acp::SessionId::new("existing-1")]); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session( + "existing-1", + Some("Already There"), + Some(paths.clone()), + None, + None, + ), + make_session("new-1", Some("Brand New"), Some(paths), None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].session_id.0.as_ref(), "new-1"); + assert_eq!(result[0].title.as_ref(), "Brand New"); + } + + #[test] + fn test_collect_skips_sessions_without_work_dirs() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session("has-dirs", Some("With Dirs"), Some(paths), None, None), + make_session("no-dirs", Some("No Dirs"), None, None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].session_id.0.as_ref(), "has-dirs"); + } + + #[test] + fn test_collect_marks_all_imported_threads_as_archived() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session("s1", Some("Thread 1"), Some(paths.clone()), None, None), + make_session("s2", Some("Thread 2"), Some(paths), None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 2); + assert!(result.iter().all(|t| t.archived)); + } + + #[test] + fn test_collect_assigns_correct_agent_id_per_session() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![ + ( + AgentId::new("agent-a"), + vec![make_session( + "s1", + Some("From A"), + Some(paths.clone()), + None, + None, + )], + ), + ( + AgentId::new("agent-b"), + vec![make_session("s2", Some("From B"), Some(paths), None, None)], + ), + ]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 2); + let s1 = result + .iter() + .find(|t| t.session_id.0.as_ref() == "s1") + .unwrap(); + let s2 = result + .iter() + .find(|t| t.session_id.0.as_ref() == "s2") + .unwrap(); + assert_eq!(s1.agent_id.as_ref(), "agent-a"); + assert_eq!(s2.agent_id.as_ref(), "agent-b"); + } + + #[test] + fn test_collect_deduplicates_across_agents() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![ + ( + AgentId::new("agent-a"), + vec![make_session( + "shared-session", + Some("From A"), + Some(paths.clone()), + None, + None, + )], + ), + ( + AgentId::new("agent-b"), + vec![make_session( + "shared-session", + Some("From B"), + Some(paths), + None, + None, + )], + ), + ]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].session_id.0.as_ref(), "shared-session"); + assert_eq!( + result[0].agent_id.as_ref(), + "agent-a", + "first agent encountered should win" + ); + } + + #[test] + fn test_collect_all_existing_returns_empty() { + let paths = PathList::new(&[Path::new("/project")]); + let existing = + HashSet::from_iter(vec![acp::SessionId::new("s1"), acp::SessionId::new("s2")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session("s1", Some("T1"), Some(paths.clone()), None, None), + make_session("s2", Some("T2"), Some(paths), None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + assert!(result.is_empty()); + } +} diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 9a99ca9fcd5766e041fa50206d6a536ac4a97854..1dadd3d9f875177d51a57106f0a2d054d0252868 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -1,11 +1,10 @@ use std::{path::Path, sync::Arc}; -use acp_thread::AgentSessionInfo; use agent::{ThreadStore, ZED_AGENT_ID}; use agent_client_protocol as acp; use anyhow::Context as _; use chrono::{DateTime, Utc}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use db::{ sqlez::{ bindable::Column, domain::Domain, statement::Statement, @@ -24,7 +23,7 @@ use workspace::PathList; use crate::DEFAULT_THREAD_TITLE; pub fn init(cx: &mut App) { - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); if cx.has_flag::() { migrate_thread_metadata(cx); @@ -38,68 +37,59 @@ pub fn init(cx: &mut App) { } /// Migrate existing thread metadata from native agent thread store to the new metadata storage. -/// We migrate the last 10 threads per project and skip threads that do not have a project. +/// We skip migrating threads that do not have a project. /// /// TODO: Remove this after N weeks of shipping the sidebar fn migrate_thread_metadata(cx: &mut App) { - const MAX_MIGRATED_THREADS_PER_PROJECT: usize = 10; - - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let db = store.read(cx).db.clone(); cx.spawn(async move |cx| { - if !db.is_empty()? { - return Ok::<(), anyhow::Error>(()); - } - - let metadata = store.read_with(cx, |_store, app| { - let mut migrated_threads_per_project = HashMap::default(); + let existing_entries = db.list_ids()?.into_iter().collect::>(); - ThreadStore::global(app) - .read(app) + let to_migrate = store.read_with(cx, |_store, cx| { + ThreadStore::global(cx) + .read(cx) .entries() .filter_map(|entry| { - if entry.folder_paths.is_empty() { - return None; - } - - let migrated_thread_count = migrated_threads_per_project - .entry(entry.folder_paths.clone()) - .or_insert(0); - if *migrated_thread_count >= MAX_MIGRATED_THREADS_PER_PROJECT { + if existing_entries.contains(&entry.id.0) || entry.folder_paths.is_empty() { return None; } - *migrated_thread_count += 1; Some(ThreadMetadata { session_id: entry.id, - agent_id: None, + agent_id: ZED_AGENT_ID.clone(), title: entry.title, updated_at: entry.updated_at, created_at: entry.created_at, folder_paths: entry.folder_paths, + archived: true, }) }) .collect::>() }); - log::info!("Migrating {} thread store entries", metadata.len()); + if to_migrate.is_empty() { + return anyhow::Ok(()); + } + + log::info!("Migrating {} thread store entries", to_migrate.len()); // Manually save each entry to the database and call reload, otherwise // we'll end up triggering lots of reloads after each save - for entry in metadata { + for entry in to_migrate { db.save(entry).await?; } log::info!("Finished migrating thread store entries"); let _ = store.update(cx, |store, cx| store.reload(cx)); - Ok(()) + anyhow::Ok(()) }) .detach_and_log_err(cx); } -struct GlobalThreadMetadataStore(Entity); +struct GlobalThreadMetadataStore(Entity); impl Global for GlobalThreadMetadataStore {} /// Lightweight metadata for any thread (native or ACP), enough to populate @@ -107,37 +97,20 @@ impl Global for GlobalThreadMetadataStore {} #[derive(Debug, Clone, PartialEq)] pub struct ThreadMetadata { pub session_id: acp::SessionId, - /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. - pub agent_id: Option, + pub agent_id: AgentId, pub title: SharedString, pub updated_at: DateTime, pub created_at: Option>, pub folder_paths: PathList, + pub archived: bool, } impl ThreadMetadata { - pub fn from_session_info(agent_id: AgentId, session: &AgentSessionInfo) -> Self { - let session_id = session.session_id.clone(); - let title = session.title.clone().unwrap_or_default(); - let updated_at = session.updated_at.unwrap_or_else(|| Utc::now()); - let created_at = session.created_at.unwrap_or(updated_at); - let folder_paths = session.work_dirs.clone().unwrap_or_default(); - let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { - None - } else { - Some(agent_id) - }; - Self { - session_id, - agent_id, - title, - updated_at, - created_at: Some(created_at), - folder_paths, - } - } - - pub fn from_thread(thread: &Entity, cx: &App) -> Self { + pub fn from_thread( + is_archived: bool, + thread: &Entity, + cx: &App, + ) -> Self { let thread_ref = thread.read(cx); let session_id = thread_ref.session_id().clone(); let title = thread_ref @@ -147,12 +120,6 @@ impl ThreadMetadata { let agent_id = thread_ref.connection().agent_id(); - let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { - None - } else { - Some(agent_id) - }; - let folder_paths = { let project = thread_ref.project().read(cx); let paths: Vec> = project @@ -169,17 +136,17 @@ impl ThreadMetadata { created_at: Some(updated_at), // handled by db `ON CONFLICT` updated_at, folder_paths, + archived: is_archived, } } } -/// The store holds all metadata needed to show threads in the sidebar. -/// Effectively, all threads stored in here are "non-archived". +/// The store holds all metadata needed to show threads in the sidebar/the archive. /// /// Automatically listens to AcpThread events and updates metadata if it has changed. -pub struct SidebarThreadMetadataStore { +pub struct ThreadMetadataStore { db: ThreadMetadataDb, - threads: Vec, + threads: HashMap, threads_by_paths: HashMap>, reload_task: Option>>, session_subscriptions: HashMap, @@ -202,7 +169,7 @@ impl DbOperation { } } -impl SidebarThreadMetadataStore { +impl ThreadMetadataStore { #[cfg(not(any(test, feature = "test-support")))] pub fn init_global(cx: &mut App) { if cx.has_global::() { @@ -237,14 +204,22 @@ impl SidebarThreadMetadataStore { self.threads.is_empty() } + /// Returns all thread IDs. + pub fn entry_ids(&self) -> impl Iterator + '_ { + self.threads.keys().cloned() + } + + /// Returns all threads. pub fn entries(&self) -> impl Iterator + '_ { - self.threads.iter().cloned() + self.threads.values().cloned() } - pub fn entry_ids(&self) -> impl Iterator + '_ { - self.threads.iter().map(|thread| thread.session_id.clone()) + /// Returns all archived threads. + pub fn archived_entries(&self) -> impl Iterator + '_ { + self.entries().filter(|t| t.archived) } + /// Returns all threads for the given path list, excluding archived threads. pub fn entries_for_path( &self, path_list: &PathList, @@ -253,6 +228,7 @@ impl SidebarThreadMetadataStore { .get(path_list) .into_iter() .flatten() + .filter(|s| !s.archived) .cloned() } @@ -278,7 +254,7 @@ impl SidebarThreadMetadataStore { .entry(row.folder_paths.clone()) .or_default() .push(row.clone()); - this.threads.push(row); + this.threads.insert(row.session_id.clone(), row); } cx.notify(); @@ -290,6 +266,18 @@ impl SidebarThreadMetadataStore { reload_task } + pub fn save_all(&mut self, metadata: Vec, cx: &mut Context) { + if !cx.has_flag::() { + return; + } + + for metadata in metadata { + self.pending_thread_ops_tx + .try_send(DbOperation::Insert(metadata)) + .log_err(); + } + } + pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context) { if !cx.has_flag::() { return; @@ -300,6 +288,36 @@ impl SidebarThreadMetadataStore { .log_err(); } + pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + self.update_archived(session_id, true, cx); + } + + pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + self.update_archived(session_id, false, cx); + } + + fn update_archived( + &mut self, + session_id: &acp::SessionId, + archived: bool, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + + if let Some(thread) = self.threads.get(session_id) { + self.save( + ThreadMetadata { + archived, + ..thread.clone() + }, + cx, + ); + cx.notify(); + } + } + pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context) { if !cx.has_flag::() { return; @@ -377,7 +395,7 @@ impl SidebarThreadMetadataStore { let mut this = Self { db, - threads: Vec::new(), + threads: HashMap::default(), threads_by_paths: HashMap::default(), reload_task: None, session_subscriptions: HashMap::default(), @@ -422,7 +440,12 @@ impl SidebarThreadMetadataStore { | acp_thread::AcpThreadEvent::Error | acp_thread::AcpThreadEvent::LoadError(_) | acp_thread::AcpThreadEvent::Refusal => { - let metadata = ThreadMetadata::from_thread(&thread, cx); + let is_archived = self + .threads + .get(thread.read(cx).session_id()) + .map(|t| t.archived) + .unwrap_or(false); + let metadata = ThreadMetadata::from_thread(is_archived, &thread, cx); self.save(metadata, cx); } _ => {} @@ -430,38 +453,43 @@ impl SidebarThreadMetadataStore { } } -impl Global for SidebarThreadMetadataStore {} +impl Global for ThreadMetadataStore {} struct ThreadMetadataDb(ThreadSafeConnection); impl Domain for ThreadMetadataDb { const NAME: &str = stringify!(ThreadMetadataDb); - const MIGRATIONS: &[&str] = &[sql!( - CREATE TABLE IF NOT EXISTS sidebar_threads( - session_id TEXT PRIMARY KEY, - agent_id TEXT, - title TEXT NOT NULL, - updated_at TEXT NOT NULL, - created_at TEXT, - folder_paths TEXT, - folder_paths_order TEXT - ) STRICT; - )]; + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_id TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + ), + sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0), + ]; } db::static_connection!(ThreadMetadataDb, []); impl ThreadMetadataDb { - pub fn is_empty(&self) -> anyhow::Result { - self.select::("SELECT COUNT(*) FROM sidebar_threads")?() - .map(|counts| counts.into_iter().next().unwrap_or_default() == 0) + pub fn list_ids(&self) -> anyhow::Result>> { + self.select::>( + "SELECT session_id FROM sidebar_threads \ + ORDER BY updated_at DESC", + )?() } /// List all sidebar thread metadata, ordered by updated_at descending. pub fn list(&self) -> anyhow::Result> { self.select::( - "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \ + "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived \ FROM sidebar_threads \ ORDER BY updated_at DESC" )?() @@ -470,7 +498,11 @@ impl ThreadMetadataDb { /// Upsert metadata for a thread. pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> { let id = row.session_id.0.clone(); - let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string()); + let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + None + } else { + Some(row.agent_id.to_string()) + }; let title = row.title.to_string(); let updated_at = row.updated_at.to_rfc3339(); let created_at = row.created_at.map(|dt| dt.to_rfc3339()); @@ -480,16 +512,18 @@ impl ThreadMetadataDb { } else { (Some(serialized.paths), Some(serialized.order)) }; + let archived = row.archived; self.write(move |conn| { - let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ ON CONFLICT(session_id) DO UPDATE SET \ agent_id = excluded.agent_id, \ title = excluded.title, \ updated_at = excluded.updated_at, \ folder_paths = excluded.folder_paths, \ - folder_paths_order = excluded.folder_paths_order"; + folder_paths_order = excluded.folder_paths_order, \ + archived = excluded.archived"; let mut stmt = Statement::prepare(conn, sql)?; let mut i = stmt.bind(&id, 1)?; i = stmt.bind(&agent_id, i)?; @@ -497,7 +531,8 @@ impl ThreadMetadataDb { i = stmt.bind(&updated_at, i)?; i = stmt.bind(&created_at, i)?; i = stmt.bind(&folder_paths, i)?; - stmt.bind(&folder_paths_order, i)?; + i = stmt.bind(&folder_paths_order, i)?; + stmt.bind(&archived, i)?; stmt.exec() }) .await @@ -526,6 +561,11 @@ impl Column for ThreadMetadata { let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; let (folder_paths_order_str, next): (Option, i32) = Column::column(statement, next)?; + let (archived, next): (bool, i32) = Column::column(statement, next)?; + + let agent_id = agent_id + .map(|id| AgentId::new(id)) + .unwrap_or(ZED_AGENT_ID.clone()); let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); let created_at = created_at_str @@ -546,11 +586,12 @@ impl Column for ThreadMetadata { Ok(( ThreadMetadata { session_id: acp::SessionId::new(id), - agent_id: agent_id.map(|id| AgentId::new(id)), + agent_id, title: title.into(), updated_at, created_at, folder_paths, + archived, }, next, )) @@ -599,8 +640,9 @@ mod tests { folder_paths: PathList, ) -> ThreadMetadata { ThreadMetadata { + archived: false, session_id: acp::SessionId::new(session_id), - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title: title.to_string().into(), updated_at, created_at: Some(updated_at), @@ -643,20 +685,22 @@ mod tests { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let entry_ids = store .entry_ids() .map(|session_id| session_id.0.to_string()) .collect::>(); - assert_eq!(entry_ids, vec!["session-1", "session-2"]); + assert_eq!(entry_ids.len(), 2); + assert!(entry_ids.contains(&"session-1".to_string())); + assert!(entry_ids.contains(&"session-2".to_string())); let first_path_entries = store .entries_for_path(&first_paths) @@ -678,7 +722,7 @@ mod tests { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); let first_paths = PathList::new(&[Path::new("/project-a")]); @@ -701,7 +745,7 @@ mod tests { ); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { store.save(initial_metadata, cx); store.save(second_metadata, cx); @@ -711,7 +755,7 @@ mod tests { cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let first_path_entries = store @@ -735,7 +779,7 @@ mod tests { ); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { store.save(moved_metadata, cx); }); @@ -744,14 +788,16 @@ mod tests { cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let entry_ids = store .entry_ids() .map(|session_id| session_id.0.to_string()) .collect::>(); - assert_eq!(entry_ids, vec!["session-1", "session-2"]); + assert_eq!(entry_ids.len(), 2); + assert!(entry_ids.contains(&"session-1".to_string())); + assert!(entry_ids.contains(&"session-2".to_string())); let first_path_entries = store .entries_for_path(&first_paths) @@ -763,11 +809,13 @@ mod tests { .entries_for_path(&second_paths) .map(|entry| entry.session_id.0.to_string()) .collect::>(); - assert_eq!(second_path_entries, vec!["session-1", "session-2"]); + assert_eq!(second_path_entries.len(), 2); + assert!(second_path_entries.contains(&"session-1".to_string())); + assert!(second_path_entries.contains(&"session-2".to_string())); }); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { store.delete(acp::SessionId::new("session-2"), cx); }); @@ -776,7 +824,7 @@ mod tests { cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let entry_ids = store @@ -794,61 +842,72 @@ mod tests { } #[gpui::test] - async fn test_migrate_thread_metadata(cx: &mut TestAppContext) { + async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) { cx.update(|cx| { ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - }); - - // Verify the cache is empty before migration - let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); - store.read(cx).entries().collect::>() + ThreadMetadataStore::init_global(cx); }); - assert_eq!(list.len(), 0); let project_a_paths = PathList::new(&[Path::new("/project-a")]); let project_b_paths = PathList::new(&[Path::new("/project-b")]); let now = Utc::now(); - for index in 0..12 { - let updated_at = now + chrono::Duration::seconds(index as i64); - let session_id = format!("project-a-session-{index}"); - let title = format!("Project A Thread {index}"); + let existing_metadata = ThreadMetadata { + session_id: acp::SessionId::new("a-session-0"), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Existing Metadata".into(), + updated_at: now - chrono::Duration::seconds(10), + created_at: Some(now - chrono::Duration::seconds(10)), + folder_paths: project_a_paths.clone(), + archived: false, + }; - let save_task = cx.update(|cx| { - let thread_store = ThreadStore::global(cx); - let session_id = session_id.clone(); - let title = title.clone(); - let project_a_paths = project_a_paths.clone(); - thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(session_id), - make_db_thread(&title, updated_at), - project_a_paths, - cx, - ) - }) + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(existing_metadata, cx); }); - save_task.await.unwrap(); - cx.run_until_parked(); - } + }); + cx.run_until_parked(); - for index in 0..3 { - let updated_at = now + chrono::Duration::seconds(100 + index as i64); - let session_id = format!("project-b-session-{index}"); - let title = format!("Project B Thread {index}"); + let threads_to_save = vec![ + ( + "a-session-0", + "Thread A0 From Native Store", + project_a_paths.clone(), + now, + ), + ( + "a-session-1", + "Thread A1", + project_a_paths.clone(), + now + chrono::Duration::seconds(1), + ), + ( + "b-session-0", + "Thread B0", + project_b_paths.clone(), + now + chrono::Duration::seconds(2), + ), + ( + "projectless", + "Projectless", + PathList::default(), + now + chrono::Duration::seconds(3), + ), + ]; + for (session_id, title, paths, updated_at) in &threads_to_save { let save_task = cx.update(|cx| { let thread_store = ThreadStore::global(cx); - let session_id = session_id.clone(); - let title = title.clone(); - let project_b_paths = project_b_paths.clone(); + let session_id = session_id.to_string(); + let title = title.to_string(); + let paths = paths.clone(); thread_store.update(cx, |store, cx| { store.save_thread( acp::SessionId::new(session_id), - make_db_thread(&title, updated_at), - project_b_paths, + make_db_thread(&title, *updated_at), + paths, cx, ) }) @@ -857,130 +916,87 @@ mod tests { cx.run_until_parked(); } - let save_projectless = cx.update(|cx| { - let thread_store = ThreadStore::global(cx); - thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new("projectless-session"), - make_db_thread("Projectless Thread", now + chrono::Duration::seconds(200)), - PathList::default(), - cx, - ) - }) - }); - save_projectless.await.unwrap(); + cx.update(|cx| migrate_thread_metadata(cx)); cx.run_until_parked(); - // Run migration - cx.update(|cx| { - migrate_thread_metadata(cx); - }); - - cx.run_until_parked(); - - // Verify the metadata was migrated, limited to 10 per project, and - // projectless threads were skipped. let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.read(cx).entries().collect::>() }); - assert_eq!(list.len(), 13); + assert_eq!(list.len(), 3); assert!( list.iter() - .all(|metadata| !metadata.folder_paths.is_empty()) - ); - assert!( - list.iter() - .all(|metadata| metadata.session_id.0.as_ref() != "projectless-session") + .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref()) ); - let project_a_entries = list + let existing_metadata = list + .iter() + .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0") + .unwrap(); + assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata"); + assert!(!existing_metadata.archived); + + let migrated_session_ids = list .iter() - .filter(|metadata| metadata.folder_paths == project_a_paths) + .map(|metadata| metadata.session_id.0.as_ref()) .collect::>(); - assert_eq!(project_a_entries.len(), 10); - assert_eq!( - project_a_entries - .iter() - .map(|metadata| metadata.session_id.0.as_ref()) - .collect::>(), - vec![ - "project-a-session-11", - "project-a-session-10", - "project-a-session-9", - "project-a-session-8", - "project-a-session-7", - "project-a-session-6", - "project-a-session-5", - "project-a-session-4", - "project-a-session-3", - "project-a-session-2", - ] - ); - assert!( - project_a_entries - .iter() - .all(|metadata| metadata.agent_id.is_none()) - ); + assert!(migrated_session_ids.contains(&"a-session-1")); + assert!(migrated_session_ids.contains(&"b-session-0")); + assert!(!migrated_session_ids.contains(&"projectless")); - let project_b_entries = list + let migrated_entries = list .iter() - .filter(|metadata| metadata.folder_paths == project_b_paths) + .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0") .collect::>(); - assert_eq!(project_b_entries.len(), 3); - assert_eq!( - project_b_entries - .iter() - .map(|metadata| metadata.session_id.0.as_ref()) - .collect::>(), - vec![ - "project-b-session-2", - "project-b-session-1", - "project-b-session-0", - ] - ); assert!( - project_b_entries + migrated_entries .iter() - .all(|metadata| metadata.agent_id.is_none()) + .all(|metadata| !metadata.folder_paths.is_empty()) ); + assert!(migrated_entries.iter().all(|metadata| metadata.archived)); } #[gpui::test] - async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) { + async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist( + cx: &mut TestAppContext, + ) { cx.update(|cx| { ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); - // Pre-populate the metadata store with existing data + let project_paths = PathList::new(&[Path::new("/project-a")]); + let existing_updated_at = Utc::now(); + let existing_metadata = ThreadMetadata { session_id: acp::SessionId::new("existing-session"), - agent_id: None, - title: "Existing Thread".into(), - updated_at: Utc::now(), - created_at: Some(Utc::now()), - folder_paths: PathList::default(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Existing Metadata".into(), + updated_at: existing_updated_at, + created_at: Some(existing_updated_at), + folder_paths: project_paths.clone(), + archived: false, }; cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { store.save(existing_metadata, cx); }); }); - cx.run_until_parked(); - // Add an entry to native thread store that should NOT be migrated let save_task = cx.update(|cx| { let thread_store = ThreadStore::global(cx); thread_store.update(cx, |store, cx| { store.save_thread( - acp::SessionId::new("native-session"), - make_db_thread("Native Thread", Utc::now()), - PathList::default(), + acp::SessionId::new("existing-session"), + make_db_thread( + "Updated Native Thread Title", + existing_updated_at + chrono::Duration::seconds(1), + ), + project_paths.clone(), cx, ) }) @@ -988,22 +1004,17 @@ mod tests { save_task.await.unwrap(); cx.run_until_parked(); - // Run migration - should skip because metadata store is not empty - cx.update(|cx| { - migrate_thread_metadata(cx); - }); - + cx.update(|cx| migrate_thread_metadata(cx)); cx.run_until_parked(); - // Verify only the existing metadata is present (migration was skipped) let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.read(cx).entries().collect::>() }); + assert_eq!(list.len(), 1); assert_eq!(list[0].session_id.0.as_ref(), "existing-session"); } - #[gpui::test] async fn test_empty_thread_metadata_deleted_when_thread_released(cx: &mut TestAppContext) { cx.update(|cx| { @@ -1011,7 +1022,7 @@ mod tests { cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); let fs = FakeFs::new(cx.executor()); @@ -1036,7 +1047,7 @@ mod tests { cx.run_until_parked(); let metadata_ids = cx.update(|cx| { - SidebarThreadMetadataStore::global(cx) + ThreadMetadataStore::global(cx) .read(cx) .entry_ids() .collect::>() @@ -1049,7 +1060,7 @@ mod tests { cx.run_until_parked(); let metadata_ids = cx.update(|cx| { - SidebarThreadMetadataStore::global(cx) + ThreadMetadataStore::global(cx) .read(cx) .entry_ids() .collect::>() @@ -1067,7 +1078,7 @@ mod tests { cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); let fs = FakeFs::new(cx.executor()); @@ -1092,7 +1103,7 @@ mod tests { cx.run_until_parked(); let metadata_ids = cx.update(|cx| { - SidebarThreadMetadataStore::global(cx) + ThreadMetadataStore::global(cx) .read(cx) .entry_ids() .collect::>() @@ -1104,7 +1115,7 @@ mod tests { cx.run_until_parked(); let metadata_ids = cx.update(|cx| { - SidebarThreadMetadataStore::global(cx) + ThreadMetadataStore::global(cx) .read(cx) .entry_ids() .collect::>() @@ -1119,7 +1130,7 @@ mod tests { cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); let fs = FakeFs::new(cx.executor()); @@ -1177,7 +1188,7 @@ mod tests { // List all metadata from the store cache. let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.read(cx).entries().collect::>() }); @@ -1208,7 +1219,7 @@ mod tests { DbOperation::Delete(acp::SessionId::new("session-1")), ]; - let deduped = SidebarThreadMetadataStore::dedup_db_operations(operations); + let deduped = ThreadMetadataStore::dedup_db_operations(operations); assert_eq!(deduped.len(), 1); assert_eq!( @@ -1225,7 +1236,7 @@ mod tests { let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default()); let new_metadata = make_metadata("session-1", "New Title", later, PathList::default()); - let deduped = SidebarThreadMetadataStore::dedup_db_operations(vec![ + let deduped = ThreadMetadataStore::dedup_db_operations(vec![ DbOperation::Insert(old_metadata), DbOperation::Insert(new_metadata.clone()), ]); @@ -1240,7 +1251,7 @@ mod tests { let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default()); let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default()); - let deduped = SidebarThreadMetadataStore::dedup_db_operations(vec![ + let deduped = ThreadMetadataStore::dedup_db_operations(vec![ DbOperation::Insert(metadata1.clone()), DbOperation::Insert(metadata2.clone()), ]); @@ -1249,4 +1260,307 @@ mod tests { assert!(deduped.contains(&DbOperation::Insert(metadata1))); assert!(deduped.contains(&DbOperation::Insert(metadata2))); } + + #[gpui::test] + async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + let metadata = make_metadata("session-1", "Thread 1", now, paths.clone()); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(path_entries, vec!["session-1"]); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(archived.is_empty()); + }); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("session-1"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(path_entries.is_empty()); + + let archived = store.archived_entries().collect::>(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].session_id.0.as_ref(), "session-1"); + assert!(archived[0].archived); + }); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.unarchive(&acp::SessionId::new("session-1"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(path_entries, vec!["session-1"]); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(archived.is_empty()); + }); + } + + #[gpui::test] + async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + + let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone()); + let metadata2 = make_metadata( + "session-2", + "Archived Thread", + now - chrono::Duration::seconds(1), + paths.clone(), + ); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(metadata1, cx); + store.save(metadata2, cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("session-2"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(path_entries, vec!["session-1"]); + + let all_entries = store + .entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(all_entries.len(), 2); + assert!(all_entries.contains(&"session-1".to_string())); + assert!(all_entries.contains(&"session-2".to_string())); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(archived, vec!["session-2"]); + }); + } + + #[gpui::test] + async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + + let m1 = make_metadata("session-1", "Thread One", now, paths.clone()); + let m2 = make_metadata( + "session-2", + "Thread Two", + now - chrono::Duration::seconds(1), + paths.clone(), + ); + let m3 = make_metadata( + "session-3", + "Thread Three", + now - chrono::Duration::seconds(2), + paths, + ); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save_all(vec![m1, m2, m3], cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let all_entries = store + .entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(all_entries.len(), 3); + assert!(all_entries.contains(&"session-1".to_string())); + assert!(all_entries.contains(&"session-2".to_string())); + assert!(all_entries.contains(&"session-3".to_string())); + + let entry_ids = store.entry_ids().collect::>(); + assert_eq!(entry_ids.len(), 3); + }); + } + + #[gpui::test] + async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + let metadata = make_metadata("session-1", "Thread 1", now, paths.clone()); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("session-1"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + let _ = store.reload(cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let thread = store + .entries() + .find(|e| e.session_id.0.as_ref() == "session-1") + .expect("thread should exist after reload"); + assert!(thread.archived); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(path_entries.is_empty()); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(archived, vec!["session-1"]); + }); + } + + #[gpui::test] + async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("nonexistent"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + assert!(store.is_empty()); + assert_eq!(store.entries().count(), 0); + assert_eq!(store.archived_entries().count(), 0); + }); + } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index a5c83d48b71c08f183c842cb3b5a3d8b75db4d89..f96efe36ba4541304b855f7f67d8f9e5cd482c2f 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,10 +1,8 @@ -use std::sync::Arc; +use crate::agent_connection_store::AgentConnectionStore; +use crate::thread_import::ThreadImportModal; +use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; +use crate::{Agent, RemoveSelectedThread}; -use crate::{ - Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore, - thread_history::ThreadHistory, -}; -use acp_thread::AgentSessionInfo; use agent::ThreadStore; use agent_client_protocol as acp; use agent_settings::AgentSettings; @@ -13,19 +11,20 @@ use editor::Editor; use fs::Fs; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render, - SharedString, Subscription, Task, Window, list, prelude::*, px, + SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px, }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::{AgentId, AgentServerStore}; +use project::{AgentId, AgentRegistryStore, AgentServerStore}; use settings::Settings as _; use theme::ActiveTheme; +use ui::ThreadItem; use ui::{ - ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel, - KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*, - utils::platform_title_bar_height, + Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, }; -use util::ResultExt as _; +use util::ResultExt; +use workspace::{MultiWorkspace, Workspace}; + use zed_actions::agents_sidebar::FocusSidebarFilter; use zed_actions::editor::{MoveDown, MoveUp}; @@ -33,7 +32,7 @@ use zed_actions::editor::{MoveDown, MoveUp}; enum ArchiveListItem { BucketSeparator(TimeBucket), Entry { - session: AgentSessionInfo, + thread: ThreadMetadata, highlight_positions: Vec, }, } @@ -95,40 +94,15 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option> { } } -fn archive_empty_state_message( - has_history: bool, - is_empty: bool, - has_query: bool, -) -> Option<&'static str> { - if !is_empty { - None - } else if !has_history { - Some("This agent does not support viewing archived threads.") - } else if has_query { - Some("No threads match your search.") - } else { - Some("No archived threads yet.") - } -} - pub enum ThreadsArchiveViewEvent { Close, - Unarchive { - agent: Agent, - session_info: AgentSessionInfo, - }, + Unarchive { thread: ThreadMetadata }, } impl EventEmitter for ThreadsArchiveView {} pub struct ThreadsArchiveView { - agent_connection_store: Entity, - agent_server_store: Entity, - thread_store: Entity, - fs: Arc, - history: Option>, _history_subscription: Subscription, - selected_agent: Agent, focus_handle: FocusHandle, list_state: ListState, items: Vec, @@ -136,17 +110,21 @@ pub struct ThreadsArchiveView { hovered_index: Option, filter_editor: Entity, _subscriptions: Vec, - selected_agent_menu: PopoverMenuHandle, _refresh_history_task: Task<()>, - is_loading: bool, + agent_connection_store: WeakEntity, + agent_server_store: WeakEntity, + agent_registry_store: WeakEntity, + workspace: WeakEntity, + multi_workspace: WeakEntity, } impl ThreadsArchiveView { pub fn new( - agent_connection_store: Entity, - agent_server_store: Entity, - thread_store: Entity, - fs: Arc, + agent_connection_store: WeakEntity, + agent_server_store: WeakEntity, + agent_registry_store: WeakEntity, + workspace: WeakEntity, + multi_workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -178,6 +156,13 @@ impl ThreadsArchiveView { ) .detach(); + let thread_metadata_store_subscription = cx.observe( + &ThreadMetadataStore::global(cx), + |this: &mut Self, _, cx| { + this.update_items(cx); + }, + ); + cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| { this.selection = None; cx.notify(); @@ -185,25 +170,26 @@ impl ThreadsArchiveView { .detach(); let mut this = Self { - agent_connection_store, - agent_server_store, - thread_store, - fs, - history: None, _history_subscription: Subscription::new(|| {}), - selected_agent: Agent::NativeAgent, focus_handle, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), items: Vec::new(), selection: None, hovered_index: None, filter_editor, - _subscriptions: vec![filter_editor_subscription], - selected_agent_menu: PopoverMenuHandle::default(), + _subscriptions: vec![ + filter_editor_subscription, + thread_metadata_store_subscription, + ], _refresh_history_task: Task::ready(()), - is_loading: true, + agent_registry_store, + agent_connection_store, + agent_server_store, + workspace, + multi_workspace, }; - this.set_selected_agent(Agent::NativeAgent, window, cx); + + this.update_items(cx); this } @@ -220,59 +206,14 @@ impl ThreadsArchiveView { handle.focus(window, cx); } - fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { - self.selected_agent = agent.clone(); - self.is_loading = true; - self.reset_history_subscription(); - self.history = None; - self.items.clear(); - self.selection = None; - self.list_state.reset(0); - self.reset_filter_editor_text(window, cx); - - let server = agent.server(self.fs.clone(), self.thread_store.clone()); - let connection = self - .agent_connection_store - .update(cx, |store, cx| store.request_connection(agent, server, cx)); - - let task = connection.read(cx).wait_for_connection(); - self._refresh_history_task = cx.spawn(async move |this, cx| { - if let Some(state) = task.await.log_err() { - this.update(cx, |this, cx| this.set_history(state.history, cx)) - .ok(); - } - }); - - cx.notify(); - } - - fn reset_history_subscription(&mut self) { - self._history_subscription = Subscription::new(|| {}); - } - - fn set_history(&mut self, history: Option>, cx: &mut Context) { - self.reset_history_subscription(); - - if let Some(history) = &history { - self._history_subscription = cx.observe(history, |this, _, cx| { - this.update_items(cx); - }); - history.update(cx, |history, cx| { - history.refresh_full_history(cx); - }); - } - self.history = history; - self.is_loading = false; - self.update_items(cx); - cx.notify(); - } - fn update_items(&mut self, cx: &mut Context) { - let sessions = self - .history - .as_ref() - .map(|h| h.read(cx).sessions().to_vec()) - .unwrap_or_default(); + let sessions = ThreadMetadataStore::global(cx) + .read(cx) + .archived_entries() + .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at)) + .rev() + .collect::>(); + let query = self.filter_editor.read(cx).text(cx).to_lowercase(); let today = Local::now().naive_local().date(); @@ -281,8 +222,7 @@ impl ThreadsArchiveView { for session in sessions { let highlight_positions = if !query.is_empty() { - let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or(""); - match fuzzy_match_positions(&query, title) { + match fuzzy_match_positions(&query, &session.title) { Some(positions) => positions, None => continue, } @@ -290,13 +230,15 @@ impl ThreadsArchiveView { Vec::new() }; - let entry_bucket = session - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::Older); + let entry_bucket = { + let entry_date = session + .created_at + .unwrap_or(session.updated_at) + .with_timezone(&Local) + .naive_local() + .date(); + TimeBucket::from_dates(today, entry_date) + }; if Some(entry_bucket) != current_bucket { current_bucket = Some(entry_bucket); @@ -304,7 +246,7 @@ impl ThreadsArchiveView { } items.push(ArchiveListItem::Entry { - session, + thread: session, highlight_positions, }); } @@ -324,47 +266,13 @@ impl ThreadsArchiveView { fn unarchive_thread( &mut self, - session_info: AgentSessionInfo, + thread: ThreadMetadata, window: &mut Window, cx: &mut Context, ) { self.selection = None; self.reset_filter_editor_text(window, cx); - cx.emit(ThreadsArchiveViewEvent::Unarchive { - agent: self.selected_agent.clone(), - session_info, - }); - } - - fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { - let Some(history) = &self.history else { - return; - }; - if !history.read(cx).supports_delete() { - return; - } - let session_id = session_id.clone(); - history.update(cx, |history, cx| { - history - .delete_session(&session_id, cx) - .detach_and_log_err(cx); - }); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(ix) = self.selection else { - return; - }; - let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { - return; - }; - let session_id = session.session_id.clone(); - self.delete_thread(&session_id, cx); + cx.emit(ThreadsArchiveViewEvent::Unarchive { thread }); } fn is_selectable_item(&self, ix: usize) -> bool { @@ -450,16 +358,15 @@ impl ThreadsArchiveView { fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { let Some(ix) = self.selection else { return }; - let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else { return; }; - let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty()); - if !can_unarchive { + if thread.folder_paths.is_empty() { return; } - self.unarchive_thread(session.clone(), window, cx); + self.unarchive_thread(thread.clone(), window, cx); } fn render_list_entry( @@ -485,71 +392,40 @@ impl ThreadsArchiveView { ) .into_any_element(), ArchiveListItem::Entry { - session, + thread, highlight_positions, } => { let id = SharedString::from(format!("archive-entry-{}", ix)); let is_focused = self.selection == Some(ix); - let hovered = self.hovered_index == Some(ix); - - let project_names = session.work_dirs.as_ref().and_then(|paths| { - let paths_str = paths - .paths() - .iter() - .filter_map(|p| p.file_name()) - .filter_map(|name| name.to_str()) - .join(", "); - if paths_str.is_empty() { - None - } else { - Some(paths_str) - } - }); - - let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty()); - - let supports_delete = self - .history - .as_ref() - .map(|h| h.read(cx).supports_delete()) - .unwrap_or(false); + let is_hovered = self.hovered_index == Some(ix); - let title: SharedString = - session.title.clone().unwrap_or_else(|| "Untitled".into()); - - let session_info = session.clone(); - let session_id_for_delete = session.session_id.clone(); let focus_handle = self.focus_handle.clone(); - let timestamp = session - .created_at - .or(session.updated_at) - .map(format_history_entry_timestamp); + let timestamp = + format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at)); - let highlight_positions = highlight_positions.clone(); - let title_label = if highlight_positions.is_empty() { - Label::new(title).truncate().flex_1().into_any_element() + let icon_from_external_svg = self + .agent_server_store + .upgrade() + .and_then(|store| store.read(cx).agent_icon(&thread.agent_id)); + + let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + IconName::ZedAgent } else { - HighlightedLabel::new(title, highlight_positions) - .truncate() - .flex_1() - .into_any_element() + IconName::Sparkle }; - h_flex() - .id(id) - .min_w_0() - .w_full() - .px(DynamicSpacing::Base06.rems(cx)) - .border_1() - .map(|this| { - if is_focused { - this.border_color(cx.theme().colors().border_focused) - } else { - this.border_color(gpui::transparent_black()) - } + ThreadItem::new(id, thread.title.clone()) + .icon(icon) + .when_some(icon_from_external_svg, |this, svg| { + this.custom_icon_from_external_svg(svg) }) + .timestamp(timestamp) + .highlight_positions(highlight_positions.clone()) + .project_paths(thread.folder_paths.paths_owned()) + .focused(is_focused) + .hovered(is_hovered) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { this.hovered_index = Some(ix); @@ -558,105 +434,59 @@ impl ThreadsArchiveView { } cx.notify(); })) - .child( - v_flex() - .min_w_0() - .w_full() - .p_1() - .child( - h_flex() - .min_w_0() - .w_full() - .gap_1() - .justify_between() - .child(title_label) - .when(hovered || is_focused, |this| { - this.child( - h_flex() - .gap_0p5() - .when(can_unarchive, |this| { - this.child( - Button::new("unarchive-thread", "Restore") - .style(ButtonStyle::Filled) - .label_size(LabelSize::Small) - .when(is_focused, |this| { - this.key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| { - kb.size(rems_from_px(12.)) - }), - ) - }) - .on_click(cx.listener( - move |this, _, window, cx| { - this.unarchive_thread( - session_info.clone(), - window, - cx, - ); - }, - )), - ) - }) - .when(supports_delete, |this| { - this.child( - IconButton::new( - "delete-thread", - IconName::Trash, - ) - .style(ButtonStyle::Filled) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - "Delete Thread", - &RemoveSelectedThread, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener( - move |this, _, _, cx| { - this.delete_thread( - &session_id_for_delete, - cx, - ); - cx.stop_propagation(); - }, - )), - ) - }), - ) - }), - ) + .action_slot( + h_flex() + .gap_2() + .when(is_hovered || is_focused, |this| { + let focus_handle = self.focus_handle.clone(); + this.child( + Button::new("unarchive-thread", "Open") + .style(ButtonStyle::Filled) + .label_size(LabelSize::Small) + .when(is_focused, |this| { + this.key_binding( + KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + }) + .on_click({ + let thread = thread.clone(); + cx.listener(move |this, _, window, cx| { + this.unarchive_thread(thread.clone(), window, cx); + }) + }), + ) + }) .child( - h_flex() - .gap_1() - .when_some(timestamp, |this, ts| { - this.child( - Label::new(ts) - .size(LabelSize::Small) - .color(Color::Muted), - ) + IconButton::new("delete-thread", IconName::Trash) + .style(ButtonStyle::Filled) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } }) - .when_some(project_names, |this, project| { - this.child( - Label::new("•") - .size(LabelSize::Small) - .color(Color::Muted) - .alpha(0.5), - ) - .child( - Label::new(project) - .size(LabelSize::Small) - .color(Color::Muted), - ) + .on_click({ + let agent = thread.agent_id.clone(); + let session_id = thread.session_id.clone(); + cx.listener(move |this, _, _, cx| { + this.delete_thread( + session_id.clone(), + agent.clone(), + cx, + ); + cx.stop_propagation(); + }) }), ), ) @@ -665,134 +495,67 @@ impl ThreadsArchiveView { } } - fn render_agent_picker(&self, cx: &mut Context) -> PopoverMenu { - let agent_server_store = self.agent_server_store.clone(); + fn delete_thread( + &mut self, + session_id: acp::SessionId, + agent: AgentId, + cx: &mut Context, + ) { + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id.clone(), cx)); - let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() { - (IconName::ChevronUp, Color::Accent) - } else { - (IconName::ChevronDown, Color::Muted) + let agent = Agent::from(agent); + + let Some(agent_connection_store) = self.agent_connection_store.upgrade() else { + return; }; + let fs = ::global(cx); - let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent { - let store = agent_server_store.read(cx); - let icon = store.agent_icon(&id); + let task = agent_connection_store.update(cx, |store, cx| { + store + .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx) + .read(cx) + .wait_for_connection() + }); + cx.spawn(async move |_this, cx| { + let state = task.await?; + let task = cx.update(|cx| { + if let Some(list) = state.connection.session_list(cx) { + list.delete_session(&session_id, cx) + } else { + Task::ready(Ok(())) + } + }); + task.await + }) + .detach_and_log_err(cx); + } - if let Some(icon) = icon { - Icon::from_external_svg(icon) - } else { - Icon::new(IconName::Sparkle) - } - .color(Color::Muted) - .size(IconSize::Small) - } else { - Icon::new(IconName::ZedAgent) - .color(Color::Muted) - .size(IconSize::Small) + fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(agent_server_store) = self.agent_server_store.upgrade() else { + return; + }; + let Some(agent_registry_store) = self.agent_registry_store.upgrade() else { + return; }; - let this = cx.weak_entity(); - - PopoverMenu::new("agent_history_menu") - .trigger( - ButtonLike::new("selected_agent") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - h_flex().gap_1().child(selected_agent_icon).child( - Icon::new(chevron_icon) - .color(icon_color) - .size(IconSize::XSmall), - ), - ), - ) - .menu(move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, cx| { - menu.item( - ContextMenuEntry::new("Zed Agent") - .icon(IconName::ZedAgent) - .icon_color(Color::Muted) - .handler({ - let this = this.clone(); - move |window, cx| { - this.update(cx, |this, cx| { - this.set_selected_agent(Agent::NativeAgent, window, cx) - }) - .ok(); - } - }), + let workspace_handle = self.workspace.clone(); + let multi_workspace = self.multi_workspace.clone(); + + self.workspace + .update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + ThreadImportModal::new( + agent_server_store, + agent_registry_store, + workspace_handle.clone(), + multi_workspace.clone(), + window, + cx, ) - .separator() - .map(|mut menu| { - let agent_server_store = agent_server_store.read(cx); - let registry_store = project::AgentRegistryStore::try_global(cx); - let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); - - struct AgentMenuItem { - id: AgentId, - display_name: SharedString, - } - - let agent_items = agent_server_store - .external_agents() - .map(|agent_id| { - let display_name = agent_server_store - .agent_display_name(agent_id) - .or_else(|| { - registry_store_ref - .as_ref() - .and_then(|store| store.agent(agent_id)) - .map(|a| a.name().clone()) - }) - .unwrap_or_else(|| agent_id.0.clone()); - AgentMenuItem { - id: agent_id.clone(), - display_name, - } - }) - .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) - .collect::>(); - - for item in &agent_items { - let mut entry = ContextMenuEntry::new(item.display_name.clone()); - - let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| { - registry_store_ref - .as_ref() - .and_then(|store| store.agent(&item.id)) - .and_then(|a| a.icon_path().cloned()) - }); - - if let Some(icon_path) = icon_path { - entry = entry.custom_icon_svg(icon_path); - } else { - entry = entry.icon(IconName::ZedAgent); - } - - entry = entry.icon_color(Color::Muted).handler({ - let this = this.clone(); - let agent = Agent::Custom { - id: item.id.clone(), - }; - move |window, cx| { - this.update(cx, |this, cx| { - this.set_selected_agent(agent.clone(), window, cx) - }) - .ok(); - } - }); - - menu = menu.item(entry); - } - menu - }) - })) - }) - .with_handle(self.selected_agent_menu.clone()) - .anchor(gpui::Corner::TopRight) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), + }); }) + .log_err(); } fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { @@ -842,19 +605,27 @@ impl ThreadsArchiveView { .when(show_focus_keybinding, |this| { this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)) }) - .when(!has_query && !show_focus_keybinding, |this| { - this.child(self.render_agent_picker(cx)) - }) - .when(has_query, |this| { - this.child( - IconButton::new("clear_filter", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_items(cx); - })), - ) + .map(|this| { + if has_query { + this.child( + IconButton::new("clear-filter", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_items(cx); + })), + ) + } else { + this.child( + IconButton::new("import-thread", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Import ACP Threads")) + .on_click(cx.listener(|this, _, window, cx| { + this.show_thread_import_modal(window, cx); + })), + ) + } }) } } @@ -888,30 +659,18 @@ impl Focusable for ThreadsArchiveView { } } -impl ThreadsArchiveView { - fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> { - archive_empty_state_message(self.history.is_some(), is_empty, has_query) - } -} - impl Render for ThreadsArchiveView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.items.is_empty(); let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); - let content = if self.is_loading { - v_flex() - .flex_1() - .justify_center() - .items_center() - .child( - Icon::new(IconName::LoadCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(2), - ) - .into_any_element() - } else if let Some(message) = self.empty_state_message(is_empty, has_query) { + let content = if is_empty { + let message = if has_query { + "No threads match your search." + } else { + "No archived or hidden threads yet." + }; + v_flex() .flex_1() .justify_center() @@ -948,44 +707,8 @@ impl Render for ThreadsArchiveView { .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::remove_selected_thread)) .size_full() .child(self.render_header(window, cx)) .child(content) } } - -#[cfg(test)] -mod tests { - use super::archive_empty_state_message; - - #[test] - fn empty_state_message_returns_none_when_archive_has_items() { - assert_eq!(archive_empty_state_message(false, false, false), None); - assert_eq!(archive_empty_state_message(true, false, true), None); - } - - #[test] - fn empty_state_message_distinguishes_unsupported_history() { - assert_eq!( - archive_empty_state_message(false, true, false), - Some("This agent does not support viewing archived threads.") - ); - assert_eq!( - archive_empty_state_message(false, true, true), - Some("This agent does not support viewing archived threads.") - ); - } - - #[test] - fn empty_state_message_distinguishes_empty_history_and_search_results() { - assert_eq!( - archive_empty_state_message(true, true, false), - Some("No archived threads yet.") - ); - assert_eq!( - archive_empty_state_message(true, true, true), - Some("No threads match your search.") - ); - } -} diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 573a747d8c6e82944f1111874081dea67c1f5e72..218b1841d1178a5ebba29f4935e4699189567fde 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -307,9 +307,9 @@ impl AgentServerStore { cx.emit(AgentServersUpdated); } - pub fn agent_icon(&self, name: &AgentId) -> Option { + pub fn agent_icon(&self, id: &AgentId) -> Option { self.external_agents - .get(name) + .get(id) .and_then(|entry| entry.icon.clone()) } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 345a8f4ac8c6f518073879b8e72b067fd4cc5fb0..4263d64ffb72e262f9f74bf287bd33bf5c3dfe17 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4,7 +4,7 @@ use acp_thread::ThreadStatus; use action_log::DiffStats; use agent_client_protocol::{self as acp}; use agent_settings::AgentSettings; -use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata}; +use agent_ui::thread_metadata_store::ThreadMetadataStore; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; @@ -21,7 +21,7 @@ use gpui::{ use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; -use project::{Event as ProjectEvent, linked_worktree_short_name}; +use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use ui::utils::platform_title_bar_height; @@ -383,12 +383,9 @@ impl Sidebar { }) .detach(); - cx.observe( - &SidebarThreadMetadataStore::global(cx), - |this, _store, cx| { - this.update_entries(cx); - }, - ) + cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { + this.update_entries(cx); + }) .detach(); cx.observe_flag::(window, |_is_enabled, this, _window, cx| { @@ -712,20 +709,16 @@ impl Sidebar { .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); - let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option) { - match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(id) => { - let custom_icon = agent_server_store - .as_ref() - .and_then(|store| store.read(cx).agent_icon(id)); - ( - Agent::Custom { id: id.clone() }, - IconName::Terminal, - custom_icon, - ) - } - } + let resolve_agent = |agent_id: &AgentId| -> (Agent, IconName, Option) { + let agent = Agent::from(agent_id.clone()); + let icon = match agent { + Agent::NativeAgent => IconName::ZedAgent, + Agent::Custom { .. } => IconName::Terminal, + }; + let icon_from_external_svg = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(&agent_id)); + (agent, icon, icon_from_external_svg) }; for (group_name, group) in project_groups.groups() { @@ -764,7 +757,7 @@ impl Sidebar { if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); - let thread_store = SidebarThreadMetadataStore::global(cx); + let thread_store = ThreadMetadataStore::global(cx); // Load threads from each workspace in the group. for workspace in &group.workspaces { @@ -774,7 +767,7 @@ impl Sidebar { if !seen_session_ids.insert(row.session_id.clone()) { continue; } - let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let (agent, icon, icon_from_external_svg) = resolve_agent(&row.agent_id); let worktrees = worktree_info_from_thread_paths(&row.folder_paths, &project_groups); threads.push(ThreadEntry { @@ -824,7 +817,7 @@ impl Sidebar { if !seen_session_ids.insert(row.session_id.clone()) { continue; } - let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let (agent, icon, icon_from_external_svg) = resolve_agent(&row.agent_id); let worktrees = worktree_info_from_thread_paths(&row.folder_paths, &project_groups); threads.push(ThreadEntry { @@ -2092,12 +2085,8 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { - // Eagerly save thread metadata so that the sidebar is updated immediately - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { - store.save( - ThreadMetadata::from_session_info(agent.id(), &session_info), - cx, - ) + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.unarchive(&session_info.session_id, cx) }); if let Some(path_list) = &session_info.work_dirs { @@ -2276,6 +2265,8 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx)); + // If we're archiving the currently focused thread, move focus to the // nearest thread within the same project group. We never cross group // boundaries — if the group has no other threads, clear focus and open @@ -2376,9 +2367,6 @@ impl Sidebar { } } } - - SidebarThreadMetadataStore::global(cx) - .update(cx, |store, cx| store.delete(session_id.clone(), cx)); } fn remove_selected_thread( @@ -2393,11 +2381,13 @@ impl Sidebar { let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { return; }; - if thread.agent != Agent::NativeAgent { - return; + match thread.status { + AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => return, + AgentThreadStatus::Completed | AgentThreadStatus::Error => {} } + let session_id = thread.session_info.session_id.clone(); - self.archive_thread(&session_id, window, cx); + self.archive_thread(&session_id, window, cx) } fn record_thread_access(&mut self, session_id: &acp::SessionId) { @@ -3273,31 +3263,34 @@ impl Sidebar { }) else { return; }; - let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { return; }; + let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else { + return; + }; - let thread_store = agent_panel.read(cx).thread_store().clone(); - let fs = active_workspace.read(cx).project().read(cx).fs().clone(); - let agent_connection_store = agent_panel.read(cx).connection_store().clone(); let agent_server_store = active_workspace .read(cx) .project() .read(cx) .agent_server_store() - .clone(); + .downgrade(); + + let agent_connection_store = agent_panel.read(cx).connection_store().downgrade(); let archive_view = cx.new(|cx| { ThreadsArchiveView::new( - agent_connection_store, - agent_server_store, - thread_store, - fs, + agent_connection_store.clone(), + agent_server_store.clone(), + agent_registry_store.downgrade(), + active_workspace.downgrade(), + self.multi_workspace.clone(), window, cx, ) }); + let subscription = cx.subscribe_in( &archive_view, window, @@ -3305,12 +3298,20 @@ impl Sidebar { ThreadsArchiveViewEvent::Close => { this.show_thread_list(window, cx); } - ThreadsArchiveViewEvent::Unarchive { - agent, - session_info, - } => { + ThreadsArchiveViewEvent::Unarchive { thread } => { this.show_thread_list(window, cx); - this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx); + + let agent = Agent::from(thread.agent_id.clone()); + let session_info = acp_thread::AgentSessionInfo { + session_id: thread.session_id.clone(), + work_dirs: Some(thread.folder_paths.clone()), + title: Some(thread.title.clone()), + updated_at: Some(thread.updated_at), + created_at: thread.created_at, + meta: None, + }; + + this.activate_archived_thread(agent, session_info, window, cx); } }, ); diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 57edbd4212acf7a40e7c29f02403e9cb2b00e11d..58f98da9fc1a1685e6d3795023ea4f5bbadd0ea0 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -1,7 +1,10 @@ use super::*; use acp_thread::StubAgentConnection; use agent::ThreadStore; -use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; +use agent_ui::{ + test_support::{active_session_id, open_thread_with_connection, send_message}, + thread_metadata_store::ThreadMetadata, +}; use assistant_text_thread::TextThreadStore; use chrono::DateTime; use feature_flags::FeatureFlagAppExt as _; @@ -20,7 +23,7 @@ fn init_test(cx: &mut TestAppContext) { editor::init(cx); cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -113,14 +116,15 @@ async fn save_thread_metadata( ) { let metadata = ThreadMetadata { session_id, - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title, updated_at, created_at: None, folder_paths: path_list, + archived: false, }; cx.update(|cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) }); cx.run_until_parked(); } @@ -1076,7 +1080,7 @@ async fn init_test_project_with_agent_panel( cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -2249,7 +2253,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -2816,7 +2820,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -2928,7 +2932,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -3874,7 +3878,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -4178,11 +4182,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { send_message(&panel, cx); let session_id_c = active_session_id(&panel, cx); cx.update(|_, cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.save( ThreadMetadata { session_id: session_id_c.clone(), - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title: "Thread C".into(), updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0) .unwrap(), @@ -4190,6 +4194,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), ), folder_paths: path_list.clone(), + archived: false, }, cx, ) @@ -4205,11 +4210,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { send_message(&panel, cx); let session_id_b = active_session_id(&panel, cx); cx.update(|_, cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.save( ThreadMetadata { session_id: session_id_b.clone(), - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title: "Thread B".into(), updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0) .unwrap(), @@ -4217,6 +4222,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), ), folder_paths: path_list.clone(), + archived: false, }, cx, ) @@ -4232,11 +4238,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { send_message(&panel, cx); let session_id_a = active_session_id(&panel, cx); cx.update(|_, cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.save( ThreadMetadata { session_id: session_id_a.clone(), - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title: "Thread A".into(), updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0) .unwrap(), @@ -4244,6 +4250,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), ), folder_paths: path_list.clone(), + archived: false, }, cx, ) @@ -4343,11 +4350,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { // ── 3. Add a historical thread (no last_accessed_at, no message sent) ── // This thread was never opened in a panel — it only exists in metadata. cx.update(|_, cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.save( ThreadMetadata { session_id: acp::SessionId::new(Arc::from("thread-historical")), - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title: "Historical Thread".into(), updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0) .unwrap(), @@ -4355,6 +4362,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), ), folder_paths: path_list.clone(), + archived: false, }, cx, ) @@ -4394,11 +4402,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { // ── 4. Add another historical thread with older created_at ───────── cx.update(|_, cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.save( ThreadMetadata { session_id: acp::SessionId::new(Arc::from("thread-old-historical")), - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title: "Old Historical Thread".into(), updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0) .unwrap(), @@ -4406,6 +4414,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(), ), folder_paths: path_list.clone(), + archived: false, }, cx, ) @@ -4439,6 +4448,119 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { cx.run_until_parked(); } +#[gpui::test] +async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(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_thread_metadata( + acp::SessionId::new(Arc::from("thread-to-archive")), + "Thread To Archive".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("Thread To Archive")), + "expected thread to be visible before archiving, got: {entries:?}" + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread( + &acp::SessionId::new(Arc::from("thread-to-archive")), + window, + cx, + ); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries.iter().any(|e| e.contains("Thread To Archive")), + "expected thread to be hidden after archiving, got: {entries:?}" + ); + + cx.update(|_, cx| { + let store = ThreadMetadataStore::global(cx); + let archived: Vec<_> = store.read(cx).archived_entries().collect(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive"); + assert!(archived[0].archived); + }); +} + +#[gpui::test] +async fn test_archived_threads_excluded_from_sidebar_entries(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_thread_metadata( + acp::SessionId::new(Arc::from("visible-thread")), + "Visible Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + cx.update(|_, cx| { + let metadata = ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("archived-thread")), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Archived Thread".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + created_at: None, + folder_paths: path_list.clone(), + archived: true, + }; + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("Visible Thread")), + "expected visible thread in sidebar, got: {entries:?}" + ); + assert!( + !entries.iter().any(|e| e.contains("Archived Thread")), + "expected archived thread to be hidden from sidebar, got: {entries:?}" + ); + + cx.update(|_, cx| { + let store = ThreadMetadataStore::global(cx); + let all: Vec<_> = store.read(cx).entries().collect(); + assert_eq!( + all.len(), + 2, + "expected 2 total entries in the store, got: {}", + all.len() + ); + + let archived: Vec<_> = store.read(cx).archived_entries().collect(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread"); + }); +} + mod property_test { use super::*; use gpui::EntityId; @@ -4581,14 +4703,15 @@ mod property_test { + chrono::Duration::seconds(state.thread_counter as i64); let metadata = ThreadMetadata { session_id, - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title, updated_at, created_at: None, folder_paths: path_list, + archived: false, }; cx.update(|_, cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); }); } @@ -4615,7 +4738,7 @@ mod property_test { Operation::DeleteThread { index } => { let session_id = state.remove_thread(index); cx.update(|_, cx| { - SidebarThreadMetadataStore::global(cx) + ThreadMetadataStore::global(cx) .update(cx, |store, cx| store.delete(session_id, cx)); }); } @@ -4868,7 +4991,7 @@ mod property_test { anyhow::bail!("sidebar should still have an associated multi-workspace"); }; let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - let thread_store = SidebarThreadMetadataStore::global(cx); + let thread_store = ThreadMetadataStore::global(cx); let sidebar_thread_ids: HashSet = sidebar .contents @@ -4963,7 +5086,7 @@ mod property_test { cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index b20692740cb1399a562d160c80297a076f7d4516..18f54936853151001308f6476741772d2f8bda16 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -7,7 +7,8 @@ use gpui::{ Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString, pulsating_between, }; -use std::time::Duration; +use itertools::Itertools as _; +use std::{path::PathBuf, sync::Arc, time::Duration}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -44,6 +45,7 @@ pub struct ThreadItem { hovered: bool, added: Option, removed: Option, + project_paths: Option>, worktrees: Vec, on_click: Option>, on_hover: Box, @@ -71,6 +73,7 @@ impl ThreadItem { hovered: false, added: None, removed: None, + project_paths: None, worktrees: Vec::new(), on_click: None, on_hover: Box::new(|_, _, _| {}), @@ -149,6 +152,11 @@ impl ThreadItem { self } + pub fn project_paths(mut self, paths: Arc<[PathBuf]>) -> Self { + self.project_paths = Some(paths); + self + } + pub fn worktrees(mut self, worktrees: Vec) -> Self { self.worktrees = worktrees; self @@ -312,6 +320,21 @@ impl RenderOnce for ThreadItem { let added_count = self.added.unwrap_or(0); let removed_count = self.removed.unwrap_or(0); + let project_paths = self.project_paths.as_ref().and_then(|paths| { + let paths_str = paths + .as_ref() + .iter() + .filter_map(|p| p.file_name()) + .filter_map(|name| name.to_str()) + .join(", "); + if paths_str.is_empty() { + None + } else { + Some(paths_str) + } + }); + + let has_project_paths = project_paths.is_some(); let has_worktree = !self.worktrees.is_empty(); let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; @@ -429,10 +452,23 @@ impl RenderOnce for ThreadItem { .min_w_0() .gap_1p5() .child(icon_container()) // Icon Spacing - .children(worktree_chips) - .when(has_worktree && (has_diff_stats || has_timestamp), |this| { + .when_some(project_paths, |this, paths| { + this.child( + Label::new(paths) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element(), + ) + }) + .when(has_project_paths && has_worktree, |this| { this.child(dot_separator()) }) + .children(worktree_chips) + .when( + (has_project_paths || has_worktree) + && (has_diff_stats || has_timestamp), + |this| this.child(dot_separator()), + ) .when(has_diff_stats, |this| { this.child( DiffStat::new(diff_stat_id, added_count, removed_count) diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 7c19122e707ed4926bd19109d1f0282c33e79f48..0ea8bce6face2c248239c92e43a14ed010fb0c6e 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -70,6 +70,11 @@ impl PathList { self.paths.as_ref() } + /// Get the paths in the lexicographic order. + pub fn paths_owned(&self) -> Arc<[PathBuf]> { + self.paths.clone() + } + /// Get the order in which the paths were provided. pub fn order(&self) -> &[usize] { self.order.as_ref()