From a8decbea137e267928f2aae12d0b31d912e34645 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Wed, 15 Apr 2026 16:25:44 -0700 Subject: [PATCH] agent: Add ability to import threads from other channels (#54002) 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 --- 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(-) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 5d281a5071b561d3dc7f38d89263ce697807f5c0..b5b6a7dea69962c2604252913216dcfe72bb2e84 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/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| { + import_threads_from_other_channels(workspace, cx); + }, + ); + }) + .detach(); // Update command palette filter based on AI settings update_command_palette_filter(cx); diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index 4bbb1e48ed735b51e596e656412bda3523960398..b9a6e0b2505a18c3c32d161883fce5d90be99907 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/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 { + ::dismissed(cx) + } + + pub fn dismiss(cx: &mut App) { + ::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 { + 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) { + 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, +) { + let current_channel = ReleaseChannel::global(cx); + + let existing_thread_ids: HashSet = 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::("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> { + 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, + 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); + ::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 = 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 = store + .entries() + .map(|m| m.display_title().to_string()) + .collect(); + assert!(titles.contains("Thread A")); + assert!(titles.contains("Thread B")); + }); + } } diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 987dbf4ba754292056a2c3a1aec3d0b61fae7177..8501601012bcfeac76ea404c6120401339805348 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ 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> { + connection.select::(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> { - self.select::( - "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::(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 diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index 1ed6aa080757cf99dd90a685489bdf3dd6e94e0b..da418b26e8d3cc5a3271dfa0f88e54741aeed06e 100644 --- a/crates/db/src/db.rs +++ b/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::(db_dir, scope)); + let connection = smol::block_on(open_db::(db_dir, *RELEASE_CHANNEL)); Self(connection) } @@ -139,23 +139,55 @@ const DB_FILE_NAME: &str = "db.sqlite"; pub static ALL_FILE_DB_FAILED: LazyLock = 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(db_dir: &Path, scope: &str) -> ThreadSafeConnection { +pub async fn open_db( + db_dir: &Path, + scope: impl DbScope, +) -> ThreadSafeConnection { if *ZED_STATELESS { return open_fallback_db::().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::(&db_path).await }) .await; @@ -289,11 +321,7 @@ mod tests { .prefix("DbTests") .tempdir() .unwrap(); - let _bad_db = open_db::( - tempdir.path(), - release_channel::ReleaseChannel::Dev.dev_name(), - ) - .await; + let _bad_db = open_db::(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::( - tempdir.path(), - release_channel::ReleaseChannel::Dev.dev_name(), - ) - .await; + let corrupt_db = + open_db::(tempdir.path(), release_channel::ReleaseChannel::Dev).await; assert!(corrupt_db.persistent()); } - let good_db = open_db::( - tempdir.path(), - release_channel::ReleaseChannel::Dev.dev_name(), - ) - .await; + let good_db = open_db::(tempdir.path(), release_channel::ReleaseChannel::Dev).await; assert!( good_db.select_row::("SELECT * FROM test2").unwrap()() .unwrap() @@ -366,11 +387,8 @@ mod tests { .unwrap(); { // Setup the bad database - let corrupt_db = open_db::( - tempdir.path(), - release_channel::ReleaseChannel::Dev.dev_name(), - ) - .await; + let corrupt_db = + open_db::(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::( tmp_path.as_path(), - release_channel::ReleaseChannel::Dev.dev_name(), + release_channel::ReleaseChannel::Dev, )); assert!( good_db.select_row::("SELECT * FROM test2").unwrap()() diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index 8d86ac7fc4b7b62de05583b28784da251b0efe74..2dc34e5b022022d34ac89f8a292b2d27721f24f8 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -244,7 +244,8 @@ static GLOBAL_KEY_VALUE_STORE: std::sync::LazyLock = std::sync::LazyLock::new(|| { let db_dir = crate::database_dir(); GlobalKeyValueStore(smol::block_on(crate::open_db::( - db_dir, "global", + db_dir, + crate::GlobalDbScope, ))) }); diff --git a/crates/release_channel/src/lib.rs b/crates/release_channel/src/lib.rs index 65201ccc46caccdf4912b69fa296d468dfdea95d..357369b2caa234c801987e3bb41a37a0259ce234 100644 --- a/crates/release_channel/src/lib.rs +++ b/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::().0 diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index c0f59bfee589a2ecb1bdf1f79cdafb2bba159ad4..d561bd4e3b475b0bc527a0bd61a76929aae7e92b 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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, project_header_menu_ix: Option, _subscriptions: Vec, + /// 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, } 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) -> 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, + ) -> 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, + ) -> impl IntoElement { + let channels = channels_with_threads(cx); + let channel_names = channels + .iter() + .map(|channel| channel.display_name()) + .collect::>() + .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) { @@ -4403,6 +4432,66 @@ impl Sidebar { } } +fn render_import_onboarding_banner( + id: impl Into, + title: impl Into, + description: impl Into, + button_label: impl Into, + 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)) }