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