Cherry-pick: agent: Add ability to import threads from other channels (#54021)

Eric Holk and Danilo Leal created

Cherry-pick of #54002 to the v0.233.x preview branch.

Conflict resolution notes:
- Removed `WHERE session_id IS NOT NULL` from `LIST_QUERY` since the
draft threads cleanup PR (#54014) is not on this branch yet.
- Updated tests to use foreign channels so they pass on all of Dev,
Nightly, Preview, and Stable. See
https://github.com/zed-industries/zed/pull/54022 for cherry picking back
to main.

Release Notes:

- N/A

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

crates/agent_ui/src/agent_ui.rs              |  18 +
crates/agent_ui/src/thread_import.rs         | 355 ++++++++++++++++++++++
crates/agent_ui/src/thread_metadata_store.rs |  55 ++
crates/db/src/db.rs                          |  80 +++-
crates/db/src/kvp.rs                         |   3 
crates/release_channel/src/lib.rs            |   8 
crates/sidebar/src/sidebar.rs                | 195 +++++++++--
7 files changed, 618 insertions(+), 96 deletions(-)

Detailed changes

crates/agent_ui/src/agent_ui.rs 🔗

@@ -79,7 +79,10 @@ pub(crate) use model_selector::ModelSelector;
 pub(crate) use model_selector_popover::ModelSelectorPopover;
 pub(crate) use thread_history::ThreadHistory;
 pub(crate) use thread_history_view::*;
-pub use thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
+pub use thread_import::{
+    AcpThreadImportOnboarding, CrossChannelImportOnboarding, ThreadImportModal,
+    channels_with_threads, import_threads_from_other_channels,
+};
 use zed_actions;
 
 pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
@@ -195,6 +198,8 @@ actions!(
         ScrollOutputToPreviousMessage,
         /// Scroll the output to the next user message.
         ScrollOutputToNextMessage,
+        /// Import agent threads from other Zed release channels (e.g. Preview, Nightly).
+        ImportThreadsFromOtherChannels,
     ]
 );
 
@@ -511,6 +516,17 @@ pub fn init(
     })
     .detach();
     cx.observe_new(ManageProfilesModal::register).detach();
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace.register_action(
+            |workspace: &mut Workspace,
+             _: &ImportThreadsFromOtherChannels,
+             _window: &mut Window,
+             cx: &mut Context<Workspace>| {
+                import_threads_from_other_channels(workspace, cx);
+            },
+        );
+    })
+    .detach();
 
     // Update command palette filter based on AI settings
     update_command_palette_filter(cx);

crates/agent_ui/src/thread_import.rs 🔗

@@ -4,6 +4,7 @@ use agent_client_protocol as acp;
 use chrono::Utc;
 use collections::HashSet;
 use db::kvp::Dismissable;
+use db::sqlez;
 use fs::Fs;
 use futures::FutureExt as _;
 use gpui::{
@@ -12,6 +13,7 @@ use gpui::{
 };
 use notifications::status_toast::{StatusToast, ToastIcon};
 use project::{AgentId, AgentRegistryStore, AgentServerStore};
+use release_channel::ReleaseChannel;
 use remote::RemoteConnectionOptions;
 use ui::{
     Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section,
@@ -27,6 +29,7 @@ use crate::{
 };
 
 pub struct AcpThreadImportOnboarding;
+pub struct CrossChannelImportOnboarding;
 
 impl AcpThreadImportOnboarding {
     pub fn dismissed(cx: &App) -> bool {
@@ -42,6 +45,40 @@ impl Dismissable for AcpThreadImportOnboarding {
     const KEY: &'static str = "dismissed-acp-thread-import";
 }
 
+impl CrossChannelImportOnboarding {
+    pub fn dismissed(cx: &App) -> bool {
+        <Self as Dismissable>::dismissed(cx)
+    }
+
+    pub fn dismiss(cx: &mut App) {
+        <Self as Dismissable>::set_dismissed(true, cx);
+    }
+}
+
+impl Dismissable for CrossChannelImportOnboarding {
+    const KEY: &'static str = "dismissed-cross-channel-thread-import";
+}
+
+/// Returns the list of non-Dev, non-current release channels that have
+/// at least one thread in their database.  The result is suitable for
+/// building a user-facing message ("from Zed Preview and Nightly").
+pub fn channels_with_threads(cx: &App) -> Vec<ReleaseChannel> {
+    let Some(current_channel) = ReleaseChannel::try_global(cx) else {
+        return Vec::new();
+    };
+    let database_dir = paths::database_dir();
+
+    ReleaseChannel::ALL
+        .iter()
+        .copied()
+        .filter(|channel| {
+            *channel != current_channel
+                && *channel != ReleaseChannel::Dev
+                && channel_has_threads(database_dir, *channel)
+        })
+        .collect()
+}
+
 #[derive(Clone)]
 struct AgentEntry {
     agent_id: AgentId,
@@ -536,11 +573,121 @@ fn collect_importable_threads(
     to_insert
 }
 
+pub fn import_threads_from_other_channels(_workspace: &mut Workspace, cx: &mut Context<Workspace>) {
+    let database_dir = paths::database_dir().clone();
+    import_threads_from_other_channels_in(database_dir, cx);
+}
+
+fn import_threads_from_other_channels_in(
+    database_dir: std::path::PathBuf,
+    cx: &mut Context<Workspace>,
+) {
+    let current_channel = ReleaseChannel::global(cx);
+
+    let existing_thread_ids: HashSet<ThreadId> = ThreadMetadataStore::global(cx)
+        .read(cx)
+        .entries()
+        .map(|metadata| metadata.thread_id)
+        .collect();
+
+    let workspace_handle = cx.weak_entity();
+    cx.spawn(async move |_this, cx| {
+        let mut imported_threads = Vec::new();
+
+        for channel in &ReleaseChannel::ALL {
+            if *channel == current_channel || *channel == ReleaseChannel::Dev {
+                continue;
+            }
+
+            match read_threads_from_channel(&database_dir, *channel) {
+                Ok(threads) => {
+                    let new_threads = threads
+                        .into_iter()
+                        .filter(|thread| !existing_thread_ids.contains(&thread.thread_id));
+                    imported_threads.extend(new_threads);
+                }
+                Err(error) => {
+                    log::warn!(
+                        "Failed to read threads from {} channel database: {}",
+                        channel.dev_name(),
+                        error
+                    );
+                }
+            }
+        }
+
+        let imported_count = imported_threads.len();
+
+        cx.update(|cx| {
+            ThreadMetadataStore::global(cx)
+                .update(cx, |store, cx| store.save_all(imported_threads, cx));
+
+            show_cross_channel_import_toast(&workspace_handle, imported_count, cx);
+        })
+    })
+    .detach();
+}
+
+fn channel_has_threads(database_dir: &std::path::Path, channel: ReleaseChannel) -> bool {
+    let db_path = db::db_path(database_dir, channel);
+    if !db_path.exists() {
+        return false;
+    }
+    let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
+    connection
+        .select_row::<bool>("SELECT 1 FROM sidebar_threads LIMIT 1")
+        .ok()
+        .and_then(|mut query| query().ok().flatten())
+        .unwrap_or(false)
+}
+
+fn read_threads_from_channel(
+    database_dir: &std::path::Path,
+    channel: ReleaseChannel,
+) -> anyhow::Result<Vec<ThreadMetadata>> {
+    let db_path = db::db_path(database_dir, channel);
+    if !db_path.exists() {
+        return Ok(Vec::new());
+    }
+    let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
+    crate::thread_metadata_store::list_thread_metadata_from_connection(&connection)
+}
+
+fn show_cross_channel_import_toast(
+    workspace: &WeakEntity<Workspace>,
+    imported_count: usize,
+    cx: &mut App,
+) {
+    let status_toast = if imported_count == 0 {
+        StatusToast::new("No new threads found to import.", cx, |this, _cx| {
+            this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
+                .dismiss_button(true)
+        })
+    } else {
+        let message = if imported_count == 1 {
+            "Imported 1 thread from other channels.".to_string()
+        } else {
+            format!("Imported {imported_count} threads from other channels.")
+        };
+        StatusToast::new(message, cx, |this, _cx| {
+            this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+                .dismiss_button(true)
+        })
+    };
+
+    workspace
+        .update(cx, |workspace, cx| {
+            workspace.toggle_status_toast(status_toast, cx);
+        })
+        .log_err();
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
     use acp_thread::AgentSessionInfo;
     use chrono::Utc;
+    use gpui::TestAppContext;
     use std::path::Path;
     use workspace::PathList;
 
@@ -732,4 +879,212 @@ mod tests {
         let result = collect_importable_threads(sessions_by_agent, existing);
         assert!(result.is_empty());
     }
+
+    fn create_channel_db(
+        db_dir: &std::path::Path,
+        channel: ReleaseChannel,
+    ) -> db::sqlez::connection::Connection {
+        let db_path = db::db_path(db_dir, channel);
+        std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
+        let connection = db::sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
+        crate::thread_metadata_store::run_thread_metadata_migrations(&connection);
+        connection
+    }
+
+    fn insert_thread(
+        connection: &db::sqlez::connection::Connection,
+        title: &str,
+        updated_at: &str,
+        archived: bool,
+    ) {
+        let thread_id = uuid::Uuid::new_v4();
+        let session_id = uuid::Uuid::new_v4().to_string();
+        connection
+            .exec_bound::<(uuid::Uuid, &str, &str, &str, bool)>(
+                "INSERT INTO sidebar_threads \
+                 (thread_id, session_id, title, updated_at, archived) \
+                 VALUES (?1, ?2, ?3, ?4, ?5)",
+            )
+            .unwrap()((thread_id, session_id.as_str(), title, updated_at, archived))
+        .unwrap();
+    }
+
+    #[test]
+    fn test_returns_empty_when_channel_db_missing() {
+        let dir = tempfile::tempdir().unwrap();
+        let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
+        assert!(threads.is_empty());
+    }
+
+    #[test]
+    fn test_preserves_archived_state() {
+        let dir = tempfile::tempdir().unwrap();
+        let connection = create_channel_db(dir.path(), ReleaseChannel::Nightly);
+
+        insert_thread(&connection, "Active Thread", "2025-01-15T10:00:00Z", false);
+        insert_thread(&connection, "Archived Thread", "2025-01-15T09:00:00Z", true);
+        drop(connection);
+
+        let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
+        assert_eq!(threads.len(), 2);
+
+        let active = threads
+            .iter()
+            .find(|t| t.display_title().as_ref() == "Active Thread")
+            .unwrap();
+        assert!(!active.archived);
+
+        let archived = threads
+            .iter()
+            .find(|t| t.display_title().as_ref() == "Archived Thread")
+            .unwrap();
+        assert!(archived.archived);
+    }
+
+    fn init_test(cx: &mut TestAppContext) {
+        let fs = fs::FakeFs::new(cx.executor());
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            theme_settings::init(theme::LoadThemes::JustBase, cx);
+            release_channel::init("0.0.0".parse().unwrap(), cx);
+            <dyn fs::Fs>::set_global(fs, cx);
+            ThreadMetadataStore::init_global(cx);
+        });
+        cx.run_until_parked();
+    }
+
+    /// Returns two release channels that are not the current one and not Dev.
+    /// This ensures tests work regardless of which release channel branch
+    /// they run on.
+    fn foreign_channels(cx: &TestAppContext) -> (ReleaseChannel, ReleaseChannel) {
+        let current = cx.update(|cx| ReleaseChannel::global(cx));
+        let mut channels = ReleaseChannel::ALL
+            .iter()
+            .copied()
+            .filter(|ch| *ch != current && *ch != ReleaseChannel::Dev);
+        (channels.next().unwrap(), channels.next().unwrap())
+    }
+
+    #[gpui::test]
+    async fn test_import_threads_from_other_channels(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let dir = tempfile::tempdir().unwrap();
+        let database_dir = dir.path().to_path_buf();
+
+        let (channel_a, channel_b) = foreign_channels(cx);
+
+        // Set up databases for two foreign channels.
+        let db_a = create_channel_db(dir.path(), channel_a);
+        insert_thread(&db_a, "Thread A1", "2025-01-15T10:00:00Z", false);
+        insert_thread(&db_a, "Thread A2", "2025-01-15T11:00:00Z", true);
+        drop(db_a);
+
+        let db_b = create_channel_db(dir.path(), channel_b);
+        insert_thread(&db_b, "Thread B1", "2025-01-15T12:00:00Z", false);
+        drop(db_b);
+
+        // Create a workspace and run the import.
+        let fs = fs::FakeFs::new(cx.executor());
+        let project = project::Project::test(fs, [], cx).await;
+        let multi_workspace =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let workspace_entity = multi_workspace
+            .read_with(cx, |mw, _cx| mw.workspace().clone())
+            .unwrap();
+        let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
+
+        workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
+            import_threads_from_other_channels_in(database_dir, cx);
+        });
+        cx.run_until_parked();
+
+        // Verify all three threads were imported into the store.
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+            let titles: collections::HashSet<String> = store
+                .entries()
+                .map(|m| m.display_title().to_string())
+                .collect();
+
+            assert_eq!(titles.len(), 3);
+            assert!(titles.contains("Thread A1"));
+            assert!(titles.contains("Thread A2"));
+            assert!(titles.contains("Thread B1"));
+
+            // Verify archived state is preserved.
+            let thread_a2 = store
+                .entries()
+                .find(|m| m.display_title().as_ref() == "Thread A2")
+                .unwrap();
+            assert!(thread_a2.archived);
+
+            let thread_b1 = store
+                .entries()
+                .find(|m| m.display_title().as_ref() == "Thread B1")
+                .unwrap();
+            assert!(!thread_b1.archived);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_import_skips_already_existing_threads(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let dir = tempfile::tempdir().unwrap();
+        let database_dir = dir.path().to_path_buf();
+
+        let (channel_a, _) = foreign_channels(cx);
+
+        // Set up a database for a foreign channel.
+        let db_a = create_channel_db(dir.path(), channel_a);
+        insert_thread(&db_a, "Thread A", "2025-01-15T10:00:00Z", false);
+        insert_thread(&db_a, "Thread B", "2025-01-15T11:00:00Z", false);
+        drop(db_a);
+
+        // Read the threads so we can pre-populate one into the store.
+        let foreign_threads = read_threads_from_channel(dir.path(), channel_a).unwrap();
+        let thread_a = foreign_threads
+            .iter()
+            .find(|t| t.display_title().as_ref() == "Thread A")
+            .unwrap()
+            .clone();
+
+        // Pre-populate Thread A into the store.
+        cx.update(|cx| {
+            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(thread_a, cx));
+        });
+        cx.run_until_parked();
+
+        // Run the import.
+        let fs = fs::FakeFs::new(cx.executor());
+        let project = project::Project::test(fs, [], cx).await;
+        let multi_workspace =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let workspace_entity = multi_workspace
+            .read_with(cx, |mw, _cx| mw.workspace().clone())
+            .unwrap();
+        let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
+
+        workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
+            import_threads_from_other_channels_in(database_dir, cx);
+        });
+        cx.run_until_parked();
+
+        // Verify only Thread B was added (Thread A already existed).
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+            assert_eq!(store.entries().count(), 2);
+
+            let titles: collections::HashSet<String> = store
+                .entries()
+                .map(|m| m.display_title().to_string())
+                .collect();
+            assert!(titles.contains("Thread A"));
+            assert!(titles.contains("Thread B"));
+        });
+    }
 }

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -55,6 +55,31 @@ impl Column for ThreadId {
 const THREAD_REMOTE_CONNECTION_MIGRATION_KEY: &str = "thread-metadata-remote-connection-backfill";
 const THREAD_ID_MIGRATION_KEY: &str = "thread-metadata-thread-id-backfill";
 
+/// List all sidebar thread metadata from an arbitrary SQLite connection.
+///
+/// This is used to read thread metadata from another release channel's
+/// database without opening a full `ThreadSafeConnection`.
+pub(crate) fn list_thread_metadata_from_connection(
+    connection: &db::sqlez::connection::Connection,
+) -> anyhow::Result<Vec<ThreadMetadata>> {
+    connection.select::<ThreadMetadata>(ThreadMetadataDb::LIST_QUERY)?()
+}
+
+/// Run the `ThreadMetadataDb` migrations on a raw connection.
+///
+/// This is used in tests to set up the sidebar_threads schema in a
+/// temporary database.
+#[cfg(test)]
+pub(crate) fn run_thread_metadata_migrations(connection: &db::sqlez::connection::Connection) {
+    connection
+        .migrate(
+            ThreadMetadataDb::NAME,
+            ThreadMetadataDb::MIGRATIONS,
+            &mut |_, _, _| false,
+        )
+        .expect("thread metadata migrations should succeed");
+}
+
 pub fn init(cx: &mut App) {
     ThreadMetadataStore::init_global(cx);
     let migration_task = migrate_thread_metadata(cx);
@@ -1268,13 +1293,17 @@ impl ThreadMetadataDb {
         )?()
     }
 
+    const LIST_QUERY: &str = "SELECT thread_id, session_id, agent_id, title, updated_at, \
+        created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, \
+        main_worktree_paths_order, remote_connection \
+        FROM sidebar_threads \
+        ORDER BY updated_at DESC";
+
     /// List all sidebar thread metadata, ordered by updated_at descending.
+    ///
+    /// Only returns threads that have a `session_id`.
     pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
-        self.select::<ThreadMetadata>(
-            "SELECT thread_id, session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived, main_worktree_paths, main_worktree_paths_order, remote_connection \
-             FROM sidebar_threads \
-             ORDER BY updated_at DESC"
-        )?()
+        self.select::<ThreadMetadata>(Self::LIST_QUERY)?()
     }
 
     /// Upsert metadata for a thread.
@@ -1683,7 +1712,7 @@ mod tests {
             .unwrap();
     }
 
-    fn run_thread_metadata_migrations(cx: &mut TestAppContext) {
+    fn run_store_migrations(cx: &mut TestAppContext) {
         clear_thread_metadata_remote_connection_backfill(cx);
         cx.update(|cx| {
             let migration_task = migrate_thread_metadata(cx);
@@ -1962,7 +1991,7 @@ mod tests {
             cx.run_until_parked();
         }
 
-        run_thread_metadata_migrations(cx);
+        run_store_migrations(cx);
 
         let list = cx.update(|cx| {
             let store = ThreadMetadataStore::global(cx);
@@ -2053,7 +2082,7 @@ mod tests {
         save_task.await.unwrap();
         cx.run_until_parked();
 
-        run_thread_metadata_migrations(cx);
+        run_store_migrations(cx);
 
         let list = cx.update(|cx| {
             let store = ThreadMetadataStore::global(cx);
@@ -2191,7 +2220,7 @@ mod tests {
             cx.run_until_parked();
         }
 
-        run_thread_metadata_migrations(cx);
+        run_store_migrations(cx);
 
         let list = cx.update(|cx| {
             let store = ThreadMetadataStore::global(cx);
@@ -3371,13 +3400,7 @@ mod tests {
         .unwrap();
 
         // Run all migrations (0-7). sqlez skips 0-6 and runs only migration 7.
-        connection
-            .migrate(
-                ThreadMetadataDb::NAME,
-                ThreadMetadataDb::MIGRATIONS,
-                &mut |_, _, _| false,
-            )
-            .expect("new migration should succeed");
+        run_thread_metadata_migrations(&connection);
 
         // All 3 rows should survive with non-NULL thread_ids.
         let count: i64 = connection

crates/db/src/db.rs 🔗

@@ -15,11 +15,12 @@ pub use sqlez_macros;
 pub use uuid;
 
 pub use release_channel::RELEASE_CHANNEL;
+use release_channel::ReleaseChannel;
 use sqlez::domain::Migrator;
 use sqlez::thread_safe_connection::ThreadSafeConnection;
 use sqlez_macros::sql;
 use std::future::Future;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 use std::sync::atomic::AtomicBool;
 use std::sync::{LazyLock, atomic::Ordering};
 use util::{ResultExt, maybe};
@@ -61,8 +62,7 @@ impl AppDatabase {
     /// migrations in dependency order.
     pub fn new() -> Self {
         let db_dir = database_dir();
-        let scope = RELEASE_CHANNEL.dev_name();
-        let connection = smol::block_on(open_db::<AppMigrator>(db_dir, scope));
+        let connection = smol::block_on(open_db::<AppMigrator>(db_dir, *RELEASE_CHANNEL));
         Self(connection)
     }
 
@@ -139,23 +139,55 @@ const DB_FILE_NAME: &str = "db.sqlite";
 
 pub static ALL_FILE_DB_FAILED: LazyLock<AtomicBool> = LazyLock::new(|| AtomicBool::new(false));
 
+/// A type that can be used as a database scope for path construction.
+pub trait DbScope {
+    fn scope_name(&self) -> &str;
+}
+
+impl DbScope for ReleaseChannel {
+    fn scope_name(&self) -> &str {
+        self.dev_name()
+    }
+}
+
+/// A database scope shared across all release channels.
+pub struct GlobalDbScope;
+
+impl DbScope for GlobalDbScope {
+    fn scope_name(&self) -> &str {
+        "global"
+    }
+}
+
+/// Returns the path to the `AppDatabase` SQLite file for the given scope
+/// under `db_dir`.
+pub fn db_path(db_dir: &Path, scope: impl DbScope) -> PathBuf {
+    db_dir
+        .join(format!("0-{}", scope.scope_name()))
+        .join(DB_FILE_NAME)
+}
+
 /// Open or create a database at the given directory path.
 /// This will retry a couple times if there are failures. If opening fails once, the db directory
 /// is moved to a backup folder and a new one is created. If that fails, a shared in memory db is created.
 /// In either case, static variables are set so that the user can be notified.
-pub async fn open_db<M: Migrator + 'static>(db_dir: &Path, scope: &str) -> ThreadSafeConnection {
+pub async fn open_db<M: Migrator + 'static>(
+    db_dir: &Path,
+    scope: impl DbScope,
+) -> ThreadSafeConnection {
     if *ZED_STATELESS {
         return open_fallback_db::<M>().await;
     }
 
-    let main_db_dir = db_dir.join(format!("0-{}", scope));
+    let db_path = db_path(db_dir, scope);
 
     let connection = maybe!(async {
-        smol::fs::create_dir_all(&main_db_dir)
-            .await
-            .context("Could not create db directory")
-            .log_err()?;
-        let db_path = main_db_dir.join(Path::new(DB_FILE_NAME));
+        if let Some(parent) = db_path.parent() {
+            smol::fs::create_dir_all(parent)
+                .await
+                .context("Could not create db directory")
+                .log_err()?;
+        }
         open_main_db::<M>(&db_path).await
     })
     .await;
@@ -289,11 +321,7 @@ mod tests {
             .prefix("DbTests")
             .tempdir()
             .unwrap();
-        let _bad_db = open_db::<BadDB>(
-            tempdir.path(),
-            release_channel::ReleaseChannel::Dev.dev_name(),
-        )
-        .await;
+        let _bad_db = open_db::<BadDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
     }
 
     /// Test that DB exists but corrupted (causing recreate)
@@ -320,19 +348,12 @@ mod tests {
             .tempdir()
             .unwrap();
         {
-            let corrupt_db = open_db::<CorruptedDB>(
-                tempdir.path(),
-                release_channel::ReleaseChannel::Dev.dev_name(),
-            )
-            .await;
+            let corrupt_db =
+                open_db::<CorruptedDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
             assert!(corrupt_db.persistent());
         }
 
-        let good_db = open_db::<GoodDB>(
-            tempdir.path(),
-            release_channel::ReleaseChannel::Dev.dev_name(),
-        )
-        .await;
+        let good_db = open_db::<GoodDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
         assert!(
             good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()
                 .unwrap()
@@ -366,11 +387,8 @@ mod tests {
             .unwrap();
         {
             // Setup the bad database
-            let corrupt_db = open_db::<CorruptedDB>(
-                tempdir.path(),
-                release_channel::ReleaseChannel::Dev.dev_name(),
-            )
-            .await;
+            let corrupt_db =
+                open_db::<CorruptedDB>(tempdir.path(), release_channel::ReleaseChannel::Dev).await;
             assert!(corrupt_db.persistent());
         }
 
@@ -381,7 +399,7 @@ mod tests {
             let guard = thread::spawn(move || {
                 let good_db = smol::block_on(open_db::<GoodDB>(
                     tmp_path.as_path(),
-                    release_channel::ReleaseChannel::Dev.dev_name(),
+                    release_channel::ReleaseChannel::Dev,
                 ));
                 assert!(
                     good_db.select_row::<usize>("SELECT * FROM test2").unwrap()()

crates/db/src/kvp.rs 🔗

@@ -244,7 +244,8 @@ static GLOBAL_KEY_VALUE_STORE: std::sync::LazyLock<GlobalKeyValueStore> =
     std::sync::LazyLock::new(|| {
         let db_dir = crate::database_dir();
         GlobalKeyValueStore(smol::block_on(crate::open_db::<GlobalKeyValueStore>(
-            db_dir, "global",
+            db_dir,
+            crate::GlobalDbScope,
         )))
     });
 

crates/release_channel/src/lib.rs 🔗

@@ -154,6 +154,14 @@ pub fn init_test(app_version: Version, release_channel: ReleaseChannel, cx: &mut
 }
 
 impl ReleaseChannel {
+    /// All release channels.
+    pub const ALL: [ReleaseChannel; 4] = [
+        ReleaseChannel::Dev,
+        ReleaseChannel::Nightly,
+        ReleaseChannel::Preview,
+        ReleaseChannel::Stable,
+    ];
+
     /// Returns the global [`ReleaseChannel`].
     pub fn global(cx: &App) -> Self {
         cx.global::<GlobalReleaseChannel>().0

crates/sidebar/src/sidebar.rs 🔗

@@ -12,8 +12,9 @@ use agent_ui::threads_archive_view::{
     ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
 };
 use agent_ui::{
-    AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread,
-    RemoveSelectedThread, ThreadId, ThreadImportModal,
+    AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, CrossChannelImportOnboarding,
+    DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, ThreadId, ThreadImportModal,
+    channels_with_threads, import_threads_from_other_channels,
 };
 use chrono::{DateTime, Utc};
 use editor::Editor;
@@ -376,6 +377,12 @@ pub struct Sidebar {
     recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
     project_header_menu_ix: Option<usize>,
     _subscriptions: Vec<gpui::Subscription>,
+    /// For the thread import banners, if there is just one we show "Import
+    /// Threads" but if we are showing both the external agents and other
+    /// channels import banners then we change the text to disambiguate the
+    /// buttons. This field tracks whether we were using verbose labels so they
+    /// can stay stable after dismissing one of the banners.
+    import_banners_use_verbose_labels: Option<bool>,
 }
 
 impl Sidebar {
@@ -464,6 +471,7 @@ impl Sidebar {
             recent_projects_popover_handle: PopoverMenuHandle::default(),
             project_header_menu_ix: None,
             _subscriptions: Vec::new(),
+            import_banners_use_verbose_labels: None,
         }
     }
 
@@ -4481,51 +4489,72 @@ impl Sidebar {
         has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
     }
 
-    fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
-        let description = "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.";
+    fn render_acp_import_onboarding(
+        &mut self,
+        verbose_labels: bool,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let on_import = cx.listener(|this, _, window, cx| {
+            this.show_archive(window, cx);
+            this.show_thread_import_modal(window, cx);
+        });
+        render_import_onboarding_banner(
+            "acp",
+            "Looking for threads from external agents?",
+            "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.",
+            if verbose_labels {
+                "Import Threads from External Agents"
+            } else {
+                "Import Threads"
+            },
+            |_, _window, cx| AcpThreadImportOnboarding::dismiss(cx),
+            on_import,
+            cx,
+        )
+    }
 
-        let bg = cx.theme().colors().text_accent;
+    fn should_render_cross_channel_import_onboarding(&self, cx: &App) -> bool {
+        !CrossChannelImportOnboarding::dismissed(cx) && !channels_with_threads(cx).is_empty()
+    }
 
-        v_flex()
-            .min_w_0()
-            .w_full()
-            .p_2()
-            .border_t_1()
-            .border_color(cx.theme().colors().border)
-            .bg(linear_gradient(
-                360.,
-                linear_color_stop(bg.opacity(0.06), 1.),
-                linear_color_stop(bg.opacity(0.), 0.),
-            ))
-            .child(
-                h_flex()
-                    .min_w_0()
-                    .w_full()
-                    .gap_1()
-                    .justify_between()
-                    .child(Label::new("Looking for threads from external agents?"))
-                    .child(
-                        IconButton::new("close-onboarding", IconName::Close)
-                            .icon_size(IconSize::Small)
-                            .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
-                    ),
-            )
-            .child(Label::new(description).color(Color::Muted).mb_2())
-            .child(
-                Button::new("import-acp", "Import Threads")
-                    .full_width()
-                    .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
-                    .label_size(LabelSize::Small)
-                    .start_icon(
-                        Icon::new(IconName::ThreadImport)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .on_click(cx.listener(|this, _, window, cx| {
-                        this.show_archive(window, cx);
-                        this.show_thread_import_modal(window, cx);
-                    })),
-            )
+    fn render_cross_channel_import_onboarding(
+        &mut self,
+        verbose_labels: bool,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let channels = channels_with_threads(cx);
+        let channel_names = channels
+            .iter()
+            .map(|channel| channel.display_name())
+            .collect::<Vec<_>>()
+            .join(" and ");
+
+        let description = format!(
+            "Import threads from {} to continue where you left off.",
+            channel_names
+        );
+
+        let on_import = cx.listener(|this, _, _window, cx| {
+            CrossChannelImportOnboarding::dismiss(cx);
+            if let Some(workspace) = this.active_workspace(cx) {
+                workspace.update(cx, |workspace, cx| {
+                    import_threads_from_other_channels(workspace, cx);
+                });
+            }
+        });
+        render_import_onboarding_banner(
+            "channel",
+            "Threads found from other channels",
+            description,
+            if verbose_labels {
+                "Import Threads from Other Channels"
+            } else {
+                "Import Threads"
+            },
+            |_, _window, cx| CrossChannelImportOnboarding::dismiss(cx),
+            on_import,
+            cx,
+        )
     }
 
     fn toggle_archive(&mut self, _: &ViewAllThreads, window: &mut Window, cx: &mut Context<Self>) {
@@ -4606,6 +4635,66 @@ impl Sidebar {
     }
 }
 
+fn render_import_onboarding_banner(
+    id: impl Into<SharedString>,
+    title: impl Into<SharedString>,
+    description: impl Into<SharedString>,
+    button_label: impl Into<SharedString>,
+    on_dismiss: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    on_import: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    cx: &App,
+) -> impl IntoElement {
+    let id: SharedString = id.into();
+    let bg = cx.theme().colors().text_accent;
+
+    v_flex()
+        .min_w_0()
+        .w_full()
+        .p_2()
+        .border_t_1()
+        .border_color(cx.theme().colors().border)
+        .bg(linear_gradient(
+            360.,
+            linear_color_stop(bg.opacity(0.06), 1.),
+            linear_color_stop(bg.opacity(0.), 0.),
+        ))
+        .child(
+            h_flex()
+                .min_w_0()
+                .w_full()
+                .gap_1()
+                .justify_between()
+                .flex_wrap()
+                .child(Label::new(title).size(LabelSize::Small))
+                .child(
+                    IconButton::new(
+                        SharedString::from(format!("close-{id}-onboarding")),
+                        IconName::Close,
+                    )
+                    .icon_size(IconSize::Small)
+                    .on_click(on_dismiss),
+                ),
+        )
+        .child(
+            Label::new(description)
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+                .mb_2(),
+        )
+        .child(
+            Button::new(SharedString::from(format!("import-{id}")), button_label)
+                .full_width()
+                .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
+                .label_size(LabelSize::Small)
+                .start_icon(
+                    Icon::new(IconName::ThreadImport)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .on_click(on_import),
+        )
+}
+
 impl WorkspaceSidebar for Sidebar {
     fn width(&self, _cx: &App) -> Pixels {
         self.width
@@ -4771,8 +4860,20 @@ impl Render for Sidebar {
                     }),
                 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
             })
-            .when(self.should_render_acp_import_onboarding(cx), |this| {
-                this.child(self.render_acp_import_onboarding(cx))
+            .map(|this| {
+                let show_acp = self.should_render_acp_import_onboarding(cx);
+                let show_cross_channel = self.should_render_cross_channel_import_onboarding(cx);
+
+                let verbose = *self
+                    .import_banners_use_verbose_labels
+                    .get_or_insert(show_acp && show_cross_channel);
+
+                this.when(show_acp, |this| {
+                    this.child(self.render_acp_import_onboarding(verbose, cx))
+                })
+                .when(show_cross_channel, |this| {
+                    this.child(self.render_cross_channel_import_onboarding(verbose, cx))
+                })
             })
             .child(self.render_sidebar_bottom_bar(cx))
     }