1use acp_thread::AgentSessionListRequest;
2use agent::ThreadStore;
3use agent_client_protocol as acp;
4use chrono::Utc;
5use collections::HashSet;
6use db::kvp::Dismissable;
7use db::sqlez;
8use fs::Fs;
9use futures::FutureExt as _;
10use gpui::{
11 App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
12 Render, SharedString, Task, WeakEntity, Window,
13};
14use notifications::status_toast::{StatusToast, ToastIcon};
15use project::{AgentId, AgentRegistryStore, AgentServerStore};
16use release_channel::ReleaseChannel;
17use remote::RemoteConnectionOptions;
18use ui::{
19 Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section,
20 prelude::*,
21};
22use util::ResultExt;
23use workspace::{ModalView, MultiWorkspace, Workspace};
24
25use crate::{
26 Agent, AgentPanel,
27 agent_connection_store::AgentConnectionStore,
28 thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore, WorktreePaths},
29};
30
31pub struct AcpThreadImportOnboarding;
32pub struct CrossChannelImportOnboarding;
33
34impl AcpThreadImportOnboarding {
35 pub fn dismissed(cx: &App) -> bool {
36 <Self as Dismissable>::dismissed(cx)
37 }
38
39 pub fn dismiss(cx: &mut App) {
40 <Self as Dismissable>::set_dismissed(true, cx);
41 }
42}
43
44impl Dismissable for AcpThreadImportOnboarding {
45 const KEY: &'static str = "dismissed-acp-thread-import";
46}
47
48impl CrossChannelImportOnboarding {
49 pub fn dismissed(cx: &App) -> bool {
50 <Self as Dismissable>::dismissed(cx)
51 }
52
53 pub fn dismiss(cx: &mut App) {
54 <Self as Dismissable>::set_dismissed(true, cx);
55 }
56}
57
58impl Dismissable for CrossChannelImportOnboarding {
59 const KEY: &'static str = "dismissed-cross-channel-thread-import";
60}
61
62/// Returns the list of non-Dev, non-current release channels that have
63/// at least one thread in their database. The result is suitable for
64/// building a user-facing message ("from Zed Preview and Nightly").
65pub fn channels_with_threads(cx: &App) -> Vec<ReleaseChannel> {
66 let Some(current_channel) = ReleaseChannel::try_global(cx) else {
67 return Vec::new();
68 };
69 let database_dir = paths::database_dir();
70
71 ReleaseChannel::ALL
72 .iter()
73 .copied()
74 .filter(|channel| {
75 *channel != current_channel
76 && *channel != ReleaseChannel::Dev
77 && channel_has_threads(database_dir, *channel)
78 })
79 .collect()
80}
81
82#[derive(Clone)]
83struct AgentEntry {
84 agent_id: AgentId,
85 display_name: SharedString,
86 icon_path: Option<SharedString>,
87}
88
89pub struct ThreadImportModal {
90 focus_handle: FocusHandle,
91 workspace: WeakEntity<Workspace>,
92 multi_workspace: WeakEntity<MultiWorkspace>,
93 agent_entries: Vec<AgentEntry>,
94 unchecked_agents: HashSet<AgentId>,
95 selected_index: Option<usize>,
96 is_importing: bool,
97 last_error: Option<SharedString>,
98}
99
100impl ThreadImportModal {
101 pub fn new(
102 agent_server_store: Entity<AgentServerStore>,
103 agent_registry_store: Entity<AgentRegistryStore>,
104 workspace: WeakEntity<Workspace>,
105 multi_workspace: WeakEntity<MultiWorkspace>,
106 _window: &mut Window,
107 cx: &mut Context<Self>,
108 ) -> Self {
109 AcpThreadImportOnboarding::dismiss(cx);
110
111 let agent_entries = agent_server_store
112 .read(cx)
113 .external_agents()
114 .map(|agent_id| {
115 let display_name = agent_server_store
116 .read(cx)
117 .agent_display_name(agent_id)
118 .or_else(|| {
119 agent_registry_store
120 .read(cx)
121 .agent(agent_id)
122 .map(|agent| agent.name().clone())
123 })
124 .unwrap_or_else(|| agent_id.0.clone());
125 let icon_path = agent_server_store
126 .read(cx)
127 .agent_icon(agent_id)
128 .or_else(|| {
129 agent_registry_store
130 .read(cx)
131 .agent(agent_id)
132 .and_then(|agent| agent.icon_path().cloned())
133 });
134
135 AgentEntry {
136 agent_id: agent_id.clone(),
137 display_name,
138 icon_path,
139 }
140 })
141 .collect::<Vec<_>>();
142
143 Self {
144 focus_handle: cx.focus_handle(),
145 workspace,
146 multi_workspace,
147 agent_entries,
148 unchecked_agents: HashSet::default(),
149 selected_index: None,
150 is_importing: false,
151 last_error: None,
152 }
153 }
154
155 fn agent_ids(&self) -> Vec<AgentId> {
156 self.agent_entries
157 .iter()
158 .map(|entry| entry.agent_id.clone())
159 .collect()
160 }
161
162 fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context<Self>) {
163 if self.unchecked_agents.contains(&agent_id) {
164 self.unchecked_agents.remove(&agent_id);
165 } else {
166 self.unchecked_agents.insert(agent_id);
167 }
168 cx.notify();
169 }
170
171 fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
172 if self.agent_entries.is_empty() {
173 return;
174 }
175 self.selected_index = Some(match self.selected_index {
176 Some(ix) if ix + 1 >= self.agent_entries.len() => 0,
177 Some(ix) => ix + 1,
178 None => 0,
179 });
180 cx.notify();
181 }
182
183 fn select_previous(
184 &mut self,
185 _: &menu::SelectPrevious,
186 _window: &mut Window,
187 cx: &mut Context<Self>,
188 ) {
189 if self.agent_entries.is_empty() {
190 return;
191 }
192 self.selected_index = Some(match self.selected_index {
193 Some(0) => self.agent_entries.len() - 1,
194 Some(ix) => ix - 1,
195 None => self.agent_entries.len() - 1,
196 });
197 cx.notify();
198 }
199
200 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
201 if let Some(ix) = self.selected_index {
202 if let Some(entry) = self.agent_entries.get(ix) {
203 self.toggle_agent_checked(entry.agent_id.clone(), cx);
204 }
205 }
206 }
207
208 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
209 cx.emit(DismissEvent);
210 }
211
212 fn import_threads(
213 &mut self,
214 _: &menu::SecondaryConfirm,
215 _: &mut Window,
216 cx: &mut Context<Self>,
217 ) {
218 if self.is_importing {
219 return;
220 }
221
222 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
223 self.is_importing = false;
224 cx.notify();
225 return;
226 };
227
228 let stores = resolve_agent_connection_stores(&multi_workspace, cx);
229 if stores.is_empty() {
230 log::error!("Did not find any workspaces to import from");
231 self.is_importing = false;
232 cx.notify();
233 return;
234 }
235
236 self.is_importing = true;
237 self.last_error = None;
238 cx.notify();
239
240 let agent_ids = self
241 .agent_ids()
242 .into_iter()
243 .filter(|agent_id| !self.unchecked_agents.contains(agent_id))
244 .collect::<Vec<_>>();
245
246 let existing_sessions: HashSet<acp::SessionId> = ThreadMetadataStore::global(cx)
247 .read(cx)
248 .entries()
249 .filter_map(|m| m.session_id.clone())
250 .collect();
251
252 let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx);
253 cx.spawn(async move |this, cx| {
254 let result = task.await;
255 this.update(cx, |this, cx| match result {
256 Ok(threads) => {
257 let imported_count = threads.len();
258 ThreadMetadataStore::global(cx)
259 .update(cx, |store, cx| store.save_all(threads, cx));
260 this.is_importing = false;
261 this.last_error = None;
262 this.show_imported_threads_toast(imported_count, cx);
263 cx.emit(DismissEvent);
264 }
265 Err(error) => {
266 this.is_importing = false;
267 this.last_error = Some(error.to_string().into());
268 cx.notify();
269 }
270 })
271 })
272 .detach_and_log_err(cx);
273 }
274
275 fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) {
276 let status_toast = if imported_count == 0 {
277 StatusToast::new("No threads found to import.", cx, |this, _cx| {
278 this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
279 .dismiss_button(true)
280 })
281 } else {
282 let message = if imported_count == 1 {
283 "Imported 1 thread.".to_string()
284 } else {
285 format!("Imported {imported_count} threads.")
286 };
287 StatusToast::new(message, cx, |this, _cx| {
288 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
289 .dismiss_button(true)
290 })
291 };
292
293 self.workspace
294 .update(cx, |workspace, cx| {
295 workspace.toggle_status_toast(status_toast, cx);
296 })
297 .log_err();
298 }
299}
300
301impl EventEmitter<DismissEvent> for ThreadImportModal {}
302
303impl Focusable for ThreadImportModal {
304 fn focus_handle(&self, _cx: &App) -> FocusHandle {
305 self.focus_handle.clone()
306 }
307}
308
309impl ModalView for ThreadImportModal {}
310
311impl Render for ThreadImportModal {
312 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
313 let has_agents = !self.agent_entries.is_empty();
314 let disabled_import_thread = self.is_importing
315 || !has_agents
316 || self.unchecked_agents.len() == self.agent_entries.len();
317
318 let agent_rows = self
319 .agent_entries
320 .iter()
321 .enumerate()
322 .map(|(ix, entry)| {
323 let is_checked = !self.unchecked_agents.contains(&entry.agent_id);
324 let is_focused = self.selected_index == Some(ix);
325
326 ListItem::new(("thread-import-agent", ix))
327 .rounded()
328 .spacing(ListItemSpacing::Sparse)
329 .focused(is_focused)
330 .disabled(self.is_importing)
331 .child(
332 h_flex()
333 .w_full()
334 .gap_2()
335 .when(!is_checked, |this| this.opacity(0.6))
336 .child(if let Some(icon_path) = entry.icon_path.clone() {
337 Icon::from_external_svg(icon_path)
338 .color(Color::Muted)
339 .size(IconSize::Small)
340 } else {
341 Icon::new(IconName::Sparkle)
342 .color(Color::Muted)
343 .size(IconSize::Small)
344 })
345 .child(Label::new(entry.display_name.clone())),
346 )
347 .end_slot(Checkbox::new(
348 ("thread-import-agent-checkbox", ix),
349 if is_checked {
350 ToggleState::Selected
351 } else {
352 ToggleState::Unselected
353 },
354 ))
355 .on_click({
356 let agent_id = entry.agent_id.clone();
357 cx.listener(move |this, _event, _window, cx| {
358 this.toggle_agent_checked(agent_id.clone(), cx);
359 })
360 })
361 })
362 .collect::<Vec<_>>();
363
364 v_flex()
365 .id("thread-import-modal")
366 .key_context("ThreadImportModal")
367 .w(rems(34.))
368 .elevation_3(cx)
369 .overflow_hidden()
370 .track_focus(&self.focus_handle)
371 .on_action(cx.listener(Self::cancel))
372 .on_action(cx.listener(Self::confirm))
373 .on_action(cx.listener(Self::select_next))
374 .on_action(cx.listener(Self::select_previous))
375 .on_action(cx.listener(Self::import_threads))
376 .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
377 this.focus_handle.focus(window, cx);
378 }))
379 .child(
380 Modal::new("import-threads", None)
381 .header(
382 ModalHeader::new()
383 .headline("Import External Agent Threads")
384 .description(
385 "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \
386 Choose which agents to include, and their threads will appear in your archive."
387 )
388 .show_dismiss_button(true),
389
390 )
391 .section(
392 Section::new().child(
393 v_flex()
394 .id("thread-import-agent-list")
395 .max_h(rems_from_px(320.))
396 .pb_1()
397 .overflow_y_scroll()
398 .when(has_agents, |this| this.children(agent_rows))
399 .when(!has_agents, |this| {
400 this.child(
401 Label::new("No ACP agents available.")
402 .color(Color::Muted)
403 .size(LabelSize::Small),
404 )
405 }),
406 ),
407 )
408 .footer(
409 ModalFooter::new()
410 .when_some(self.last_error.clone(), |this, error| {
411 this.start_slot(
412 Label::new(error)
413 .size(LabelSize::Small)
414 .color(Color::Error)
415 .truncate(),
416 )
417 })
418 .end_slot(
419 Button::new("import-threads", "Import Threads")
420 .loading(self.is_importing)
421 .disabled(disabled_import_thread)
422 .key_binding(
423 KeyBinding::for_action(&menu::SecondaryConfirm, cx)
424 .map(|kb| kb.size(rems_from_px(12.))),
425 )
426 .on_click(cx.listener(|this, _, window, cx| {
427 this.import_threads(&menu::SecondaryConfirm, window, cx);
428 })),
429 ),
430 ),
431 )
432 }
433}
434
435fn resolve_agent_connection_stores(
436 multi_workspace: &Entity<MultiWorkspace>,
437 cx: &App,
438) -> Vec<Entity<AgentConnectionStore>> {
439 let mut stores = Vec::new();
440 let mut included_local_store = false;
441
442 for workspace in multi_workspace.read(cx).workspaces() {
443 let workspace = workspace.read(cx);
444 let project = workspace.project().read(cx);
445
446 // We only want to include scores from one local workspace, since we
447 // know that they live on the same machine
448 let include_store = if project.is_remote() {
449 true
450 } else if project.is_local() && !included_local_store {
451 included_local_store = true;
452 true
453 } else {
454 false
455 };
456
457 if !include_store {
458 continue;
459 }
460
461 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
462 stores.push(panel.read(cx).connection_store().clone());
463 }
464 }
465
466 stores
467}
468
469fn find_threads_to_import(
470 agent_ids: Vec<AgentId>,
471 existing_sessions: HashSet<acp::SessionId>,
472 stores: Vec<Entity<AgentConnectionStore>>,
473 cx: &mut App,
474) -> Task<anyhow::Result<Vec<ThreadMetadata>>> {
475 let mut wait_for_connection_tasks = Vec::new();
476
477 for store in stores {
478 let remote_connection = store
479 .read(cx)
480 .project()
481 .read(cx)
482 .remote_connection_options(cx);
483
484 for agent_id in agent_ids.clone() {
485 let agent = Agent::from(agent_id.clone());
486 let server = agent.server(<dyn Fs>::global(cx), ThreadStore::global(cx));
487 let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx));
488
489 wait_for_connection_tasks.push(entry.read(cx).wait_for_connection().map({
490 let remote_connection = remote_connection.clone();
491 move |state| (agent_id, remote_connection, state)
492 }));
493 }
494 }
495
496 let mut session_list_tasks = Vec::new();
497 cx.spawn(async move |cx| {
498 let results = futures::future::join_all(wait_for_connection_tasks).await;
499 for (agent_id, remote_connection, result) in results {
500 let Some(state) = result.log_err() else {
501 continue;
502 };
503 let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else {
504 continue;
505 };
506 let task = cx.update(|cx| {
507 list.list_sessions(AgentSessionListRequest::default(), cx)
508 .map({
509 let remote_connection = remote_connection.clone();
510 move |response| (agent_id, remote_connection, response)
511 })
512 });
513 session_list_tasks.push(task);
514 }
515
516 let mut sessions_by_agent = Vec::new();
517 let results = futures::future::join_all(session_list_tasks).await;
518 for (agent_id, remote_connection, result) in results {
519 let Some(response) = result.log_err() else {
520 continue;
521 };
522 sessions_by_agent.push(SessionByAgent {
523 agent_id,
524 remote_connection,
525 sessions: response.sessions,
526 });
527 }
528
529 Ok(collect_importable_threads(
530 sessions_by_agent,
531 existing_sessions,
532 ))
533 })
534}
535
536struct SessionByAgent {
537 agent_id: AgentId,
538 remote_connection: Option<RemoteConnectionOptions>,
539 sessions: Vec<acp_thread::AgentSessionInfo>,
540}
541
542fn collect_importable_threads(
543 sessions_by_agent: Vec<SessionByAgent>,
544 mut existing_sessions: HashSet<acp::SessionId>,
545) -> Vec<ThreadMetadata> {
546 let mut to_insert = Vec::new();
547 for SessionByAgent {
548 agent_id,
549 remote_connection,
550 sessions,
551 } in sessions_by_agent
552 {
553 for session in sessions {
554 if !existing_sessions.insert(session.session_id.clone()) {
555 continue;
556 }
557 let Some(folder_paths) = session.work_dirs else {
558 continue;
559 };
560 to_insert.push(ThreadMetadata {
561 thread_id: ThreadId::new(),
562 session_id: Some(session.session_id),
563 agent_id: agent_id.clone(),
564 title: session.title,
565 updated_at: session.updated_at.unwrap_or_else(|| Utc::now()),
566 created_at: session.created_at,
567 interacted_at: None,
568 worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
569 remote_connection: remote_connection.clone(),
570 archived: true,
571 });
572 }
573 }
574 to_insert
575}
576
577pub fn import_threads_from_other_channels(_workspace: &mut Workspace, cx: &mut Context<Workspace>) {
578 let database_dir = paths::database_dir().clone();
579 import_threads_from_other_channels_in(database_dir, cx);
580}
581
582fn import_threads_from_other_channels_in(
583 database_dir: std::path::PathBuf,
584 cx: &mut Context<Workspace>,
585) {
586 let current_channel = ReleaseChannel::global(cx);
587
588 let existing_thread_ids: HashSet<ThreadId> = ThreadMetadataStore::global(cx)
589 .read(cx)
590 .entries()
591 .map(|metadata| metadata.thread_id)
592 .collect();
593
594 let workspace_handle = cx.weak_entity();
595 cx.spawn(async move |_this, cx| {
596 let mut imported_threads = Vec::new();
597
598 for channel in &ReleaseChannel::ALL {
599 if *channel == current_channel || *channel == ReleaseChannel::Dev {
600 continue;
601 }
602
603 match read_threads_from_channel(&database_dir, *channel) {
604 Ok(threads) => {
605 let new_threads = threads
606 .into_iter()
607 .filter(|thread| !existing_thread_ids.contains(&thread.thread_id));
608 imported_threads.extend(new_threads);
609 }
610 Err(error) => {
611 log::warn!(
612 "Failed to read threads from {} channel database: {}",
613 channel.dev_name(),
614 error
615 );
616 }
617 }
618 }
619
620 let imported_count = imported_threads.len();
621
622 cx.update(|cx| {
623 ThreadMetadataStore::global(cx)
624 .update(cx, |store, cx| store.save_all(imported_threads, cx));
625
626 show_cross_channel_import_toast(&workspace_handle, imported_count, cx);
627 })
628 })
629 .detach();
630}
631
632fn channel_has_threads(database_dir: &std::path::Path, channel: ReleaseChannel) -> bool {
633 let db_path = db::db_path(database_dir, channel);
634 if !db_path.exists() {
635 return false;
636 }
637 let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
638 connection
639 .select_row::<bool>("SELECT 1 FROM sidebar_threads LIMIT 1")
640 .ok()
641 .and_then(|mut query| query().ok().flatten())
642 .unwrap_or(false)
643}
644
645fn read_threads_from_channel(
646 database_dir: &std::path::Path,
647 channel: ReleaseChannel,
648) -> anyhow::Result<Vec<ThreadMetadata>> {
649 let db_path = db::db_path(database_dir, channel);
650 if !db_path.exists() {
651 return Ok(Vec::new());
652 }
653 let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
654 crate::thread_metadata_store::list_thread_metadata_from_connection(&connection)
655}
656
657fn show_cross_channel_import_toast(
658 workspace: &WeakEntity<Workspace>,
659 imported_count: usize,
660 cx: &mut App,
661) {
662 let status_toast = if imported_count == 0 {
663 StatusToast::new("No new threads found to import.", cx, |this, _cx| {
664 this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
665 .dismiss_button(true)
666 })
667 } else {
668 let message = if imported_count == 1 {
669 "Imported 1 thread from other channels.".to_string()
670 } else {
671 format!("Imported {imported_count} threads from other channels.")
672 };
673 StatusToast::new(message, cx, |this, _cx| {
674 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
675 .dismiss_button(true)
676 })
677 };
678
679 workspace
680 .update(cx, |workspace, cx| {
681 workspace.toggle_status_toast(status_toast, cx);
682 })
683 .log_err();
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use acp_thread::AgentSessionInfo;
690 use chrono::Utc;
691 use gpui::TestAppContext;
692 use std::path::Path;
693 use workspace::PathList;
694
695 fn make_session(
696 session_id: &str,
697 title: Option<&str>,
698 work_dirs: Option<PathList>,
699 updated_at: Option<chrono::DateTime<Utc>>,
700 created_at: Option<chrono::DateTime<Utc>>,
701 ) -> AgentSessionInfo {
702 AgentSessionInfo {
703 session_id: acp::SessionId::new(session_id),
704 title: title.map(|t| SharedString::from(t.to_string())),
705 work_dirs,
706 updated_at,
707 created_at,
708 meta: None,
709 }
710 }
711
712 #[test]
713 fn test_collect_skips_sessions_already_in_existing_set() {
714 let existing = HashSet::from_iter(vec![acp::SessionId::new("existing-1")]);
715 let paths = PathList::new(&[Path::new("/project")]);
716
717 let sessions_by_agent = vec![SessionByAgent {
718 agent_id: AgentId::new("agent-a"),
719 remote_connection: None,
720 sessions: vec![
721 make_session(
722 "existing-1",
723 Some("Already There"),
724 Some(paths.clone()),
725 None,
726 None,
727 ),
728 make_session("new-1", Some("Brand New"), Some(paths), None, None),
729 ],
730 }];
731
732 let result = collect_importable_threads(sessions_by_agent, existing);
733
734 assert_eq!(result.len(), 1);
735 assert_eq!(result[0].session_id.as_ref().unwrap().0.as_ref(), "new-1");
736 assert_eq!(result[0].display_title(), "Brand New");
737 }
738
739 #[test]
740 fn test_collect_skips_sessions_without_work_dirs() {
741 let existing = HashSet::default();
742 let paths = PathList::new(&[Path::new("/project")]);
743
744 let sessions_by_agent = vec![SessionByAgent {
745 agent_id: AgentId::new("agent-a"),
746 remote_connection: None,
747 sessions: vec![
748 make_session("has-dirs", Some("With Dirs"), Some(paths), None, None),
749 make_session("no-dirs", Some("No Dirs"), None, None, None),
750 ],
751 }];
752
753 let result = collect_importable_threads(sessions_by_agent, existing);
754
755 assert_eq!(result.len(), 1);
756 assert_eq!(
757 result[0].session_id.as_ref().unwrap().0.as_ref(),
758 "has-dirs"
759 );
760 }
761
762 #[test]
763 fn test_collect_marks_all_imported_threads_as_archived() {
764 let existing = HashSet::default();
765 let paths = PathList::new(&[Path::new("/project")]);
766
767 let sessions_by_agent = vec![SessionByAgent {
768 agent_id: AgentId::new("agent-a"),
769 remote_connection: None,
770 sessions: vec![
771 make_session("s1", Some("Thread 1"), Some(paths.clone()), None, None),
772 make_session("s2", Some("Thread 2"), Some(paths), None, None),
773 ],
774 }];
775
776 let result = collect_importable_threads(sessions_by_agent, existing);
777
778 assert_eq!(result.len(), 2);
779 assert!(result.iter().all(|t| t.archived));
780 }
781
782 #[test]
783 fn test_collect_assigns_correct_agent_id_per_session() {
784 let existing = HashSet::default();
785 let paths = PathList::new(&[Path::new("/project")]);
786
787 let sessions_by_agent = vec![
788 SessionByAgent {
789 agent_id: AgentId::new("agent-a"),
790 remote_connection: None,
791 sessions: vec![make_session(
792 "s1",
793 Some("From A"),
794 Some(paths.clone()),
795 None,
796 None,
797 )],
798 },
799 SessionByAgent {
800 agent_id: AgentId::new("agent-b"),
801 remote_connection: None,
802 sessions: vec![make_session("s2", Some("From B"), Some(paths), None, None)],
803 },
804 ];
805
806 let result = collect_importable_threads(sessions_by_agent, existing);
807
808 assert_eq!(result.len(), 2);
809 let s1 = result
810 .iter()
811 .find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s1"))
812 .unwrap();
813 let s2 = result
814 .iter()
815 .find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s2"))
816 .unwrap();
817 assert_eq!(s1.agent_id.as_ref(), "agent-a");
818 assert_eq!(s2.agent_id.as_ref(), "agent-b");
819 }
820
821 #[test]
822 fn test_collect_deduplicates_across_agents() {
823 let existing = HashSet::default();
824 let paths = PathList::new(&[Path::new("/project")]);
825
826 let sessions_by_agent = vec![
827 SessionByAgent {
828 agent_id: AgentId::new("agent-a"),
829 remote_connection: None,
830 sessions: vec![make_session(
831 "shared-session",
832 Some("From A"),
833 Some(paths.clone()),
834 None,
835 None,
836 )],
837 },
838 SessionByAgent {
839 agent_id: AgentId::new("agent-b"),
840 remote_connection: None,
841 sessions: vec![make_session(
842 "shared-session",
843 Some("From B"),
844 Some(paths),
845 None,
846 None,
847 )],
848 },
849 ];
850
851 let result = collect_importable_threads(sessions_by_agent, existing);
852
853 assert_eq!(result.len(), 1);
854 assert_eq!(
855 result[0].session_id.as_ref().unwrap().0.as_ref(),
856 "shared-session"
857 );
858 assert_eq!(
859 result[0].agent_id.as_ref(),
860 "agent-a",
861 "first agent encountered should win"
862 );
863 }
864
865 #[test]
866 fn test_collect_all_existing_returns_empty() {
867 let paths = PathList::new(&[Path::new("/project")]);
868 let existing =
869 HashSet::from_iter(vec![acp::SessionId::new("s1"), acp::SessionId::new("s2")]);
870
871 let sessions_by_agent = vec![SessionByAgent {
872 agent_id: AgentId::new("agent-a"),
873 remote_connection: None,
874 sessions: vec![
875 make_session("s1", Some("T1"), Some(paths.clone()), None, None),
876 make_session("s2", Some("T2"), Some(paths), None, None),
877 ],
878 }];
879
880 let result = collect_importable_threads(sessions_by_agent, existing);
881 assert!(result.is_empty());
882 }
883
884 fn create_channel_db(
885 db_dir: &std::path::Path,
886 channel: ReleaseChannel,
887 ) -> db::sqlez::connection::Connection {
888 let db_path = db::db_path(db_dir, channel);
889 std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
890 let connection = db::sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
891 crate::thread_metadata_store::run_thread_metadata_migrations(&connection);
892 connection
893 }
894
895 fn insert_thread(
896 connection: &db::sqlez::connection::Connection,
897 title: &str,
898 updated_at: &str,
899 archived: bool,
900 ) {
901 let thread_id = uuid::Uuid::new_v4();
902 let session_id = uuid::Uuid::new_v4().to_string();
903 connection
904 .exec_bound::<(uuid::Uuid, &str, &str, &str, bool)>(
905 "INSERT INTO sidebar_threads \
906 (thread_id, session_id, title, updated_at, archived) \
907 VALUES (?1, ?2, ?3, ?4, ?5)",
908 )
909 .unwrap()((thread_id, session_id.as_str(), title, updated_at, archived))
910 .unwrap();
911 }
912
913 #[test]
914 fn test_returns_empty_when_channel_db_missing() {
915 let dir = tempfile::tempdir().unwrap();
916 let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
917 assert!(threads.is_empty());
918 }
919
920 #[test]
921 fn test_preserves_archived_state() {
922 let dir = tempfile::tempdir().unwrap();
923 let connection = create_channel_db(dir.path(), ReleaseChannel::Nightly);
924
925 insert_thread(&connection, "Active Thread", "2025-01-15T10:00:00Z", false);
926 insert_thread(&connection, "Archived Thread", "2025-01-15T09:00:00Z", true);
927 drop(connection);
928
929 let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
930 assert_eq!(threads.len(), 2);
931
932 let active = threads
933 .iter()
934 .find(|t| t.display_title().as_ref() == "Active Thread")
935 .unwrap();
936 assert!(!active.archived);
937
938 let archived = threads
939 .iter()
940 .find(|t| t.display_title().as_ref() == "Archived Thread")
941 .unwrap();
942 assert!(archived.archived);
943 }
944
945 fn init_test(cx: &mut TestAppContext) {
946 let fs = fs::FakeFs::new(cx.executor());
947 cx.update(|cx| {
948 let settings_store = settings::SettingsStore::test(cx);
949 cx.set_global(settings_store);
950 theme_settings::init(theme::LoadThemes::JustBase, cx);
951 release_channel::init("0.0.0".parse().unwrap(), cx);
952 <dyn fs::Fs>::set_global(fs, cx);
953 ThreadMetadataStore::init_global(cx);
954 });
955 cx.run_until_parked();
956 }
957
958 /// Returns two release channels that are not the current one and not Dev.
959 /// This ensures tests work regardless of which release channel branch
960 /// they run on.
961 fn foreign_channels(cx: &TestAppContext) -> (ReleaseChannel, ReleaseChannel) {
962 let current = cx.update(|cx| ReleaseChannel::global(cx));
963 let mut channels = ReleaseChannel::ALL
964 .iter()
965 .copied()
966 .filter(|ch| *ch != current && *ch != ReleaseChannel::Dev);
967 (channels.next().unwrap(), channels.next().unwrap())
968 }
969
970 #[gpui::test]
971 async fn test_import_threads_from_other_channels(cx: &mut TestAppContext) {
972 init_test(cx);
973
974 let dir = tempfile::tempdir().unwrap();
975 let database_dir = dir.path().to_path_buf();
976
977 let (channel_a, channel_b) = foreign_channels(cx);
978
979 // Set up databases for two foreign channels.
980 let db_a = create_channel_db(dir.path(), channel_a);
981 insert_thread(&db_a, "Thread A1", "2025-01-15T10:00:00Z", false);
982 insert_thread(&db_a, "Thread A2", "2025-01-15T11:00:00Z", true);
983 drop(db_a);
984
985 let db_b = create_channel_db(dir.path(), channel_b);
986 insert_thread(&db_b, "Thread B1", "2025-01-15T12:00:00Z", false);
987 drop(db_b);
988
989 // Create a workspace and run the import.
990 let fs = fs::FakeFs::new(cx.executor());
991 let project = project::Project::test(fs, [], cx).await;
992 let multi_workspace =
993 cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
994 let workspace_entity = multi_workspace
995 .read_with(cx, |mw, _cx| mw.workspace().clone())
996 .unwrap();
997 let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
998
999 workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
1000 import_threads_from_other_channels_in(database_dir, cx);
1001 });
1002 cx.run_until_parked();
1003
1004 // Verify all three threads were imported into the store.
1005 cx.update(|cx| {
1006 let store = ThreadMetadataStore::global(cx);
1007 let store = store.read(cx);
1008 let titles: collections::HashSet<String> = store
1009 .entries()
1010 .map(|m| m.display_title().to_string())
1011 .collect();
1012
1013 assert_eq!(titles.len(), 3);
1014 assert!(titles.contains("Thread A1"));
1015 assert!(titles.contains("Thread A2"));
1016 assert!(titles.contains("Thread B1"));
1017
1018 // Verify archived state is preserved.
1019 let thread_a2 = store
1020 .entries()
1021 .find(|m| m.display_title().as_ref() == "Thread A2")
1022 .unwrap();
1023 assert!(thread_a2.archived);
1024
1025 let thread_b1 = store
1026 .entries()
1027 .find(|m| m.display_title().as_ref() == "Thread B1")
1028 .unwrap();
1029 assert!(!thread_b1.archived);
1030 });
1031 }
1032
1033 #[gpui::test]
1034 async fn test_import_skips_already_existing_threads(cx: &mut TestAppContext) {
1035 init_test(cx);
1036
1037 let dir = tempfile::tempdir().unwrap();
1038 let database_dir = dir.path().to_path_buf();
1039
1040 let (channel_a, _) = foreign_channels(cx);
1041
1042 // Set up a database for a foreign channel.
1043 let db_a = create_channel_db(dir.path(), channel_a);
1044 insert_thread(&db_a, "Thread A", "2025-01-15T10:00:00Z", false);
1045 insert_thread(&db_a, "Thread B", "2025-01-15T11:00:00Z", false);
1046 drop(db_a);
1047
1048 // Read the threads so we can pre-populate one into the store.
1049 let foreign_threads = read_threads_from_channel(dir.path(), channel_a).unwrap();
1050 let thread_a = foreign_threads
1051 .iter()
1052 .find(|t| t.display_title().as_ref() == "Thread A")
1053 .unwrap()
1054 .clone();
1055
1056 // Pre-populate Thread A into the store.
1057 cx.update(|cx| {
1058 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(thread_a, cx));
1059 });
1060 cx.run_until_parked();
1061
1062 // Run the import.
1063 let fs = fs::FakeFs::new(cx.executor());
1064 let project = project::Project::test(fs, [], cx).await;
1065 let multi_workspace =
1066 cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
1067 let workspace_entity = multi_workspace
1068 .read_with(cx, |mw, _cx| mw.workspace().clone())
1069 .unwrap();
1070 let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
1071
1072 workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
1073 import_threads_from_other_channels_in(database_dir, cx);
1074 });
1075 cx.run_until_parked();
1076
1077 // Verify only Thread B was added (Thread A already existed).
1078 cx.update(|cx| {
1079 let store = ThreadMetadataStore::global(cx);
1080 let store = store.read(cx);
1081 assert_eq!(store.entries().count(), 2);
1082
1083 let titles: collections::HashSet<String> = store
1084 .entries()
1085 .map(|m| m.display_title().to_string())
1086 .collect();
1087 assert!(titles.contains("Thread A"));
1088 assert!(titles.contains("Thread B"));
1089 });
1090 }
1091}