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 worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
568 remote_connection: remote_connection.clone(),
569 archived: true,
570 });
571 }
572 }
573 to_insert
574}
575
576pub fn import_threads_from_other_channels(_workspace: &mut Workspace, cx: &mut Context<Workspace>) {
577 let database_dir = paths::database_dir().clone();
578 import_threads_from_other_channels_in(database_dir, cx);
579}
580
581fn import_threads_from_other_channels_in(
582 database_dir: std::path::PathBuf,
583 cx: &mut Context<Workspace>,
584) {
585 let current_channel = ReleaseChannel::global(cx);
586
587 let existing_thread_ids: HashSet<ThreadId> = ThreadMetadataStore::global(cx)
588 .read(cx)
589 .entries()
590 .map(|metadata| metadata.thread_id)
591 .collect();
592
593 let workspace_handle = cx.weak_entity();
594 cx.spawn(async move |_this, cx| {
595 let mut imported_threads = Vec::new();
596
597 for channel in &ReleaseChannel::ALL {
598 if *channel == current_channel || *channel == ReleaseChannel::Dev {
599 continue;
600 }
601
602 match read_threads_from_channel(&database_dir, *channel) {
603 Ok(threads) => {
604 let new_threads = threads
605 .into_iter()
606 .filter(|thread| !existing_thread_ids.contains(&thread.thread_id));
607 imported_threads.extend(new_threads);
608 }
609 Err(error) => {
610 log::warn!(
611 "Failed to read threads from {} channel database: {}",
612 channel.dev_name(),
613 error
614 );
615 }
616 }
617 }
618
619 let imported_count = imported_threads.len();
620
621 cx.update(|cx| {
622 ThreadMetadataStore::global(cx)
623 .update(cx, |store, cx| store.save_all(imported_threads, cx));
624
625 show_cross_channel_import_toast(&workspace_handle, imported_count, cx);
626 })
627 })
628 .detach();
629}
630
631fn channel_has_threads(database_dir: &std::path::Path, channel: ReleaseChannel) -> bool {
632 let db_path = db::db_path(database_dir, channel);
633 if !db_path.exists() {
634 return false;
635 }
636 let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
637 connection
638 .select_row::<bool>("SELECT 1 FROM sidebar_threads LIMIT 1")
639 .ok()
640 .and_then(|mut query| query().ok().flatten())
641 .unwrap_or(false)
642}
643
644fn read_threads_from_channel(
645 database_dir: &std::path::Path,
646 channel: ReleaseChannel,
647) -> anyhow::Result<Vec<ThreadMetadata>> {
648 let db_path = db::db_path(database_dir, channel);
649 if !db_path.exists() {
650 return Ok(Vec::new());
651 }
652 let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
653 crate::thread_metadata_store::list_thread_metadata_from_connection(&connection)
654}
655
656fn show_cross_channel_import_toast(
657 workspace: &WeakEntity<Workspace>,
658 imported_count: usize,
659 cx: &mut App,
660) {
661 let status_toast = if imported_count == 0 {
662 StatusToast::new("No new threads found to import.", cx, |this, _cx| {
663 this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
664 .dismiss_button(true)
665 })
666 } else {
667 let message = if imported_count == 1 {
668 "Imported 1 thread from other channels.".to_string()
669 } else {
670 format!("Imported {imported_count} threads from other channels.")
671 };
672 StatusToast::new(message, cx, |this, _cx| {
673 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
674 .dismiss_button(true)
675 })
676 };
677
678 workspace
679 .update(cx, |workspace, cx| {
680 workspace.toggle_status_toast(status_toast, cx);
681 })
682 .log_err();
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use acp_thread::AgentSessionInfo;
689 use chrono::Utc;
690 use gpui::TestAppContext;
691 use std::path::Path;
692 use workspace::PathList;
693
694 fn make_session(
695 session_id: &str,
696 title: Option<&str>,
697 work_dirs: Option<PathList>,
698 updated_at: Option<chrono::DateTime<Utc>>,
699 created_at: Option<chrono::DateTime<Utc>>,
700 ) -> AgentSessionInfo {
701 AgentSessionInfo {
702 session_id: acp::SessionId::new(session_id),
703 title: title.map(|t| SharedString::from(t.to_string())),
704 work_dirs,
705 updated_at,
706 created_at,
707 meta: None,
708 }
709 }
710
711 #[test]
712 fn test_collect_skips_sessions_already_in_existing_set() {
713 let existing = HashSet::from_iter(vec![acp::SessionId::new("existing-1")]);
714 let paths = PathList::new(&[Path::new("/project")]);
715
716 let sessions_by_agent = vec![SessionByAgent {
717 agent_id: AgentId::new("agent-a"),
718 remote_connection: None,
719 sessions: vec![
720 make_session(
721 "existing-1",
722 Some("Already There"),
723 Some(paths.clone()),
724 None,
725 None,
726 ),
727 make_session("new-1", Some("Brand New"), Some(paths), None, None),
728 ],
729 }];
730
731 let result = collect_importable_threads(sessions_by_agent, existing);
732
733 assert_eq!(result.len(), 1);
734 assert_eq!(result[0].session_id.as_ref().unwrap().0.as_ref(), "new-1");
735 assert_eq!(result[0].display_title(), "Brand New");
736 }
737
738 #[test]
739 fn test_collect_skips_sessions_without_work_dirs() {
740 let existing = HashSet::default();
741 let paths = PathList::new(&[Path::new("/project")]);
742
743 let sessions_by_agent = vec![SessionByAgent {
744 agent_id: AgentId::new("agent-a"),
745 remote_connection: None,
746 sessions: vec![
747 make_session("has-dirs", Some("With Dirs"), Some(paths), None, None),
748 make_session("no-dirs", Some("No Dirs"), None, None, None),
749 ],
750 }];
751
752 let result = collect_importable_threads(sessions_by_agent, existing);
753
754 assert_eq!(result.len(), 1);
755 assert_eq!(
756 result[0].session_id.as_ref().unwrap().0.as_ref(),
757 "has-dirs"
758 );
759 }
760
761 #[test]
762 fn test_collect_marks_all_imported_threads_as_archived() {
763 let existing = HashSet::default();
764 let paths = PathList::new(&[Path::new("/project")]);
765
766 let sessions_by_agent = vec![SessionByAgent {
767 agent_id: AgentId::new("agent-a"),
768 remote_connection: None,
769 sessions: vec![
770 make_session("s1", Some("Thread 1"), Some(paths.clone()), None, None),
771 make_session("s2", Some("Thread 2"), Some(paths), None, None),
772 ],
773 }];
774
775 let result = collect_importable_threads(sessions_by_agent, existing);
776
777 assert_eq!(result.len(), 2);
778 assert!(result.iter().all(|t| t.archived));
779 }
780
781 #[test]
782 fn test_collect_assigns_correct_agent_id_per_session() {
783 let existing = HashSet::default();
784 let paths = PathList::new(&[Path::new("/project")]);
785
786 let sessions_by_agent = vec![
787 SessionByAgent {
788 agent_id: AgentId::new("agent-a"),
789 remote_connection: None,
790 sessions: vec![make_session(
791 "s1",
792 Some("From A"),
793 Some(paths.clone()),
794 None,
795 None,
796 )],
797 },
798 SessionByAgent {
799 agent_id: AgentId::new("agent-b"),
800 remote_connection: None,
801 sessions: vec![make_session("s2", Some("From B"), Some(paths), None, None)],
802 },
803 ];
804
805 let result = collect_importable_threads(sessions_by_agent, existing);
806
807 assert_eq!(result.len(), 2);
808 let s1 = result
809 .iter()
810 .find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s1"))
811 .unwrap();
812 let s2 = result
813 .iter()
814 .find(|t| t.session_id.as_ref().map(|s| s.0.as_ref()) == Some("s2"))
815 .unwrap();
816 assert_eq!(s1.agent_id.as_ref(), "agent-a");
817 assert_eq!(s2.agent_id.as_ref(), "agent-b");
818 }
819
820 #[test]
821 fn test_collect_deduplicates_across_agents() {
822 let existing = HashSet::default();
823 let paths = PathList::new(&[Path::new("/project")]);
824
825 let sessions_by_agent = vec![
826 SessionByAgent {
827 agent_id: AgentId::new("agent-a"),
828 remote_connection: None,
829 sessions: vec![make_session(
830 "shared-session",
831 Some("From A"),
832 Some(paths.clone()),
833 None,
834 None,
835 )],
836 },
837 SessionByAgent {
838 agent_id: AgentId::new("agent-b"),
839 remote_connection: None,
840 sessions: vec![make_session(
841 "shared-session",
842 Some("From B"),
843 Some(paths),
844 None,
845 None,
846 )],
847 },
848 ];
849
850 let result = collect_importable_threads(sessions_by_agent, existing);
851
852 assert_eq!(result.len(), 1);
853 assert_eq!(
854 result[0].session_id.as_ref().unwrap().0.as_ref(),
855 "shared-session"
856 );
857 assert_eq!(
858 result[0].agent_id.as_ref(),
859 "agent-a",
860 "first agent encountered should win"
861 );
862 }
863
864 #[test]
865 fn test_collect_all_existing_returns_empty() {
866 let paths = PathList::new(&[Path::new("/project")]);
867 let existing =
868 HashSet::from_iter(vec![acp::SessionId::new("s1"), acp::SessionId::new("s2")]);
869
870 let sessions_by_agent = vec![SessionByAgent {
871 agent_id: AgentId::new("agent-a"),
872 remote_connection: None,
873 sessions: vec![
874 make_session("s1", Some("T1"), Some(paths.clone()), None, None),
875 make_session("s2", Some("T2"), Some(paths), None, None),
876 ],
877 }];
878
879 let result = collect_importable_threads(sessions_by_agent, existing);
880 assert!(result.is_empty());
881 }
882
883 fn create_channel_db(
884 db_dir: &std::path::Path,
885 channel: ReleaseChannel,
886 ) -> db::sqlez::connection::Connection {
887 let db_path = db::db_path(db_dir, channel);
888 std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
889 let connection = db::sqlez::connection::Connection::open_file(&db_path.to_string_lossy());
890 crate::thread_metadata_store::run_thread_metadata_migrations(&connection);
891 connection
892 }
893
894 fn insert_thread(
895 connection: &db::sqlez::connection::Connection,
896 title: &str,
897 updated_at: &str,
898 archived: bool,
899 ) {
900 let thread_id = uuid::Uuid::new_v4();
901 let session_id = uuid::Uuid::new_v4().to_string();
902 connection
903 .exec_bound::<(uuid::Uuid, &str, &str, &str, bool)>(
904 "INSERT INTO sidebar_threads \
905 (thread_id, session_id, title, updated_at, archived) \
906 VALUES (?1, ?2, ?3, ?4, ?5)",
907 )
908 .unwrap()((thread_id, session_id.as_str(), title, updated_at, archived))
909 .unwrap();
910 }
911
912 #[test]
913 fn test_returns_empty_when_channel_db_missing() {
914 let dir = tempfile::tempdir().unwrap();
915 let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
916 assert!(threads.is_empty());
917 }
918
919 #[test]
920 fn test_preserves_archived_state() {
921 let dir = tempfile::tempdir().unwrap();
922 let connection = create_channel_db(dir.path(), ReleaseChannel::Nightly);
923
924 insert_thread(&connection, "Active Thread", "2025-01-15T10:00:00Z", false);
925 insert_thread(&connection, "Archived Thread", "2025-01-15T09:00:00Z", true);
926 drop(connection);
927
928 let threads = read_threads_from_channel(dir.path(), ReleaseChannel::Nightly).unwrap();
929 assert_eq!(threads.len(), 2);
930
931 let active = threads
932 .iter()
933 .find(|t| t.display_title().as_ref() == "Active Thread")
934 .unwrap();
935 assert!(!active.archived);
936
937 let archived = threads
938 .iter()
939 .find(|t| t.display_title().as_ref() == "Archived Thread")
940 .unwrap();
941 assert!(archived.archived);
942 }
943
944 fn init_test(cx: &mut TestAppContext) {
945 let fs = fs::FakeFs::new(cx.executor());
946 cx.update(|cx| {
947 let settings_store = settings::SettingsStore::test(cx);
948 cx.set_global(settings_store);
949 theme_settings::init(theme::LoadThemes::JustBase, cx);
950 release_channel::init("0.0.0".parse().unwrap(), cx);
951 <dyn fs::Fs>::set_global(fs, cx);
952 ThreadMetadataStore::init_global(cx);
953 });
954 cx.run_until_parked();
955 }
956
957 #[gpui::test]
958 async fn test_import_threads_from_other_channels(cx: &mut TestAppContext) {
959 init_test(cx);
960
961 let dir = tempfile::tempdir().unwrap();
962 let database_dir = dir.path().to_path_buf();
963
964 // Set up a "preview" database with two threads.
965 let preview_db = create_channel_db(dir.path(), ReleaseChannel::Preview);
966 insert_thread(
967 &preview_db,
968 "Preview Thread 1",
969 "2025-01-15T10:00:00Z",
970 false,
971 );
972 insert_thread(
973 &preview_db,
974 "Preview Thread 2",
975 "2025-01-15T11:00:00Z",
976 true,
977 );
978 drop(preview_db);
979
980 // Set up a "nightly" database with one thread.
981 let nightly_db = create_channel_db(dir.path(), ReleaseChannel::Nightly);
982 insert_thread(&nightly_db, "Nightly Thread", "2025-01-15T12:00:00Z", false);
983 drop(nightly_db);
984
985 // Create a workspace and run the import.
986 let fs = fs::FakeFs::new(cx.executor());
987 let project = project::Project::test(fs, [], cx).await;
988 let multi_workspace =
989 cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
990 let workspace_entity = multi_workspace
991 .read_with(cx, |mw, _cx| mw.workspace().clone())
992 .unwrap();
993 let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
994
995 workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
996 import_threads_from_other_channels_in(database_dir, cx);
997 });
998 cx.run_until_parked();
999
1000 // Verify all three threads were imported into the store.
1001 cx.update(|cx| {
1002 let store = ThreadMetadataStore::global(cx);
1003 let store = store.read(cx);
1004 let titles: collections::HashSet<String> = store
1005 .entries()
1006 .map(|m| m.display_title().to_string())
1007 .collect();
1008
1009 assert_eq!(titles.len(), 3);
1010 assert!(titles.contains("Preview Thread 1"));
1011 assert!(titles.contains("Preview Thread 2"));
1012 assert!(titles.contains("Nightly Thread"));
1013
1014 // Verify archived state is preserved.
1015 let preview_2 = store
1016 .entries()
1017 .find(|m| m.display_title().as_ref() == "Preview Thread 2")
1018 .unwrap();
1019 assert!(preview_2.archived);
1020
1021 let nightly = store
1022 .entries()
1023 .find(|m| m.display_title().as_ref() == "Nightly Thread")
1024 .unwrap();
1025 assert!(!nightly.archived);
1026 });
1027 }
1028
1029 #[gpui::test]
1030 async fn test_import_skips_already_existing_threads(cx: &mut TestAppContext) {
1031 init_test(cx);
1032
1033 let dir = tempfile::tempdir().unwrap();
1034 let database_dir = dir.path().to_path_buf();
1035
1036 // Set up a "preview" database with threads.
1037 let preview_db = create_channel_db(dir.path(), ReleaseChannel::Preview);
1038 insert_thread(&preview_db, "Thread A", "2025-01-15T10:00:00Z", false);
1039 insert_thread(&preview_db, "Thread B", "2025-01-15T11:00:00Z", false);
1040 drop(preview_db);
1041
1042 // Read the threads so we can pre-populate one into the store.
1043 let preview_threads =
1044 read_threads_from_channel(dir.path(), ReleaseChannel::Preview).unwrap();
1045 let thread_a = preview_threads
1046 .iter()
1047 .find(|t| t.display_title().as_ref() == "Thread A")
1048 .unwrap()
1049 .clone();
1050
1051 // Pre-populate Thread A into the store.
1052 cx.update(|cx| {
1053 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(thread_a, cx));
1054 });
1055 cx.run_until_parked();
1056
1057 // Run the import.
1058 let fs = fs::FakeFs::new(cx.executor());
1059 let project = project::Project::test(fs, [], cx).await;
1060 let multi_workspace =
1061 cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
1062 let workspace_entity = multi_workspace
1063 .read_with(cx, |mw, _cx| mw.workspace().clone())
1064 .unwrap();
1065 let mut vcx = gpui::VisualTestContext::from_window(multi_workspace.into(), cx);
1066
1067 workspace_entity.update_in(&mut vcx, |_workspace, _window, cx| {
1068 import_threads_from_other_channels_in(database_dir, cx);
1069 });
1070 cx.run_until_parked();
1071
1072 // Verify only Thread B was added (Thread A already existed).
1073 cx.update(|cx| {
1074 let store = ThreadMetadataStore::global(cx);
1075 let store = store.read(cx);
1076 assert_eq!(store.entries().count(), 2);
1077
1078 let titles: collections::HashSet<String> = store
1079 .entries()
1080 .map(|m| m.display_title().to_string())
1081 .collect();
1082 assert!(titles.contains("Thread A"));
1083 assert!(titles.contains("Thread B"));
1084 });
1085 }
1086}