use std::time::Duration; use acp_thread::AgentSessionListRequest;use agent::ThreadStore;use agent_client_protocol::schema::v1 as acp;use chrono::Utc;use collections::{HashMap, HashSet};use db::kvp::Dismissable;use db::sqlez;use fs::Fs;use futures::FutureExt as _;use gpui::{ Animation, AnimationExt as _, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, SharedString, Task, TaskExt, WeakEntity, Window, pulsating_between,};use itertools::Itertools as _;use notifications::status_toast::StatusToast;use project::{AgentId, AgentRegistryStore, AgentServerStore};use release_channel::ReleaseChannel;use remote::RemoteConnectionOptions;use ui::{ Checkbox, CommonAnimationExt, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*,};use util::ResultExt;use workspace::{ModalView, MultiWorkspace, Workspace}; use crate::{ Agent, AgentPanel, agent_connection_store::AgentConnectionStore, thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore, WorktreePaths},}; pub struct AcpThreadImportOnboarding;pub struct CrossChannelImportOnboarding; impl AcpThreadImportOnboarding { 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 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";} pub fn channels_with_threads(cx: &App) -> Task<Vec<SharedString>> { let Some(current_channel) = ReleaseChannel::try_global(cx) else { return Task::ready(Vec::new()); }; let database_dir = paths::database_dir(); let channel_has_threads = |database_dir: &std::path::Path, channel: ReleaseChannel| { 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) }; cx.background_spawn(async move { ReleaseChannel::ALL .iter() .copied() .filter(|channel| { *channel != current_channel && *channel != ReleaseChannel::Dev && channel_has_threads(database_dir, *channel) }) .map(|channel| SharedString::new_static(channel.display_name())) .collect() })} #[derive(Clone)]struct AgentEntry { agent_id: AgentId, display_name: SharedString, icon_path: Option<SharedString>,} #[derive(Clone)]enum AgentImportStatus { Loading, Ready { importable_count: usize }, Unsupported, Error(SharedString),} impl AgentImportStatus { fn is_selectable(&self) -> bool { matches!(self, Self::Ready { importable_count } if *importable_count > 0) } fn tooltip_text(&self) -> Option<SharedString> { match self { Self::Loading => Some("Fetching Sessions…".into()), Self::Ready { .. } => None, Self::Unsupported => Some("Importing threads from this agent is not possible as it doesn't support ACP's session/list capability.".into()), Self::Error(error) => Some(format!("Failed to fetch sessions: {error}").into()), } }} pub struct ThreadImportModal { focus_handle: FocusHandle, workspace: WeakEntity<Workspace>, multi_workspace: WeakEntity<MultiWorkspace>, agent_entries: Vec<AgentEntry>, unchecked_agents: HashSet<AgentId>, agent_import_statuses: HashMap<AgentId, AgentImportStatus>, sessions_by_agent: Vec<SessionByAgent>, selected_index: Option<usize>, is_fetching_sessions: bool, is_importing: bool, last_error: Option<SharedString>,} impl ThreadImportModal { pub fn new( agent_server_store: Entity<AgentServerStore>, agent_registry_store: Entity<AgentRegistryStore>, workspace: WeakEntity<Workspace>, multi_workspace: WeakEntity<MultiWorkspace>, _window: &mut Window, cx: &mut Context<Self>, ) -> Self { AcpThreadImportOnboarding::dismiss(cx); let agent_entries = agent_server_store .read(cx) .external_agents() .map(|agent_id| { let display_name = agent_server_store .read(cx) .agent_display_name(agent_id) .or_else(|| { agent_registry_store .read(cx) .agent(agent_id) .map(|agent| agent.name().clone()) }) .unwrap_or_else(|| agent_id.0.clone()); let icon_path = agent_server_store .read(cx) .agent_icon(agent_id) .or_else(|| { agent_registry_store .read(cx) .agent(agent_id) .and_then(|agent| agent.icon_path().cloned()) }); AgentEntry { agent_id: agent_id.clone(), display_name, icon_path, } }) .sorted_unstable_by_key(|entry| entry.display_name.to_lowercase()) .collect::<Vec<_>>(); let this = Self { focus_handle: cx.focus_handle(), workspace, multi_workspace, agent_entries, unchecked_agents: HashSet::default(), agent_import_statuses: HashMap::default(), sessions_by_agent: Vec::new(), selected_index: None, is_fetching_sessions: false, is_importing: false, last_error: None, }; cx.spawn(async move |this, cx| this.update(cx, |this, cx| this.fetch_sessions(cx))) .detach_and_log_err(cx); this } fn agent_ids(&self) -> Vec<AgentId> { self.agent_entries .iter() .map(|entry| entry.agent_id.clone()) .collect() } fn fetch_sessions(&mut self, cx: &mut Context<Self>) { if self.agent_entries.is_empty() { return; } let Some(multi_workspace) = self.multi_workspace.upgrade() else { self.mark_all_agents_failed("Could not find workspace to import from."); return; }; let stores = resolve_agent_connection_stores(&multi_workspace, cx); if stores.is_empty() { log::error!("Did not find any workspaces to import from"); self.mark_all_agents_failed("Did not find any workspaces to import from."); return; } self.is_fetching_sessions = true; self.last_error = None; self.sessions_by_agent.clear(); self.agent_import_statuses = self .agent_ids() .into_iter() .map(|agent_id| (agent_id, AgentImportStatus::Loading)) .collect(); let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx) .read(cx) .entries() .filter_map(|metadata| metadata.session_id.clone()) .collect(); for agent_id in self.agent_ids() { let task = fetch_sessions_for_agent(agent_id, existing_sessions.clone(), stores.clone(), cx); cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| { let AgentSessionFetchResult { agent_id, sessions_by_agent, status, } = result; this.sessions_by_agent .retain(|sessions| sessions.agent_id != agent_id); this.sessions_by_agent.extend(sessions_by_agent); this.agent_import_statuses.insert(agent_id, status); this.is_fetching_sessions = this.has_loading_agents(); cx.notify(); }) }) .detach_and_log_err(cx); } } fn mark_all_agents_failed(&mut self, message: impl Into<SharedString>) { let message = message.into(); self.is_fetching_sessions = false; self.sessions_by_agent.clear(); self.last_error = Some(message.clone()); self.agent_import_statuses = self .agent_ids() .into_iter() .map(|agent_id| (agent_id, AgentImportStatus::Error(message.clone()))) .collect(); } fn agent_is_selectable(&self, agent_id: &AgentId) -> bool { self.agent_import_statuses .get(agent_id) .map_or(false, AgentImportStatus::is_selectable) } fn has_checked_selectable_agent(&self) -> bool { self.agent_entries.iter().any(|entry| { self.agent_is_selectable(&entry.agent_id) && !self.unchecked_agents.contains(&entry.agent_id) }) } fn has_loading_agents(&self) -> bool { self.agent_import_statuses .values() .any(|status| matches!(status, AgentImportStatus::Loading)) } fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context<Self>) { if self.is_importing || !self.agent_is_selectable(&agent_id) { return; } if self.unchecked_agents.contains(&agent_id) { self.unchecked_agents.remove(&agent_id); } else { self.unchecked_agents.insert(agent_id); } cx.notify(); } fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) { if self.agent_entries.is_empty() { return; } self.selected_index = Some(match self.selected_index { Some(ix) if ix + 1 >= self.agent_entries.len() => 0, Some(ix) => ix + 1, None => 0, }); cx.notify(); } fn select_previous( &mut self, _: &menu::SelectPrevious, _window: &mut Window, cx: &mut Context<Self>, ) { if self.agent_entries.is_empty() { return; } self.selected_index = Some(match self.selected_index { Some(0) => self.agent_entries.len() - 1, Some(ix) => ix - 1, None => self.agent_entries.len() - 1, }); cx.notify(); } fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) { if let Some(ix) = self.selected_index { if let Some(entry) = self.agent_entries.get(ix) { self.toggle_agent_checked(entry.agent_id.clone(), cx); } } } fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) { cx.emit(DismissEvent); } fn import_threads( &mut self, _: &menu::SecondaryConfirm, _: &mut Window, cx: &mut Context<Self>, ) { if self.is_importing || !self.has_checked_selectable_agent() { return; } self.is_importing = true; self.last_error = None; let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx) .read(cx) .entries() .filter_map(|metadata| metadata.session_id.clone()) .collect(); let selected_sessions_by_agent = self .sessions_by_agent .iter() .filter(|sessions| { self.agent_is_selectable(&sessions.agent_id) && !self.unchecked_agents.contains(&sessions.agent_id) }) .cloned() .collect::<Vec<_>>(); let threads = collect_importable_threads(selected_sessions_by_agent, existing_sessions); let imported_count = threads.len(); ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_all(threads, cx)); self.is_importing = false; self.show_imported_threads_toast(imported_count, cx); cx.emit(DismissEvent); } fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) { let status_toast = if imported_count == 0 { StatusToast::new("No threads found to import.", cx, |this, _cx| { this.icon( Icon::new(IconName::Info) .size(IconSize::Small) .color(Color::Muted), ) .dismiss_button(true) }) } else { let message = if imported_count == 1 { "Imported 1 thread.".to_string() } else { format!("Imported {imported_count} threads.") }; StatusToast::new(message, cx, |this, _cx| { this.icon( Icon::new(IconName::Check) .size(IconSize::Small) .color(Color::Success), ) .dismiss_button(true) }) }; self.workspace .update(cx, |workspace, cx| { workspace.toggle_status_toast(status_toast, cx); }) .log_err(); }} impl EventEmitter<DismissEvent> for ThreadImportModal {} impl Focusable for ThreadImportModal { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() }} impl ModalView for ThreadImportModal {} impl Render for ThreadImportModal { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let has_agents = !self.agent_entries.is_empty(); let disabled_import_thread = self.is_importing || !has_agents || !self.has_checked_selectable_agent(); let agent_rows = self .agent_entries .iter() .enumerate() .map(|(ix, entry)| { let status = self .agent_import_statuses .get(&entry.agent_id) .cloned() .unwrap_or(AgentImportStatus::Loading); let is_selectable = status.is_selectable(); let is_checked = is_selectable && !self.unchecked_agents.contains(&entry.agent_id); let is_focused = self.selected_index == Some(ix); let row_disabled = self.is_importing || !is_selectable; let checkbox_state = if is_checked { ToggleState::Selected } else { ToggleState::Unselected }; let end_slot = match &status { AgentImportStatus::Loading | AgentImportStatus::Unsupported | AgentImportStatus::Error(_) => { Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state) .disabled(true) .into_any_element() } AgentImportStatus::Ready { .. } => { Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state) .disabled(row_disabled) .into_any_element() } }; let is_loading = matches!(status, AgentImportStatus::Loading); let icon_color = if is_checked { Color::Muted } else { Color::Disabled }; let item = h_flex() .w_full() .gap_2() .child(if let Some(icon_path) = entry.icon_path.clone() { Icon::from_external_svg(icon_path) .color(icon_color) .size(IconSize::Small) } else { Icon::new(IconName::Sparkle) .color(icon_color) .size(IconSize::Small) }) .child( Label::new(entry.display_name.clone()) .when(!is_checked, |s| s.color(Color::Disabled)), ) .map(|this| match status { AgentImportStatus::Loading => this, AgentImportStatus::Ready {Lines 1–500 of 1431. Load more
use std::time::Duration; use acp_thread::AgentSessionListRequest; use agent::ThreadStore; use agent_client_protocol::schema::v1 as acp; use chrono::Utc; use collections::{HashMap, HashSet}; use db::kvp::Dismissable; use db::sqlez; use fs::Fs; use futures::FutureExt as _; use gpui::{ Animation, AnimationExt as _, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, SharedString, Task, TaskExt, WeakEntity, Window, pulsating_between, }; use itertools::Itertools as _; use notifications::status_toast::StatusToast; use project::{AgentId, AgentRegistryStore, AgentServerStore}; use release_channel::ReleaseChannel; use remote::RemoteConnectionOptions; use ui::{ Checkbox, CommonAnimationExt, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*, }; use util::ResultExt; use workspace::{ModalView, MultiWorkspace, Workspace}; use crate::{ Agent, AgentPanel, agent_connection_store::AgentConnectionStore, thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore, WorktreePaths}, }; pub struct AcpThreadImportOnboarding; pub struct CrossChannelImportOnboarding; impl AcpThreadImportOnboarding { 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 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"; } pub fn channels_with_threads(cx: &App) -> Task<Vec<SharedString>> { let Some(current_channel) = ReleaseChannel::try_global(cx) else { return Task::ready(Vec::new()); }; let database_dir = paths::database_dir(); let channel_has_threads = |database_dir: &std::path::Path, channel: ReleaseChannel| { 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) }; cx.background_spawn(async move { ReleaseChannel::ALL .iter() .copied() .filter(|channel| { *channel != current_channel && *channel != ReleaseChannel::Dev && channel_has_threads(database_dir, *channel) }) .map(|channel| SharedString::new_static(channel.display_name())) .collect() }) } #[derive(Clone)] struct AgentEntry { agent_id: AgentId, display_name: SharedString, icon_path: Option<SharedString>, } #[derive(Clone)] enum AgentImportStatus { Loading, Ready { importable_count: usize }, Unsupported, Error(SharedString), } impl AgentImportStatus { fn is_selectable(&self) -> bool { matches!(self, Self::Ready { importable_count } if *importable_count > 0) } fn tooltip_text(&self) -> Option<SharedString> { match self { Self::Loading => Some("Fetching Sessions…".into()), Self::Ready { .. } => None, Self::Unsupported => Some("Importing threads from this agent is not possible as it doesn't support ACP's session/list capability.".into()), Self::Error(error) => Some(format!("Failed to fetch sessions: {error}").into()), } } } pub struct ThreadImportModal { focus_handle: FocusHandle, workspace: WeakEntity<Workspace>, multi_workspace: WeakEntity<MultiWorkspace>, agent_entries: Vec<AgentEntry>, unchecked_agents: HashSet<AgentId>, agent_import_statuses: HashMap<AgentId, AgentImportStatus>, sessions_by_agent: Vec<SessionByAgent>, selected_index: Option<usize>, is_fetching_sessions: bool, is_importing: bool, last_error: Option<SharedString>, } impl ThreadImportModal { pub fn new( agent_server_store: Entity<AgentServerStore>, agent_registry_store: Entity<AgentRegistryStore>, workspace: WeakEntity<Workspace>, multi_workspace: WeakEntity<MultiWorkspace>, _window: &mut Window, cx: &mut Context<Self>, ) -> Self { AcpThreadImportOnboarding::dismiss(cx); let agent_entries = agent_server_store .read(cx) .external_agents() .map(|agent_id| { let display_name = agent_server_store .read(cx) .agent_display_name(agent_id) .or_else(|| { agent_registry_store .read(cx) .agent(agent_id) .map(|agent| agent.name().clone()) }) .unwrap_or_else(|| agent_id.0.clone()); let icon_path = agent_server_store .read(cx) .agent_icon(agent_id) .or_else(|| { agent_registry_store .read(cx) .agent(agent_id) .and_then(|agent| agent.icon_path().cloned()) }); AgentEntry { agent_id: agent_id.clone(), display_name, icon_path, } }) .sorted_unstable_by_key(|entry| entry.display_name.to_lowercase()) .collect::<Vec<_>>(); let this = Self { focus_handle: cx.focus_handle(), workspace, multi_workspace, agent_entries, unchecked_agents: HashSet::default(), agent_import_statuses: HashMap::default(), sessions_by_agent: Vec::new(), selected_index: None, is_fetching_sessions: false, is_importing: false, last_error: None, }; cx.spawn(async move |this, cx| this.update(cx, |this, cx| this.fetch_sessions(cx))) .detach_and_log_err(cx); this } fn agent_ids(&self) -> Vec<AgentId> { self.agent_entries .iter() .map(|entry| entry.agent_id.clone()) .collect() } fn fetch_sessions(&mut self, cx: &mut Context<Self>) { if self.agent_entries.is_empty() { return; } let Some(multi_workspace) = self.multi_workspace.upgrade() else { self.mark_all_agents_failed("Could not find workspace to import from."); return; }; let stores = resolve_agent_connection_stores(&multi_workspace, cx); if stores.is_empty() { log::error!("Did not find any workspaces to import from"); self.mark_all_agents_failed("Did not find any workspaces to import from."); return; } self.is_fetching_sessions = true; self.last_error = None; self.sessions_by_agent.clear(); self.agent_import_statuses = self .agent_ids() .into_iter() .map(|agent_id| (agent_id, AgentImportStatus::Loading)) .collect(); let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx) .read(cx) .entries() .filter_map(|metadata| metadata.session_id.clone()) .collect(); for agent_id in self.agent_ids() { let task = fetch_sessions_for_agent(agent_id, existing_sessions.clone(), stores.clone(), cx); cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| { let AgentSessionFetchResult { agent_id, sessions_by_agent, status, } = result; this.sessions_by_agent .retain(|sessions| sessions.agent_id != agent_id); this.sessions_by_agent.extend(sessions_by_agent); this.agent_import_statuses.insert(agent_id, status); this.is_fetching_sessions = this.has_loading_agents(); cx.notify(); }) }) .detach_and_log_err(cx); } } fn mark_all_agents_failed(&mut self, message: impl Into<SharedString>) { let message = message.into(); self.is_fetching_sessions = false; self.sessions_by_agent.clear(); self.last_error = Some(message.clone()); self.agent_import_statuses = self .agent_ids() .into_iter() .map(|agent_id| (agent_id, AgentImportStatus::Error(message.clone()))) .collect(); } fn agent_is_selectable(&self, agent_id: &AgentId) -> bool { self.agent_import_statuses .get(agent_id) .map_or(false, AgentImportStatus::is_selectable) } fn has_checked_selectable_agent(&self) -> bool { self.agent_entries.iter().any(|entry| { self.agent_is_selectable(&entry.agent_id) && !self.unchecked_agents.contains(&entry.agent_id) }) } fn has_loading_agents(&self) -> bool { self.agent_import_statuses .values() .any(|status| matches!(status, AgentImportStatus::Loading)) } fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context<Self>) { if self.is_importing || !self.agent_is_selectable(&agent_id) { return; } if self.unchecked_agents.contains(&agent_id) { self.unchecked_agents.remove(&agent_id); } else { self.unchecked_agents.insert(agent_id); } cx.notify(); } fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) { if self.agent_entries.is_empty() { return; } self.selected_index = Some(match self.selected_index { Some(ix) if ix + 1 >= self.agent_entries.len() => 0, Some(ix) => ix + 1, None => 0, }); cx.notify(); } fn select_previous( &mut self, _: &menu::SelectPrevious, _window: &mut Window, cx: &mut Context<Self>, ) { if self.agent_entries.is_empty() { return; } self.selected_index = Some(match self.selected_index { Some(0) => self.agent_entries.len() - 1, Some(ix) => ix - 1, None => self.agent_entries.len() - 1, }); cx.notify(); } fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) { if let Some(ix) = self.selected_index { if let Some(entry) = self.agent_entries.get(ix) { self.toggle_agent_checked(entry.agent_id.clone(), cx); } } } fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) { cx.emit(DismissEvent); } fn import_threads( &mut self, _: &menu::SecondaryConfirm, _: &mut Window, cx: &mut Context<Self>, ) { if self.is_importing || !self.has_checked_selectable_agent() { return; } self.is_importing = true; self.last_error = None; let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx) .read(cx) .entries() .filter_map(|metadata| metadata.session_id.clone()) .collect(); let selected_sessions_by_agent = self .sessions_by_agent .iter() .filter(|sessions| { self.agent_is_selectable(&sessions.agent_id) && !self.unchecked_agents.contains(&sessions.agent_id) }) .cloned() .collect::<Vec<_>>(); let threads = collect_importable_threads(selected_sessions_by_agent, existing_sessions); let imported_count = threads.len(); ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_all(threads, cx)); self.is_importing = false; self.show_imported_threads_toast(imported_count, cx); cx.emit(DismissEvent); } fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) { let status_toast = if imported_count == 0 { StatusToast::new("No threads found to import.", cx, |this, _cx| { this.icon( Icon::new(IconName::Info) .size(IconSize::Small) .color(Color::Muted), ) .dismiss_button(true) }) } else { let message = if imported_count == 1 { "Imported 1 thread.".to_string() } else { format!("Imported {imported_count} threads.") }; StatusToast::new(message, cx, |this, _cx| { this.icon( Icon::new(IconName::Check) .size(IconSize::Small) .color(Color::Success), ) .dismiss_button(true) }) }; self.workspace .update(cx, |workspace, cx| { workspace.toggle_status_toast(status_toast, cx); }) .log_err(); } } impl EventEmitter<DismissEvent> for ThreadImportModal {} impl Focusable for ThreadImportModal { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl ModalView for ThreadImportModal {} impl Render for ThreadImportModal { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let has_agents = !self.agent_entries.is_empty(); let disabled_import_thread = self.is_importing || !has_agents || !self.has_checked_selectable_agent(); let agent_rows = self .agent_entries .iter() .enumerate() .map(|(ix, entry)| { let status = self .agent_import_statuses .get(&entry.agent_id) .cloned() .unwrap_or(AgentImportStatus::Loading); let is_selectable = status.is_selectable(); let is_checked = is_selectable && !self.unchecked_agents.contains(&entry.agent_id); let is_focused = self.selected_index == Some(ix); let row_disabled = self.is_importing || !is_selectable; let checkbox_state = if is_checked { ToggleState::Selected } else { ToggleState::Unselected }; let end_slot = match &status { AgentImportStatus::Loading | AgentImportStatus::Unsupported | AgentImportStatus::Error(_) => { Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state) .disabled(true) .into_any_element() } AgentImportStatus::Ready { .. } => { Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state) .disabled(row_disabled) .into_any_element() } }; let is_loading = matches!(status, AgentImportStatus::Loading); let icon_color = if is_checked { Color::Muted } else { Color::Disabled }; let item = h_flex() .w_full() .gap_2() .child(if let Some(icon_path) = entry.icon_path.clone() { Icon::from_external_svg(icon_path) .color(icon_color) .size(IconSize::Small) } else { Icon::new(IconName::Sparkle) .color(icon_color) .size(IconSize::Small) }) .child( Label::new(entry.display_name.clone()) .when(!is_checked, |s| s.color(Color::Disabled)), ) .map(|this| match status { AgentImportStatus::Loading => this, AgentImportStatus::Ready {