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..08aaed78a7baa6453213d16371f579472e72f91e 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,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); + ::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 = 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 = 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 234eb6221fa1cd62e2e864e657868dc513fc9299..154e71eb0dff5880c30302de7aa46a665e54ff1f 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); @@ -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> { - 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 \ - ORDER BY updated_at DESC" - )?() + self.select::(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 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 60a714d06929ccda12d77a03052046e9b972e35d..2418e2bc1d6554434bacf7d7004143593a7645f9 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; @@ -376,6 +377,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 { @@ -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) -> 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) { @@ -4606,6 +4635,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 @@ -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)) }