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