Detailed changes
@@ -113,6 +113,10 @@ impl ThreadStore {
pub fn entries(&self) -> impl Iterator<Item = DbThreadMetadata> + '_ {
self.threads.iter().cloned()
}
+
+ pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
+ self.threads.iter().map(|t| t.id.clone())
+ }
}
#[cfg(test)]
@@ -83,6 +83,10 @@ impl AgentConnectionStore {
}
}
+ pub fn project(&self) -> &Entity<Project> {
+ &self.project
+ }
+
pub fn entry(&self, key: &Agent) -> Option<&Entity<AgentConnectionEntry>> {
self.entries.get(key)
}
@@ -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<AgentId> 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 {
@@ -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);
@@ -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<SharedString>,
+}
+
+pub struct ThreadImportModal {
+ focus_handle: FocusHandle,
+ workspace: WeakEntity<Workspace>,
+ multi_workspace: WeakEntity<MultiWorkspace>,
+ agent_entries: Vec<AgentEntry>,
+ unchecked_agents: HashSet<AgentId>,
+ is_importing: bool,
+ last_error: Option<SharedString>,
+}
+
+impl ThreadImportModal {
+ pub fn new(
+ agent_server_store: Entity<AgentServerStore>,
+ agent_registry_store: Entity<AgentRegistryStore>,
+ workspace: WeakEntity<Workspace>,
+ multi_workspace: WeakEntity<MultiWorkspace>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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::<Vec<_>>();
+
+ 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<AgentId> {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn import_threads(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context<Self>) {
+ 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::<Vec<_>>();
+
+ let existing_sessions = ThreadMetadataStore::global(cx)
+ .read(cx)
+ .entry_ids()
+ .collect::<HashSet<_>>();
+
+ 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<DismissEvent> 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<Self>) -> 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::<Vec<_>>();
+
+ 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<MultiWorkspace>,
+ cx: &App,
+) -> Vec<Entity<AgentConnectionStore>> {
+ 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::<AgentPanel>(cx) {
+ stores.push(panel.read(cx).connection_store().clone());
+ }
+ }
+
+ stores
+}
+
+fn find_threads_to_import(
+ agent_ids: Vec<AgentId>,
+ existing_sessions: HashSet<acp::SessionId>,
+ stores: Vec<Entity<AgentConnectionStore>>,
+ cx: &mut App,
+) -> Task<anyhow::Result<Vec<ThreadMetadata>>> {
+ 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(<dyn Fs>::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<acp_thread::AgentSessionInfo>)>,
+ mut existing_sessions: HashSet<acp::SessionId>,
+) -> Vec<ThreadMetadata> {
+ 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<PathList>,
+ updated_at: Option<chrono::DateTime<Utc>>,
+ created_at: Option<chrono::DateTime<Utc>>,
+ ) -> 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());
+ }
+}
@@ -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::<AgentV2FeatureFlag>() {
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::<HashSet<_>>();
- 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::<Vec<_>>()
});
- 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<SidebarThreadMetadataStore>);
+struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
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<AgentId>,
+ pub agent_id: AgentId,
pub title: SharedString,
pub updated_at: DateTime<Utc>,
pub created_at: Option<DateTime<Utc>>,
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<acp_thread::AcpThread>, cx: &App) -> Self {
+ pub fn from_thread(
+ is_archived: bool,
+ thread: &Entity<acp_thread::AcpThread>,
+ 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<Arc<Path>> = 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<ThreadMetadata>,
+ threads: HashMap<acp::SessionId, ThreadMetadata>,
threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>>,
reload_task: Option<Shared<Task<()>>>,
session_subscriptions: HashMap<acp::SessionId, Subscription>,
@@ -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::<Self>() {
@@ -237,14 +204,22 @@ impl SidebarThreadMetadataStore {
self.threads.is_empty()
}
+ /// Returns all thread IDs.
+ pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
+ self.threads.keys().cloned()
+ }
+
+ /// Returns all threads.
pub fn entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
- self.threads.iter().cloned()
+ self.threads.values().cloned()
}
- pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
- self.threads.iter().map(|thread| thread.session_id.clone())
+ /// Returns all archived threads.
+ pub fn archived_entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
+ 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<ThreadMetadata>, cx: &mut Context<Self>) {
+ if !cx.has_flag::<AgentV2FeatureFlag>() {
+ 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<Self>) {
if !cx.has_flag::<AgentV2FeatureFlag>() {
return;
@@ -300,6 +288,36 @@ impl SidebarThreadMetadataStore {
.log_err();
}
+ pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+ self.update_archived(session_id, true, cx);
+ }
+
+ pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+ self.update_archived(session_id, false, cx);
+ }
+
+ fn update_archived(
+ &mut self,
+ session_id: &acp::SessionId,
+ archived: bool,
+ cx: &mut Context<Self>,
+ ) {
+ if !cx.has_flag::<AgentV2FeatureFlag>() {
+ 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<Self>) {
if !cx.has_flag::<AgentV2FeatureFlag>() {
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<bool> {
- self.select::<i64>("SELECT COUNT(*) FROM sidebar_threads")?()
- .map(|counts| counts.into_iter().next().unwrap_or_default() == 0)
+ pub fn list_ids(&self) -> anyhow::Result<Vec<Arc<str>>> {
+ self.select::<Arc<str>>(
+ "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<Vec<ThreadMetadata>> {
self.select::<ThreadMetadata>(
- "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<String>, i32) = Column::column(statement, next)?;
let (folder_paths_order_str, next): (Option<String>, 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::<Vec<_>>();
- 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::<Vec<_>>();
- 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::<Vec<_>>();
- 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::<Vec<_>>()
+ 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::<Vec<_>>()
});
- 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::<Vec<_>>();
- assert_eq!(project_a_entries.len(), 10);
- assert_eq!(
- project_a_entries
- .iter()
- .map(|metadata| metadata.session_id.0.as_ref())
- .collect::<Vec<_>>(),
- 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::<Vec<_>>();
- assert_eq!(project_b_entries.len(), 3);
- assert_eq!(
- project_b_entries
- .iter()
- .map(|metadata| metadata.session_id.0.as_ref())
- .collect::<Vec<_>>(),
- 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::<Vec<_>>()
});
+
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::<Vec<_>>()
@@ -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::<Vec<_>>()
@@ -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::<Vec<_>>()
@@ -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::<Vec<_>>()
@@ -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::<Vec<_>>()
});
@@ -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::<Vec<_>>();
+ assert_eq!(path_entries, vec!["session-1"]);
+
+ let archived = store
+ .archived_entries()
+ .map(|e| e.session_id.0.to_string())
+ .collect::<Vec<_>>();
+ 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::<Vec<_>>();
+ assert!(path_entries.is_empty());
+
+ let archived = store.archived_entries().collect::<Vec<_>>();
+ 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::<Vec<_>>();
+ assert_eq!(path_entries, vec!["session-1"]);
+
+ let archived = store
+ .archived_entries()
+ .map(|e| e.session_id.0.to_string())
+ .collect::<Vec<_>>();
+ 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::<Vec<_>>();
+ assert_eq!(path_entries, vec!["session-1"]);
+
+ let all_entries = store
+ .entries()
+ .map(|e| e.session_id.0.to_string())
+ .collect::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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::<Vec<_>>();
+ assert!(path_entries.is_empty());
+
+ let archived = store
+ .archived_entries()
+ .map(|e| e.session_id.0.to_string())
+ .collect::<Vec<_>>();
+ 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);
+ });
+ }
}
@@ -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<usize>,
},
}
@@ -95,40 +94,15 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
}
}
-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<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
pub struct ThreadsArchiveView {
- agent_connection_store: Entity<AgentConnectionStore>,
- agent_server_store: Entity<AgentServerStore>,
- thread_store: Entity<ThreadStore>,
- fs: Arc<dyn Fs>,
- history: Option<Entity<ThreadHistory>>,
_history_subscription: Subscription,
- selected_agent: Agent,
focus_handle: FocusHandle,
list_state: ListState,
items: Vec<ArchiveListItem>,
@@ -136,17 +110,21 @@ pub struct ThreadsArchiveView {
hovered_index: Option<usize>,
filter_editor: Entity<Editor>,
_subscriptions: Vec<gpui::Subscription>,
- selected_agent_menu: PopoverMenuHandle<ContextMenu>,
_refresh_history_task: Task<()>,
- is_loading: bool,
+ agent_connection_store: WeakEntity<AgentConnectionStore>,
+ agent_server_store: WeakEntity<AgentServerStore>,
+ agent_registry_store: WeakEntity<AgentRegistryStore>,
+ workspace: WeakEntity<Workspace>,
+ multi_workspace: WeakEntity<MultiWorkspace>,
}
impl ThreadsArchiveView {
pub fn new(
- agent_connection_store: Entity<AgentConnectionStore>,
- agent_server_store: Entity<AgentServerStore>,
- thread_store: Entity<ThreadStore>,
- fs: Arc<dyn Fs>,
+ agent_connection_store: WeakEntity<AgentConnectionStore>,
+ agent_server_store: WeakEntity<AgentServerStore>,
+ agent_registry_store: WeakEntity<AgentRegistryStore>,
+ workspace: WeakEntity<Workspace>,
+ multi_workspace: WeakEntity<MultiWorkspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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>) {
- 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<Entity<ThreadHistory>>, cx: &mut Context<Self>) {
- 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<Self>) {
- 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::<Vec<_>>();
+
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>,
) {
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<Self>) {
- 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<Self>,
- ) {
- 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<Self>) {
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<Self>) -> PopoverMenu<ContextMenu> {
- let agent_server_store = self.agent_server_store.clone();
+ fn delete_thread(
+ &mut self,
+ session_id: acp::SessionId,
+ agent: AgentId,
+ cx: &mut Context<Self>,
+ ) {
+ 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 = <dyn 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<Self>) {
+ 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::<Vec<_>>();
-
- 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<Self>) -> 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<Self>) -> 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.")
- );
- }
-}
@@ -307,9 +307,9 @@ impl AgentServerStore {
cx.emit(AgentServersUpdated);
}
- pub fn agent_icon(&self, name: &AgentId) -> Option<SharedString> {
+ pub fn agent_icon(&self, id: &AgentId) -> Option<SharedString> {
self.external_agents
- .get(name)
+ .get(id)
.and_then(|entry| entry.icon.clone())
}
@@ -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::<AgentV2FeatureFlag, _>(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<SharedString>) {
- 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<SharedString>) {
+ 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<acp::SessionId> = 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<Self>,
) {
- // 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<Self>,
) {
+ 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::<AgentPanel>(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);
}
},
);
@@ -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<acp::SessionId> = 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);
});
@@ -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<usize>,
removed: Option<usize>,
+ project_paths: Option<Arc<[PathBuf]>>,
worktrees: Vec<ThreadItemWorktreeInfo>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
@@ -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<ThreadItemWorktreeInfo>) -> 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)
@@ -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()