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

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

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

Release Notes:

- N/A

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
Eric Holk 2026-04-15 17:15:15 -07:00 committed by GitHub
parent b8b7aad70a
commit 5eca965473
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 618 additions and 96 deletions

View file

@ -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);

View file

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

View file

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

View file

@ -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()()

View file

@ -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,
)))
});

View file

@ -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

View file

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