1use acp_thread::AgentSessionListRequest;
2use agent::ThreadStore;
3use agent_client_protocol as acp;
4use chrono::Utc;
5use collections::HashSet;
6use db::kvp::Dismissable;
7use fs::Fs;
8use futures::FutureExt as _;
9use gpui::{
10 App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
11 Render, SharedString, Task, WeakEntity, Window,
12};
13use notifications::status_toast::{StatusToast, ToastIcon};
14use project::{AgentId, AgentRegistryStore, AgentServerStore};
15use remote::RemoteConnectionOptions;
16use ui::{
17 Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section,
18 prelude::*,
19};
20use util::ResultExt;
21use workspace::{ModalView, MultiWorkspace, Workspace};
22
23use crate::{
24 Agent, AgentPanel,
25 agent_connection_store::AgentConnectionStore,
26 thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore, WorktreePaths},
27};
28
29pub struct AcpThreadImportOnboarding;
30
31impl AcpThreadImportOnboarding {
32 pub fn dismissed(cx: &App) -> bool {
33 <Self as Dismissable>::dismissed(cx)
34 }
35
36 pub fn dismiss(cx: &mut App) {
37 <Self as Dismissable>::set_dismissed(true, cx);
38 }
39}
40
41impl Dismissable for AcpThreadImportOnboarding {
42 const KEY: &'static str = "dismissed-acp-thread-import";
43}
44
45#[derive(Clone)]
46struct AgentEntry {
47 agent_id: AgentId,
48 display_name: SharedString,
49 icon_path: Option<SharedString>,
50}
51
52pub struct ThreadImportModal {
53 focus_handle: FocusHandle,
54 workspace: WeakEntity<Workspace>,
55 multi_workspace: WeakEntity<MultiWorkspace>,
56 agent_entries: Vec<AgentEntry>,
57 unchecked_agents: HashSet<AgentId>,
58 selected_index: Option<usize>,
59 is_importing: bool,
60 last_error: Option<SharedString>,
61}
62
63impl ThreadImportModal {
64 pub fn new(
65 agent_server_store: Entity<AgentServerStore>,
66 agent_registry_store: Entity<AgentRegistryStore>,
67 workspace: WeakEntity<Workspace>,
68 multi_workspace: WeakEntity<MultiWorkspace>,
69 _window: &mut Window,
70 cx: &mut Context<Self>,
71 ) -> Self {
72 AcpThreadImportOnboarding::dismiss(cx);
73
74 let agent_entries = agent_server_store
75 .read(cx)
76 .external_agents()
77 .map(|agent_id| {
78 let display_name = agent_server_store
79 .read(cx)
80 .agent_display_name(agent_id)
81 .or_else(|| {
82 agent_registry_store
83 .read(cx)
84 .agent(agent_id)
85 .map(|agent| agent.name().clone())
86 })
87 .unwrap_or_else(|| agent_id.0.clone());
88 let icon_path = agent_server_store
89 .read(cx)
90 .agent_icon(agent_id)
91 .or_else(|| {
92 agent_registry_store
93 .read(cx)
94 .agent(agent_id)
95 .and_then(|agent| agent.icon_path().cloned())
96 });
97
98 AgentEntry {
99 agent_id: agent_id.clone(),
100 display_name,
101 icon_path,
102 }
103 })
104 .collect::<Vec<_>>();
105
106 Self {
107 focus_handle: cx.focus_handle(),
108 workspace,
109 multi_workspace,
110 agent_entries,
111 unchecked_agents: HashSet::default(),
112 selected_index: None,
113 is_importing: false,
114 last_error: None,
115 }
116 }
117
118 fn agent_ids(&self) -> Vec<AgentId> {
119 self.agent_entries
120 .iter()
121 .map(|entry| entry.agent_id.clone())
122 .collect()
123 }
124
125 fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context<Self>) {
126 if self.unchecked_agents.contains(&agent_id) {
127 self.unchecked_agents.remove(&agent_id);
128 } else {
129 self.unchecked_agents.insert(agent_id);
130 }
131 cx.notify();
132 }
133
134 fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
135 if self.agent_entries.is_empty() {
136 return;
137 }
138 self.selected_index = Some(match self.selected_index {
139 Some(ix) if ix + 1 >= self.agent_entries.len() => 0,
140 Some(ix) => ix + 1,
141 None => 0,
142 });
143 cx.notify();
144 }
145
146 fn select_previous(
147 &mut self,
148 _: &menu::SelectPrevious,
149 _window: &mut Window,
150 cx: &mut Context<Self>,
151 ) {
152 if self.agent_entries.is_empty() {
153 return;
154 }
155 self.selected_index = Some(match self.selected_index {
156 Some(0) => self.agent_entries.len() - 1,
157 Some(ix) => ix - 1,
158 None => self.agent_entries.len() - 1,
159 });
160 cx.notify();
161 }
162
163 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
164 if let Some(ix) = self.selected_index {
165 if let Some(entry) = self.agent_entries.get(ix) {
166 self.toggle_agent_checked(entry.agent_id.clone(), cx);
167 }
168 }
169 }
170
171 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
172 cx.emit(DismissEvent);
173 }
174
175 fn import_threads(
176 &mut self,
177 _: &menu::SecondaryConfirm,
178 _: &mut Window,
179 cx: &mut Context<Self>,
180 ) {
181 if self.is_importing {
182 return;
183 }
184
185 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
186 self.is_importing = false;
187 cx.notify();
188 return;
189 };
190
191 let stores = resolve_agent_connection_stores(&multi_workspace, cx);
192 if stores.is_empty() {
193 log::error!("Did not find any workspaces to import from");
194 self.is_importing = false;
195 cx.notify();
196 return;
197 }
198
199 self.is_importing = true;
200 self.last_error = None;
201 cx.notify();
202
203 let agent_ids = self
204 .agent_ids()
205 .into_iter()
206 .filter(|agent_id| !self.unchecked_agents.contains(agent_id))
207 .collect::<Vec<_>>();
208
209 let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx)
210 .read(cx)
211 .entries()
212 .filter_map(|m| m.session_id.clone())
213 .collect();
214
215 let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx);
216 cx.spawn(async move |this, cx| {
217 let result = task.await;
218 this.update(cx, |this, cx| match result {
219 Ok(threads) => {
220 let imported_count = threads.len();
221 ThreadMetadataStore::global(cx)
222 .update(cx, |store, cx| store.save_all(threads, cx));
223 this.is_importing = false;
224 this.last_error = None;
225 this.show_imported_threads_toast(imported_count, cx);
226 cx.emit(DismissEvent);
227 }
228 Err(error) => {
229 this.is_importing = false;
230 this.last_error = Some(error.to_string().into());
231 cx.notify();
232 }
233 })
234 })
235 .detach_and_log_err(cx);
236 }
237
238 fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) {
239 let status_toast = if imported_count == 0 {
240 StatusToast::new("No threads found to import.", cx, |this, _cx| {
241 this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
242 .dismiss_button(true)
243 })
244 } else {
245 let message = if imported_count == 1 {
246 "Imported 1 thread.".to_string()
247 } else {
248 format!("Imported {imported_count} threads.")
249 };
250 StatusToast::new(message, cx, |this, _cx| {
251 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
252 .dismiss_button(true)
253 })
254 };
255
256 self.workspace
257 .update(cx, |workspace, cx| {
258 workspace.toggle_status_toast(status_toast, cx);
259 })
260 .log_err();
261 }
262}
263
264impl EventEmitter<DismissEvent> for ThreadImportModal {}
265
266impl Focusable for ThreadImportModal {
267 fn focus_handle(&self, _cx: &App) -> FocusHandle {
268 self.focus_handle.clone()
269 }
270}
271
272impl ModalView for ThreadImportModal {}
273
274impl Render for ThreadImportModal {
275 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
276 let has_agents = !self.agent_entries.is_empty();
277 let disabled_import_thread = self.is_importing
278 || !has_agents
279 || self.unchecked_agents.len() == self.agent_entries.len();
280
281 let agent_rows = self
282 .agent_entries
283 .iter()
284 .enumerate()
285 .map(|(ix, entry)| {
286 let is_checked = !self.unchecked_agents.contains(&entry.agent_id);
287 let is_focused = self.selected_index == Some(ix);
288
289 ListItem::new(("thread-import-agent", ix))
290 .rounded()
291 .spacing(ListItemSpacing::Sparse)
292 .focused(is_focused)
293 .disabled(self.is_importing)
294 .child(
295 h_flex()
296 .w_full()
297 .gap_2()
298 .when(!is_checked, |this| this.opacity(0.6))
299 .child(if let Some(icon_path) = entry.icon_path.clone() {
300 Icon::from_external_svg(icon_path)
301 .color(Color::Muted)
302 .size(IconSize::Small)
303 } else {
304 Icon::new(IconName::Sparkle)
305 .color(Color::Muted)
306 .size(IconSize::Small)
307 })
308 .child(Label::new(entry.display_name.clone())),
309 )
310 .end_slot(Checkbox::new(
311 ("thread-import-agent-checkbox", ix),
312 if is_checked {
313 ToggleState::Selected
314 } else {
315 ToggleState::Unselected
316 },
317 ))
318 .on_click({
319 let agent_id = entry.agent_id.clone();
320 cx.listener(move |this, _event, _window, cx| {
321 this.toggle_agent_checked(agent_id.clone(), cx);
322 })
323 })
324 })
325 .collect::<Vec<_>>();
326
327 v_flex()
328 .id("thread-import-modal")
329 .key_context("ThreadImportModal")
330 .w(rems(34.))
331 .elevation_3(cx)
332 .overflow_hidden()
333 .track_focus(&self.focus_handle)
334 .on_action(cx.listener(Self::cancel))
335 .on_action(cx.listener(Self::confirm))
336 .on_action(cx.listener(Self::select_next))
337 .on_action(cx.listener(Self::select_previous))
338 .on_action(cx.listener(Self::import_threads))
339 .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
340 this.focus_handle.focus(window, cx);
341 }))
342 .child(
343 Modal::new("import-threads", None)
344 .header(
345 ModalHeader::new()
346 .headline("Import External Agent Threads")
347 .description(
348 "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \
349 Choose which agents to include, and their threads will appear in your archive."
350 )
351 .show_dismiss_button(true),
352
353 )
354 .section(
355 Section::new().child(
356 v_flex()
357 .id("thread-import-agent-list")
358 .max_h(rems_from_px(320.))
359 .pb_1()
360 .overflow_y_scroll()
361 .when(has_agents, |this| this.children(agent_rows))
362 .when(!has_agents, |this| {
363 this.child(
364 Label::new("No ACP agents available.")
365 .color(Color::Muted)
366 .size(LabelSize::Small),
367 )
368 }),
369 ),
370 )
371 .footer(
372 ModalFooter::new()
373 .when_some(self.last_error.clone(), |this, error| {
374 this.start_slot(
375 Label::new(error)
376 .size(LabelSize::Small)
377 .color(Color::Error)
378 .truncate(),
379 )
380 })
381 .end_slot(
382 Button::new("import-threads", "Import Threads")
383 .loading(self.is_importing)
384 .disabled(disabled_import_thread)
385 .key_binding(
386 KeyBinding::for_action(&menu::SecondaryConfirm, cx)
387 .map(|kb| kb.size(rems_from_px(12.))),
388 )
389 .on_click(cx.listener(|this, _, window, cx| {
390 this.import_threads(&menu::SecondaryConfirm, window, cx);
391 })),
392 ),
393 ),
394 )
395 }
396}
397
398fn resolve_agent_connection_stores(
399 multi_workspace: &Entity<MultiWorkspace>,
400 cx: &App,
401) -> Vec<Entity<AgentConnectionStore>> {
402 let mut stores = Vec::new();
403 let mut included_local_store = false;
404
405 for workspace in multi_workspace.read(cx).workspaces() {
406 let workspace = workspace.read(cx);
407 let project = workspace.project().read(cx);
408
409 // We only want to include scores from one local workspace, since we
410 // know that they live on the same machine
411 let include_store = if project.is_remote() {
412 true
413 } else if project.is_local() && !included_local_store {
414 included_local_store = true;
415 true
416 } else {
417 false
418 };
419
420 if !include_store {
421 continue;
422 }
423
424 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
425 stores.push(panel.read(cx).connection_store().clone());
426 }
427 }
428
429 stores
430}
431
432fn find_threads_to_import(
433 agent_ids: Vec<AgentId>,
434 existing_sessions: HashSet<acp::SessionId>,
435 stores: Vec<Entity<AgentConnectionStore>>,
436 cx: &mut App,
437) -> Task<anyhow::Result<Vec<ThreadMetadata>>> {
438 let mut wait_for_connection_tasks = Vec::new();
439
440 for store in stores {
441 let remote_connection = store
442 .read(cx)
443 .project()
444 .read(cx)
445 .remote_connection_options(cx);
446
447 for agent_id in agent_ids.clone() {
448 let agent = Agent::from(agent_id.clone());
449 let server = agent.server(<dyn Fs>::global(cx), ThreadStore::global(cx));
450 let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx));
451
452 wait_for_connection_tasks.push(entry.read(cx).wait_for_connection().map({
453 let remote_connection = remote_connection.clone();
454 move |state| (agent_id, remote_connection, state)
455 }));
456 }
457 }
458
459 let mut session_list_tasks = Vec::new();
460 cx.spawn(async move |cx| {
461 let results = futures::future::join_all(wait_for_connection_tasks).await;
462 for (agent_id, remote_connection, result) in results {
463 let Some(state) = result.log_err() else {
464 continue;
465 };
466 let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else {
467 continue;
468 };
469 let task = cx.update(|cx| {
470 list.list_sessions(AgentSessionListRequest::default(), cx)
471 .map({
472 let remote_connection = remote_connection.clone();
473 move |response| (agent_id, remote_connection, response)
474 })
475 });
476 session_list_tasks.push(task);
477 }
478
479 let mut sessions_by_agent = Vec::new();
480 let results = futures::future::join_all(session_list_tasks).await;
481 for (agent_id, remote_connection, result) in results {
482 let Some(response) = result.log_err() else {
483 continue;
484 };
485 sessions_by_agent.push(SessionByAgent {
486 agent_id,
487 remote_connection,
488 sessions: response.sessions,
489 });
490 }
491
492 Ok(collect_importable_threads(
493 sessions_by_agent,
494 existing_sessions,
495 ))
496 })
497}
498
499struct SessionByAgent {
500 agent_id: AgentId,
501 remote_connection: Option<RemoteConnectionOptions>,
502 sessions: Vec<acp_thread::AgentSessionInfo>,
503}
504
505fn collect_importable_threads(
506 sessions_by_agent: Vec<SessionByAgent>,
507 mut existing_sessions: HashSet<acp::SessionId>,
508) -> Vec<ThreadMetadata> {
509 let mut to_insert = Vec::new();
510 for SessionByAgent {
511 agent_id,
512 remote_connection,
513 sessions,
514 } in sessions_by_agent
515 {
516 for session in sessions {
517 if !existing_sessions.insert(session.session_id.clone()) {
518 continue;
519 }
520 let Some(folder_paths) = session.work_dirs else {
521 continue;
522 };
523 to_insert.push(ThreadMetadata {
524 thread_id: ThreadId::new(),
525 session_id: Some(session.session_id),
526 agent_id: agent_id.clone(),
527 title: session.title,
528 updated_at: session.updated_at.unwrap_or_else(|| Utc::now()),
529 created_at: session.created_at,
530 worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
531 remote_connection: remote_connection.clone(),
532 archived: true,
533 });
534 }
535 }
536 to_insert
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use acp_thread::AgentSessionInfo;
543 use chrono::Utc;
544 use std::path::Path;
545 use workspace::PathList;
546
547 fn make_session(
548 session_id: &str,
549 title: Option<&str>,
550 work_dirs: Option<PathList>,
551 updated_at: Option<chrono::DateTime<Utc>>,
552 created_at: Option<chrono::DateTime<Utc>>,
553 ) -> AgentSessionInfo {
554 AgentSessionInfo {
555 session_id: acp::SessionId::new(session_id),
556 title: title.map(|t| SharedString::from(t.to_string())),
557 work_dirs,
558 updated_at,
559 created_at,
560 meta: None,
561 }
562 }
563
564 #[test]
565 fn test_collect_skips_sessions_already_in_existing_set() {
566 let existing = HashSet::from_iter(vec![acp::SessionId::new("existing-1")]);
567 let paths = PathList::new(&[Path::new("/project")]);
568
569 let sessions_by_agent = vec![SessionByAgent {
570 agent_id: AgentId::new("agent-a"),
571 remote_connection: None,
572 sessions: vec![
573 make_session(
574 "existing-1",
575 Some("Already There"),
576 Some(paths.clone()),
577 None,
578 None,
579 ),
580 make_session("new-1", Some("Brand New"), Some(paths), None, None),
581 ],
582 }];
583
584 let result = collect_importable_threads(sessions_by_agent, existing);
585
586 assert_eq!(result.len(), 1);
587 assert_eq!(result[0].session_id.as_ref().unwrap().0.as_ref(), "new-1");
588 assert_eq!(result[0].display_title(), "Brand New");
589 }
590
591 #[test]
592 fn test_collect_skips_sessions_without_work_dirs() {
593 let existing = HashSet::default();
594 let paths = PathList::new(&[Path::new("/project")]);
595
596 let sessions_by_agent = vec![SessionByAgent {
597 agent_id: AgentId::new("agent-a"),
598 remote_connection: None,
599 sessions: vec![
600 make_session("has-dirs", Some("With Dirs"), Some(paths), None, None),
601 make_session("no-dirs", Some("No Dirs"), None, None, None),
602 ],
603 }];
604
605 let result = collect_importable_threads(sessions_by_agent, existing);
606
607 assert_eq!(result.len(), 1);
608 assert_eq!(
609 result[0].session_id.as_ref().unwrap().0.as_ref(),
610 "has-dirs"
611 );
612 }
613
614 #[test]
615 fn test_collect_marks_all_imported_threads_as_archived() {
616 let existing = HashSet::default();
617 let paths = PathList::new(&[Path::new("/project")]);
618
619 let sessions_by_agent = vec![SessionByAgent {
620 agent_id: AgentId::new("agent-a"),
621 remote_connection: None,
622 sessions: vec![
623 make_session("s1", Some("Thread 1"), Some(paths.clone()), None, None),
624 make_session("s2", Some("Thread 2"), Some(paths), None, None),
625 ],
626 }];
627
628 let result = collect_importable_threads(sessions_by_agent, existing);
629
630 assert_eq!(result.len(), 2);
631 assert!(result.iter().all(|t| t.archived));
632 }
633
634 #[test]
635 fn test_collect_assigns_correct_agent_id_per_session() {
636 let existing = HashSet::default();
637 let paths = PathList::new(&[Path::new("/project")]);
638
639 let sessions_by_agent = vec![
640 SessionByAgent {
641 agent_id: AgentId::new("agent-a"),
642 remote_connection: None,
643 sessions: vec![make_session(
644 "s1",
645 Some("From A"),
646 Some(paths.clone()),
647 None,
648 None,
649 )],
650 },
651 SessionByAgent {
652 agent_id: AgentId::new("agent-b"),
653 remote_connection: None,
654 sessions: vec![make_session("s2", Some("From B"), Some(paths), None, None)],
655 },
656 ];
657
658 let result = collect_importable_threads(sessions_by_agent, existing);
659
660 assert_eq!(result.len(), 2);
661 let s1 = result
662 .iter()
663 .find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s1"))
664 .unwrap();
665 let s2 = result
666 .iter()
667 .find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s2"))
668 .unwrap();
669 assert_eq!(s1.agent_id.as_ref(), "agent-a");
670 assert_eq!(s2.agent_id.as_ref(), "agent-b");
671 }
672
673 #[test]
674 fn test_collect_deduplicates_across_agents() {
675 let existing = HashSet::default();
676 let paths = PathList::new(&[Path::new("/project")]);
677
678 let sessions_by_agent = vec![
679 SessionByAgent {
680 agent_id: AgentId::new("agent-a"),
681 remote_connection: None,
682 sessions: vec![make_session(
683 "shared-session",
684 Some("From A"),
685 Some(paths.clone()),
686 None,
687 None,
688 )],
689 },
690 SessionByAgent {
691 agent_id: AgentId::new("agent-b"),
692 remote_connection: None,
693 sessions: vec![make_session(
694 "shared-session",
695 Some("From B"),
696 Some(paths),
697 None,
698 None,
699 )],
700 },
701 ];
702
703 let result = collect_importable_threads(sessions_by_agent, existing);
704
705 assert_eq!(result.len(), 1);
706 assert_eq!(
707 result[0].session_id.as_ref().unwrap().0.as_ref(),
708 "shared-session"
709 );
710 assert_eq!(
711 result[0].agent_id.as_ref(),
712 "agent-a",
713 "first agent encountered should win"
714 );
715 }
716
717 #[test]
718 fn test_collect_all_existing_returns_empty() {
719 let paths = PathList::new(&[Path::new("/project")]);
720 let existing =
721 HashSet::from_iter(vec![acp::SessionId::new("s1"), acp::SessionId::new("s2")]);
722
723 let sessions_by_agent = vec![SessionByAgent {
724 agent_id: AgentId::new("agent-a"),
725 remote_connection: None,
726 sessions: vec![
727 make_session("s1", Some("T1"), Some(paths.clone()), None, None),
728 make_session("s2", Some("T2"), Some(paths), None, None),
729 ],
730 }];
731
732 let result = collect_importable_threads(sessions_by_agent, existing);
733 assert!(result.is_empty());
734 }
735}