agent: Add ability to import threads from other channels (#54002)

Eric Holk and Danilo Leal created

Users have been testing the threads sidebar on Nightly and Preview but
once we release the feature we expect many of these users to move back
to Stable. As it stands right now, they would lose the threads they'd
been working on. This PR adds a way to import these threads.

- [x] Add `agent: import threads from other channels` action to the
command palette.
- This can be always available for users that switch between channels
regularly.
- [x] Add tests for import behavior
- [x] Add a button that disappears after using it to make importing more
discoverable for when users first switch.

Release Notes:

- Added an action to the command palette that imports threads from other
Zed release channels into the current one.

---------

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         | 351 ++++++++++++++++++++++
crates/agent_ui/src/thread_metadata_store.rs |  57 ++-
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, 615 insertions(+), 97 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,208 @@ 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();
+    }
+
+    #[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();
+
+        // Set up a "preview" database with two threads.
+        let preview_db = create_channel_db(dir.path(), ReleaseChannel::Preview);
+        insert_thread(
+            &preview_db,
+            "Preview Thread 1",
+            "2025-01-15T10:00:00Z",
+            false,
+        );
+        insert_thread(
+            &preview_db,
+            "Preview Thread 2",
+            "2025-01-15T11:00:00Z",
+            true,
+        );
+        drop(preview_db);
+
+        // Set up a "nightly" database with one thread.
+        let nightly_db = create_channel_db(dir.path(), ReleaseChannel::Nightly);
+        insert_thread(&nightly_db, "Nightly Thread", "2025-01-15T12:00:00Z", false);
+        drop(nightly_db);
+
+        // 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("Preview Thread 1"));
+            assert!(titles.contains("Preview Thread 2"));
+            assert!(titles.contains("Nightly Thread"));
+
+            // Verify archived state is preserved.
+            let preview_2 = store
+                .entries()
+                .find(|m| m.display_title().as_ref() == "Preview Thread 2")
+                .unwrap();
+            assert!(preview_2.archived);
+
+            let nightly = store
+                .entries()
+                .find(|m| m.display_title().as_ref() == "Nightly Thread")
+                .unwrap();
+            assert!(!nightly.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();
+
+        // Set up a "preview" database with threads.
+        let preview_db = create_channel_db(dir.path(), ReleaseChannel::Preview);
+        insert_thread(&preview_db, "Thread A", "2025-01-15T10:00:00Z", false);
+        insert_thread(&preview_db, "Thread B", "2025-01-15T11:00:00Z", false);
+        drop(preview_db);
+
+        // Read the threads so we can pre-populate one into the store.
+        let preview_threads =
+            read_threads_from_channel(dir.path(), ReleaseChannel::Preview).unwrap();
+        let thread_a = preview_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);
@@ -1280,14 +1305,18 @@ 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 \
+        WHERE session_id IS NOT NULL \
+        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 \
-             WHERE session_id IS NOT NULL \
-             ORDER BY updated_at DESC"
-        )?()
+        self.select::<ThreadMetadata>(Self::LIST_QUERY)?()
     }
 
     /// Upsert metadata for a thread.
@@ -1701,7 +1730,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);
@@ -1980,7 +2009,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);
@@ -2071,7 +2100,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);
@@ -2209,7 +2238,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);
@@ -3390,13 +3419,7 @@ mod tests {
 
         // Run all current migrations. sqlez skips the already-applied ones and
         // runs the remaining migrations.
-        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;
@@ -370,6 +371,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 {
@@ -458,6 +465,7 @@ impl Sidebar {
             recent_projects_popover_handle: PopoverMenuHandle::default(),
             project_header_menu_ix: None,
             _subscriptions: Vec::new(),
+            import_banners_use_verbose_labels: None,
         }
     }
 
@@ -4278,51 +4286,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>) {
@@ -4403,6 +4432,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
@@ -4566,8 +4655,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))
     }