1use super::*;
2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
3use agent::ThreadStore;
4use agent_ui::{
5 ThreadId,
6 test_support::{active_session_id, open_thread_with_connection, send_message},
7 thread_metadata_store::{ThreadMetadata, WorktreePaths},
8};
9use chrono::DateTime;
10use fs::{FakeFs, Fs};
11use gpui::TestAppContext;
12use pretty_assertions::assert_eq;
13use project::AgentId;
14use settings::SettingsStore;
15use std::{
16 path::{Path, PathBuf},
17 sync::Arc,
18};
19use util::{path_list::PathList, rel_path::rel_path};
20
21fn init_test(cx: &mut TestAppContext) {
22 cx.update(|cx| {
23 let settings_store = SettingsStore::test(cx);
24 cx.set_global(settings_store);
25 theme_settings::init(theme::LoadThemes::JustBase, cx);
26 editor::init(cx);
27 ThreadStore::init_global(cx);
28 ThreadMetadataStore::init_global(cx);
29 language_model::LanguageModelRegistry::test(cx);
30 prompt_store::init(cx);
31 });
32}
33
34#[track_caller]
35fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
36 let active = sidebar.active_entry.as_ref();
37 let matches = active.is_some_and(|entry| {
38 // Match by session_id directly on active_entry.
39 entry.session_id.as_ref() == Some(session_id)
40 // Or match by finding the thread in sidebar entries.
41 || sidebar.contents.entries.iter().any(|list_entry| {
42 matches!(list_entry, ListEntry::Thread(t)
43 if t.metadata.session_id.as_ref() == Some(session_id)
44 && entry.matches_entry(list_entry))
45 })
46 });
47 assert!(
48 matches,
49 "{msg}: expected active_entry for session {session_id:?}, got {:?}",
50 active,
51 );
52}
53
54#[track_caller]
55fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
56 let thread_id = sidebar
57 .contents
58 .entries
59 .iter()
60 .find_map(|entry| match entry {
61 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
62 Some(t.metadata.thread_id)
63 }
64 _ => None,
65 });
66 match thread_id {
67 Some(tid) => {
68 matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
69 }
70 // Thread not in sidebar entries — can't confirm it's active.
71 None => false,
72 }
73}
74
75#[track_caller]
76fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
77 assert!(
78 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
79 "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
80 workspace.entity_id(),
81 sidebar.active_entry,
82 );
83}
84
85fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
86 sidebar
87 .contents
88 .entries
89 .iter()
90 .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
91}
92
93#[track_caller]
94fn assert_remote_project_integration_sidebar_state(
95 sidebar: &mut Sidebar,
96 main_thread_id: &acp::SessionId,
97 remote_thread_id: &acp::SessionId,
98) {
99 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
100 if let ListEntry::ProjectHeader { label, .. } = entry {
101 Some(label.as_ref())
102 } else {
103 None
104 }
105 });
106
107 let Some(project_header) = project_headers.next() else {
108 panic!("expected exactly one sidebar project header named `project`, found none");
109 };
110 assert_eq!(
111 project_header, "project",
112 "expected the only sidebar project header to be `project`"
113 );
114 if let Some(unexpected_header) = project_headers.next() {
115 panic!(
116 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
117 );
118 }
119
120 let mut saw_main_thread = false;
121 let mut saw_remote_thread = false;
122 for entry in &sidebar.contents.entries {
123 match entry {
124 ListEntry::ProjectHeader { label, .. } => {
125 assert_eq!(
126 label.as_ref(),
127 "project",
128 "expected the only sidebar project header to be `project`"
129 );
130 }
131 ListEntry::Thread(thread)
132 if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
133 {
134 saw_main_thread = true;
135 }
136 ListEntry::Thread(thread)
137 if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
138 {
139 saw_remote_thread = true;
140 }
141 ListEntry::Thread(thread) => {
142 let title = thread.metadata.display_title();
143 panic!(
144 "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
145 title
146 );
147 }
148 }
149 }
150
151 assert!(
152 saw_main_thread,
153 "expected the sidebar to keep showing `Main Thread` under `project`"
154 );
155 assert!(
156 saw_remote_thread,
157 "expected the sidebar to keep showing `Worktree Thread` under `project`"
158 );
159}
160
161async fn init_test_project(
162 worktree_path: &str,
163 cx: &mut TestAppContext,
164) -> Entity<project::Project> {
165 init_test(cx);
166 let fs = FakeFs::new(cx.executor());
167 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
168 .await;
169 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
170 project::Project::test(fs, [worktree_path.as_ref()], cx).await
171}
172
173fn setup_sidebar(
174 multi_workspace: &Entity<MultiWorkspace>,
175 cx: &mut gpui::VisualTestContext,
176) -> Entity<Sidebar> {
177 let sidebar = setup_sidebar_closed(multi_workspace, cx);
178 multi_workspace.update_in(cx, |mw, window, cx| {
179 mw.toggle_sidebar(window, cx);
180 });
181 cx.run_until_parked();
182 sidebar
183}
184
185fn setup_sidebar_closed(
186 multi_workspace: &Entity<MultiWorkspace>,
187 cx: &mut gpui::VisualTestContext,
188) -> Entity<Sidebar> {
189 let multi_workspace = multi_workspace.clone();
190 let sidebar =
191 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
192 multi_workspace.update(cx, |mw, cx| {
193 mw.register_sidebar(sidebar.clone(), cx);
194 });
195 cx.run_until_parked();
196 sidebar
197}
198
199async fn save_n_test_threads(
200 count: u32,
201 project: &Entity<project::Project>,
202 cx: &mut gpui::VisualTestContext,
203) {
204 for i in 0..count {
205 save_thread_metadata(
206 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
207 Some(format!("Thread {}", i + 1).into()),
208 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
209 None,
210 project,
211 cx,
212 )
213 }
214 cx.run_until_parked();
215}
216
217async fn save_test_thread_metadata(
218 session_id: &acp::SessionId,
219 project: &Entity<project::Project>,
220 cx: &mut TestAppContext,
221) {
222 save_thread_metadata(
223 session_id.clone(),
224 Some("Test".into()),
225 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
226 None,
227 project,
228 cx,
229 )
230}
231
232async fn save_named_thread_metadata(
233 session_id: &str,
234 title: &str,
235 project: &Entity<project::Project>,
236 cx: &mut gpui::VisualTestContext,
237) {
238 save_thread_metadata(
239 acp::SessionId::new(Arc::from(session_id)),
240 Some(SharedString::from(title.to_string())),
241 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
242 None,
243 project,
244 cx,
245 );
246 cx.run_until_parked();
247}
248
249fn save_thread_metadata(
250 session_id: acp::SessionId,
251 title: Option<SharedString>,
252 updated_at: DateTime<Utc>,
253 created_at: Option<DateTime<Utc>>,
254 project: &Entity<project::Project>,
255 cx: &mut TestAppContext,
256) {
257 cx.update(|cx| {
258 let worktree_paths = project.read(cx).worktree_paths(cx);
259 let remote_connection = project.read(cx).remote_connection_options(cx);
260 let thread_id = ThreadMetadataStore::global(cx)
261 .read(cx)
262 .entries()
263 .find(|e| e.session_id.as_ref() == Some(&session_id))
264 .map(|e| e.thread_id)
265 .unwrap_or_else(ThreadId::new);
266 let metadata = ThreadMetadata {
267 thread_id,
268 session_id: Some(session_id),
269 agent_id: agent::ZED_AGENT_ID.clone(),
270 title,
271 updated_at,
272 created_at,
273 worktree_paths,
274 archived: false,
275 remote_connection,
276 };
277 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
278 });
279 cx.run_until_parked();
280}
281
282fn save_thread_metadata_with_main_paths(
283 session_id: &str,
284 title: &str,
285 folder_paths: PathList,
286 main_worktree_paths: PathList,
287 updated_at: DateTime<Utc>,
288 cx: &mut TestAppContext,
289) {
290 let session_id = acp::SessionId::new(Arc::from(session_id));
291 let title = SharedString::from(title.to_string());
292 let thread_id = cx.update(|cx| {
293 ThreadMetadataStore::global(cx)
294 .read(cx)
295 .entries()
296 .find(|e| e.session_id.as_ref() == Some(&session_id))
297 .map(|e| e.thread_id)
298 .unwrap_or_else(ThreadId::new)
299 });
300 let metadata = ThreadMetadata {
301 thread_id,
302 session_id: Some(session_id),
303 agent_id: agent::ZED_AGENT_ID.clone(),
304 title: Some(title),
305 updated_at,
306 created_at: None,
307 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
308 archived: false,
309 remote_connection: None,
310 };
311 cx.update(|cx| {
312 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
313 });
314 cx.run_until_parked();
315}
316
317fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
318 sidebar.update_in(cx, |_, window, cx| {
319 cx.focus_self(window);
320 });
321 cx.run_until_parked();
322}
323
324fn request_test_tool_authorization(
325 thread: &Entity<AcpThread>,
326 tool_call_id: &str,
327 option_id: &str,
328 cx: &mut gpui::VisualTestContext,
329) {
330 let tool_call_id = acp::ToolCallId::new(tool_call_id);
331 let label = format!("Tool {tool_call_id}");
332 let option_id = acp::PermissionOptionId::new(option_id);
333 let _authorization_task = cx.update(|_, cx| {
334 thread.update(cx, |thread, cx| {
335 thread
336 .request_tool_call_authorization(
337 acp::ToolCall::new(tool_call_id, label)
338 .kind(acp::ToolKind::Edit)
339 .into(),
340 PermissionOptions::Flat(vec![acp::PermissionOption::new(
341 option_id,
342 "Allow",
343 acp::PermissionOptionKind::AllowOnce,
344 )]),
345 cx,
346 )
347 .unwrap()
348 })
349 });
350 cx.run_until_parked();
351}
352
353fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
354 let mut seen = Vec::new();
355 let mut chips = Vec::new();
356 for wt in worktrees {
357 if wt.kind == ui::WorktreeKind::Main {
358 continue;
359 }
360 if !seen.contains(&wt.name) {
361 seen.push(wt.name.clone());
362 chips.push(format!("{{{}}}", wt.name));
363 }
364 }
365 if chips.is_empty() {
366 String::new()
367 } else {
368 format!(" {}", chips.join(", "))
369 }
370}
371
372fn visible_entries_as_strings(
373 sidebar: &Entity<Sidebar>,
374 cx: &mut gpui::VisualTestContext,
375) -> Vec<String> {
376 sidebar.read_with(cx, |sidebar, cx| {
377 sidebar
378 .contents
379 .entries
380 .iter()
381 .enumerate()
382 .map(|(ix, entry)| {
383 let selected = if sidebar.selection == Some(ix) {
384 " <== selected"
385 } else {
386 ""
387 };
388 match entry {
389 ListEntry::ProjectHeader {
390 label,
391 key,
392 highlight_positions: _,
393 ..
394 } => {
395 let icon = if sidebar.is_group_collapsed(key, cx) {
396 ">"
397 } else {
398 "v"
399 };
400 format!("{} [{}]{}", icon, label, selected)
401 }
402 ListEntry::Thread(thread) => {
403 let title = thread.metadata.display_title();
404 let worktree = format_linked_worktree_chips(&thread.worktrees);
405
406 {
407 let live = if thread.is_live { " *" } else { "" };
408 let status_str = match thread.status {
409 AgentThreadStatus::Running => " (running)",
410 AgentThreadStatus::Error => " (error)",
411 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
412 _ => "",
413 };
414 let notified = if sidebar
415 .contents
416 .is_thread_notified(&thread.metadata.thread_id)
417 {
418 " (!)"
419 } else {
420 ""
421 };
422 format!(" {title}{worktree}{live}{status_str}{notified}{selected}")
423 }
424 }
425 }
426 })
427 .collect()
428 })
429}
430
431#[gpui::test]
432async fn test_serialization_round_trip(cx: &mut TestAppContext) {
433 let project = init_test_project("/my-project", cx).await;
434 let (multi_workspace, cx) =
435 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
436 let sidebar = setup_sidebar(&multi_workspace, cx);
437
438 save_n_test_threads(3, &project, cx).await;
439
440 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
441
442 // Set a custom width and collapse the group.
443 sidebar.update_in(cx, |sidebar, window, cx| {
444 sidebar.set_width(Some(px(420.0)), cx);
445 sidebar.toggle_collapse(&project_group_key, window, cx);
446 });
447 cx.run_until_parked();
448
449 // Capture the serialized state from the first sidebar.
450 let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
451 let serialized = serialized.expect("serialized_state should return Some");
452
453 // Create a fresh sidebar and restore into it.
454 let sidebar2 =
455 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
456 cx.run_until_parked();
457
458 sidebar2.update_in(cx, |sidebar, window, cx| {
459 sidebar.restore_serialized_state(&serialized, window, cx);
460 });
461 cx.run_until_parked();
462
463 // Assert all serialized fields match.
464 let width1 = sidebar.read_with(cx, |s, _| s.width);
465 let width2 = sidebar2.read_with(cx, |s, _| s.width);
466
467 assert_eq!(width1, width2);
468 assert_eq!(width1, px(420.0));
469}
470
471#[gpui::test]
472async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
473 // A regression test to ensure that restoring a serialized archive view does not panic.
474 let project = init_test_project_with_agent_panel("/my-project", cx).await;
475 let (multi_workspace, cx) =
476 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
477 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
478 cx.update(|_window, cx| {
479 AgentRegistryStore::init_test_global(cx, vec![]);
480 });
481
482 let serialized = serde_json::to_string(&SerializedSidebar {
483 width: Some(400.0),
484 active_view: SerializedSidebarView::Archive,
485 })
486 .expect("serialization should succeed");
487
488 multi_workspace.update_in(cx, |multi_workspace, window, cx| {
489 if let Some(sidebar) = multi_workspace.sidebar() {
490 sidebar.restore_serialized_state(&serialized, window, cx);
491 }
492 });
493 cx.run_until_parked();
494
495 // After the deferred `show_archive` runs, the view should be Archive.
496 sidebar.read_with(cx, |sidebar, _cx| {
497 assert!(
498 matches!(sidebar.view, SidebarView::Archive(_)),
499 "expected sidebar view to be Archive after restore, got ThreadList"
500 );
501 });
502}
503
504#[gpui::test]
505async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
506 let project = init_test_project("/my-project", cx).await;
507 let (multi_workspace, cx) =
508 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
509 let sidebar = setup_sidebar(&multi_workspace, cx);
510
511 let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
512 let weak_sidebar = sidebar.downgrade();
513 let weak_multi_workspace = multi_workspace.downgrade();
514
515 drop(sidebar);
516 drop(multi_workspace);
517 cx.update(|window, _cx| window.remove_window());
518 cx.run_until_parked();
519
520 weak_multi_workspace.assert_released();
521 weak_sidebar.assert_released();
522 weak_workspace.assert_released();
523}
524
525#[gpui::test]
526async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
527 let project = init_test_project_with_agent_panel("/my-project", cx).await;
528 let (multi_workspace, cx) =
529 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
530 let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
531
532 assert_eq!(
533 visible_entries_as_strings(&_sidebar, cx),
534 vec!["v [my-project]"]
535 );
536}
537
538#[gpui::test]
539async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
540 let project = init_test_project("/my-project", cx).await;
541 let (multi_workspace, cx) =
542 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
543 let sidebar = setup_sidebar(&multi_workspace, cx);
544
545 save_thread_metadata(
546 acp::SessionId::new(Arc::from("thread-1")),
547 Some("Fix crash in project panel".into()),
548 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
549 None,
550 &project,
551 cx,
552 );
553
554 save_thread_metadata(
555 acp::SessionId::new(Arc::from("thread-2")),
556 Some("Add inline diff view".into()),
557 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
558 None,
559 &project,
560 cx,
561 );
562 cx.run_until_parked();
563
564 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
565 cx.run_until_parked();
566
567 assert_eq!(
568 visible_entries_as_strings(&sidebar, cx),
569 vec![
570 //
571 "v [my-project]",
572 " Fix crash in project panel",
573 " Add inline diff view",
574 ]
575 );
576}
577
578#[gpui::test]
579async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
580 let project = init_test_project("/project-a", cx).await;
581 let (multi_workspace, cx) =
582 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
583 let sidebar = setup_sidebar(&multi_workspace, cx);
584
585 // Single workspace with a thread
586 save_thread_metadata(
587 acp::SessionId::new(Arc::from("thread-a1")),
588 Some("Thread A1".into()),
589 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
590 None,
591 &project,
592 cx,
593 );
594 cx.run_until_parked();
595
596 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
597 cx.run_until_parked();
598
599 assert_eq!(
600 visible_entries_as_strings(&sidebar, cx),
601 vec![
602 //
603 "v [project-a]",
604 " Thread A1",
605 ]
606 );
607
608 // Add a second workspace
609 multi_workspace.update_in(cx, |mw, window, cx| {
610 mw.create_test_workspace(window, cx).detach();
611 });
612 cx.run_until_parked();
613
614 assert_eq!(
615 visible_entries_as_strings(&sidebar, cx),
616 vec![
617 //
618 "v [project-a]",
619 " Thread A1",
620 ]
621 );
622}
623
624#[gpui::test]
625async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
626 let project = init_test_project("/my-project", cx).await;
627 let (multi_workspace, cx) =
628 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
629 let sidebar = setup_sidebar(&multi_workspace, cx);
630
631 save_n_test_threads(1, &project, cx).await;
632
633 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
634
635 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
636 cx.run_until_parked();
637
638 assert_eq!(
639 visible_entries_as_strings(&sidebar, cx),
640 vec![
641 //
642 "v [my-project]",
643 " Thread 1",
644 ]
645 );
646
647 // Collapse
648 sidebar.update_in(cx, |s, window, cx| {
649 s.toggle_collapse(&project_group_key, window, cx);
650 });
651 cx.run_until_parked();
652
653 assert_eq!(
654 visible_entries_as_strings(&sidebar, cx),
655 vec![
656 //
657 "> [my-project]",
658 ]
659 );
660
661 // Expand
662 sidebar.update_in(cx, |s, window, cx| {
663 s.toggle_collapse(&project_group_key, window, cx);
664 });
665 cx.run_until_parked();
666
667 assert_eq!(
668 visible_entries_as_strings(&sidebar, cx),
669 vec![
670 //
671 "v [my-project]",
672 " Thread 1",
673 ]
674 );
675}
676
677#[gpui::test]
678async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
679 // When a worktree is added to a project, the project group key changes.
680 // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
681 // UI state must survive the key change.
682 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
683 let (multi_workspace, cx) =
684 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
685 let sidebar = setup_sidebar(&multi_workspace, cx);
686
687 save_n_test_threads(2, &project, cx).await;
688 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
689 cx.run_until_parked();
690
691 assert_eq!(
692 visible_entries_as_strings(&sidebar, cx),
693 vec!["v [project-a]", " Thread 2", " Thread 1",]
694 );
695
696 // Collapse the group.
697 let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
698 sidebar.update_in(cx, |sidebar, window, cx| {
699 sidebar.toggle_collapse(&old_key, window, cx);
700 });
701 cx.run_until_parked();
702
703 assert_eq!(
704 visible_entries_as_strings(&sidebar, cx),
705 vec!["> [project-a]"]
706 );
707
708 // Add a second worktree — the key changes from [/project-a] to
709 // [/project-a, /project-b].
710 project
711 .update(cx, |project, cx| {
712 project.find_or_create_worktree("/project-b", true, cx)
713 })
714 .await
715 .expect("should add worktree");
716 cx.run_until_parked();
717
718 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
719 cx.run_until_parked();
720
721 // The group should still be collapsed under the new key.
722 assert_eq!(
723 visible_entries_as_strings(&sidebar, cx),
724 vec!["> [project-a, project-b]"]
725 );
726}
727
728#[gpui::test]
729async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
730 use workspace::ProjectGroup;
731
732 let project = init_test_project("/my-project", cx).await;
733 let (multi_workspace, cx) =
734 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
735 let sidebar = setup_sidebar(&multi_workspace, cx);
736
737 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
738 let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
739 let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
740
741 // Set the collapsed group state through multi_workspace
742 multi_workspace.update(cx, |mw, _cx| {
743 mw.test_add_project_group(ProjectGroup {
744 key: ProjectGroupKey::new(None, collapsed_path.clone()),
745 workspaces: Vec::new(),
746 expanded: false,
747 });
748 });
749
750 sidebar.update_in(cx, |s, _window, _cx| {
751 let notified_thread_id = ThreadId::new();
752 s.contents.notified_threads.insert(notified_thread_id);
753 s.contents.entries = vec![
754 // Expanded project header
755 ListEntry::ProjectHeader {
756 key: ProjectGroupKey::new(None, expanded_path.clone()),
757 label: "expanded-project".into(),
758 highlight_positions: Vec::new(),
759 has_running_threads: false,
760 waiting_thread_count: 0,
761 is_active: true,
762 has_threads: true,
763 },
764 ListEntry::Thread(ThreadEntry {
765 metadata: ThreadMetadata {
766 thread_id: ThreadId::new(),
767 session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
768 agent_id: AgentId::new("zed-agent"),
769 worktree_paths: WorktreePaths::default(),
770 title: Some("Completed thread".into()),
771 updated_at: Utc::now(),
772 created_at: Some(Utc::now()),
773 archived: false,
774 remote_connection: None,
775 },
776 icon: IconName::ZedAgent,
777 icon_from_external_svg: None,
778 status: AgentThreadStatus::Completed,
779 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
780 is_live: false,
781 is_background: false,
782 is_title_generating: false,
783 highlight_positions: Vec::new(),
784 worktrees: Vec::new(),
785 diff_stats: DiffStats::default(),
786 }),
787 // Active thread with Running status
788 ListEntry::Thread(ThreadEntry {
789 metadata: ThreadMetadata {
790 thread_id: ThreadId::new(),
791 session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
792 agent_id: AgentId::new("zed-agent"),
793 worktree_paths: WorktreePaths::default(),
794 title: Some("Running thread".into()),
795 updated_at: Utc::now(),
796 created_at: Some(Utc::now()),
797 archived: false,
798 remote_connection: None,
799 },
800 icon: IconName::ZedAgent,
801 icon_from_external_svg: None,
802 status: AgentThreadStatus::Running,
803 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
804 is_live: true,
805 is_background: false,
806 is_title_generating: false,
807 highlight_positions: Vec::new(),
808 worktrees: Vec::new(),
809 diff_stats: DiffStats::default(),
810 }),
811 // Active thread with Error status
812 ListEntry::Thread(ThreadEntry {
813 metadata: ThreadMetadata {
814 thread_id: ThreadId::new(),
815 session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
816 agent_id: AgentId::new("zed-agent"),
817 worktree_paths: WorktreePaths::default(),
818 title: Some("Error thread".into()),
819 updated_at: Utc::now(),
820 created_at: Some(Utc::now()),
821 archived: false,
822 remote_connection: None,
823 },
824 icon: IconName::ZedAgent,
825 icon_from_external_svg: None,
826 status: AgentThreadStatus::Error,
827 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
828 is_live: true,
829 is_background: false,
830 is_title_generating: false,
831 highlight_positions: Vec::new(),
832 worktrees: Vec::new(),
833 diff_stats: DiffStats::default(),
834 }),
835 // Thread with WaitingForConfirmation status, not active
836 // remote_connection: None,
837 ListEntry::Thread(ThreadEntry {
838 metadata: ThreadMetadata {
839 thread_id: ThreadId::new(),
840 session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
841 agent_id: AgentId::new("zed-agent"),
842 worktree_paths: WorktreePaths::default(),
843 title: Some("Waiting thread".into()),
844 updated_at: Utc::now(),
845 created_at: Some(Utc::now()),
846 archived: false,
847 remote_connection: None,
848 },
849 icon: IconName::ZedAgent,
850 icon_from_external_svg: None,
851 status: AgentThreadStatus::WaitingForConfirmation,
852 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
853 is_live: false,
854 is_background: false,
855 is_title_generating: false,
856 highlight_positions: Vec::new(),
857 worktrees: Vec::new(),
858 diff_stats: DiffStats::default(),
859 }),
860 // Background thread that completed (should show notification)
861 // remote_connection: None,
862 ListEntry::Thread(ThreadEntry {
863 metadata: ThreadMetadata {
864 thread_id: notified_thread_id,
865 session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
866 agent_id: AgentId::new("zed-agent"),
867 worktree_paths: WorktreePaths::default(),
868 title: Some("Notified thread".into()),
869 updated_at: Utc::now(),
870 created_at: Some(Utc::now()),
871 archived: false,
872 remote_connection: None,
873 },
874 icon: IconName::ZedAgent,
875 icon_from_external_svg: None,
876 status: AgentThreadStatus::Completed,
877 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
878 is_live: true,
879 is_background: true,
880 is_title_generating: false,
881 highlight_positions: Vec::new(),
882 worktrees: Vec::new(),
883 diff_stats: DiffStats::default(),
884 }),
885 // Collapsed project header
886 ListEntry::ProjectHeader {
887 key: ProjectGroupKey::new(None, collapsed_path.clone()),
888 label: "collapsed-project".into(),
889 highlight_positions: Vec::new(),
890 has_running_threads: false,
891 waiting_thread_count: 0,
892 is_active: false,
893 has_threads: false,
894 },
895 ];
896
897 // Select the Running thread (index 2)
898 s.selection = Some(2);
899 });
900
901 assert_eq!(
902 visible_entries_as_strings(&sidebar, cx),
903 vec![
904 //
905 "v [expanded-project]",
906 " Completed thread",
907 " Running thread * (running) <== selected",
908 " Error thread * (error)",
909 " Waiting thread (waiting)",
910 " Notified thread * (!)",
911 "> [collapsed-project]",
912 ]
913 );
914
915 // Move selection to the collapsed header
916 sidebar.update_in(cx, |s, _window, _cx| {
917 s.selection = Some(6);
918 });
919
920 assert_eq!(
921 visible_entries_as_strings(&sidebar, cx).last().cloned(),
922 Some("> [collapsed-project] <== selected".to_string()),
923 );
924
925 // Clear selection
926 sidebar.update_in(cx, |s, _window, _cx| {
927 s.selection = None;
928 });
929
930 // No entry should have the selected marker
931 let entries = visible_entries_as_strings(&sidebar, cx);
932 for entry in &entries {
933 assert!(
934 !entry.contains("<== selected"),
935 "unexpected selection marker in: {}",
936 entry
937 );
938 }
939}
940
941#[gpui::test]
942async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
943 let project = init_test_project("/my-project", cx).await;
944 let (multi_workspace, cx) =
945 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
946 let sidebar = setup_sidebar(&multi_workspace, cx);
947
948 save_n_test_threads(3, &project, cx).await;
949
950 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
951 cx.run_until_parked();
952
953 // Entries: [header, thread3, thread2, thread1]
954 // Focusing the sidebar does not set a selection; select_next/select_previous
955 // handle None gracefully by starting from the first or last entry.
956 focus_sidebar(&sidebar, cx);
957 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
958
959 // First SelectNext from None starts at index 0
960 cx.dispatch_action(SelectNext);
961 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
962
963 // Move down through remaining entries
964 cx.dispatch_action(SelectNext);
965 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
966
967 cx.dispatch_action(SelectNext);
968 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
969
970 cx.dispatch_action(SelectNext);
971 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
972
973 // At the end, wraps back to first entry
974 cx.dispatch_action(SelectNext);
975 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
976
977 // Navigate back to the end
978 cx.dispatch_action(SelectNext);
979 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
980 cx.dispatch_action(SelectNext);
981 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
982 cx.dispatch_action(SelectNext);
983 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
984
985 // Move back up
986 cx.dispatch_action(SelectPrevious);
987 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
988
989 cx.dispatch_action(SelectPrevious);
990 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
991
992 cx.dispatch_action(SelectPrevious);
993 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
994
995 // At the top, selection clears (focus returns to editor)
996 cx.dispatch_action(SelectPrevious);
997 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
998}
999
1000#[gpui::test]
1001async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
1002 let project = init_test_project("/my-project", cx).await;
1003 let (multi_workspace, cx) =
1004 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1005 let sidebar = setup_sidebar(&multi_workspace, cx);
1006
1007 save_n_test_threads(3, &project, cx).await;
1008 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1009 cx.run_until_parked();
1010
1011 focus_sidebar(&sidebar, cx);
1012
1013 // SelectLast jumps to the end
1014 cx.dispatch_action(SelectLast);
1015 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1016
1017 // SelectFirst jumps to the beginning
1018 cx.dispatch_action(SelectFirst);
1019 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1020}
1021
1022#[gpui::test]
1023async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
1024 let project = init_test_project("/my-project", cx).await;
1025 let (multi_workspace, cx) =
1026 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1027 let sidebar = setup_sidebar(&multi_workspace, cx);
1028
1029 // Initially no selection
1030 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1031
1032 // Open the sidebar so it's rendered, then focus it to trigger focus_in.
1033 // focus_in no longer sets a default selection.
1034 focus_sidebar(&sidebar, cx);
1035 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1036
1037 // Manually set a selection, blur, then refocus — selection should be preserved
1038 sidebar.update_in(cx, |sidebar, _window, _cx| {
1039 sidebar.selection = Some(0);
1040 });
1041
1042 cx.update(|window, _cx| {
1043 window.blur();
1044 });
1045 cx.run_until_parked();
1046
1047 sidebar.update_in(cx, |_, window, cx| {
1048 cx.focus_self(window);
1049 });
1050 cx.run_until_parked();
1051 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1052}
1053
1054#[gpui::test]
1055async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1056 let project = init_test_project("/my-project", cx).await;
1057 let (multi_workspace, cx) =
1058 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1059 let sidebar = setup_sidebar(&multi_workspace, cx);
1060
1061 save_n_test_threads(1, &project, cx).await;
1062 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1063 cx.run_until_parked();
1064
1065 assert_eq!(
1066 visible_entries_as_strings(&sidebar, cx),
1067 vec![
1068 //
1069 "v [my-project]",
1070 " Thread 1",
1071 ]
1072 );
1073
1074 // Focus the sidebar and select the header
1075 focus_sidebar(&sidebar, cx);
1076 sidebar.update_in(cx, |sidebar, _window, _cx| {
1077 sidebar.selection = Some(0);
1078 });
1079
1080 // Confirm on project header collapses the group
1081 cx.dispatch_action(Confirm);
1082 cx.run_until_parked();
1083
1084 assert_eq!(
1085 visible_entries_as_strings(&sidebar, cx),
1086 vec![
1087 //
1088 "> [my-project] <== selected",
1089 ]
1090 );
1091
1092 // Confirm again expands the group
1093 cx.dispatch_action(Confirm);
1094 cx.run_until_parked();
1095
1096 assert_eq!(
1097 visible_entries_as_strings(&sidebar, cx),
1098 vec![
1099 //
1100 "v [my-project] <== selected",
1101 " Thread 1",
1102 ]
1103 );
1104}
1105
1106#[gpui::test]
1107async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
1108 let project = init_test_project("/my-project", cx).await;
1109 let (multi_workspace, cx) =
1110 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1111 let sidebar = setup_sidebar(&multi_workspace, cx);
1112
1113 save_n_test_threads(1, &project, cx).await;
1114 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1115 cx.run_until_parked();
1116
1117 assert_eq!(
1118 visible_entries_as_strings(&sidebar, cx),
1119 vec![
1120 //
1121 "v [my-project]",
1122 " Thread 1",
1123 ]
1124 );
1125
1126 // Focus sidebar and manually select the header (index 0). Press left to collapse.
1127 focus_sidebar(&sidebar, cx);
1128 sidebar.update_in(cx, |sidebar, _window, _cx| {
1129 sidebar.selection = Some(0);
1130 });
1131
1132 cx.dispatch_action(SelectParent);
1133 cx.run_until_parked();
1134
1135 assert_eq!(
1136 visible_entries_as_strings(&sidebar, cx),
1137 vec![
1138 //
1139 "> [my-project] <== selected",
1140 ]
1141 );
1142
1143 // Press right to expand
1144 cx.dispatch_action(SelectChild);
1145 cx.run_until_parked();
1146
1147 assert_eq!(
1148 visible_entries_as_strings(&sidebar, cx),
1149 vec![
1150 //
1151 "v [my-project] <== selected",
1152 " Thread 1",
1153 ]
1154 );
1155
1156 // Press right again on already-expanded header moves selection down
1157 cx.dispatch_action(SelectChild);
1158 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1159}
1160
1161#[gpui::test]
1162async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
1163 let project = init_test_project("/my-project", cx).await;
1164 let (multi_workspace, cx) =
1165 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1166 let sidebar = setup_sidebar(&multi_workspace, cx);
1167
1168 save_n_test_threads(1, &project, cx).await;
1169 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1170 cx.run_until_parked();
1171
1172 // Focus sidebar (selection starts at None), then navigate down to the thread (child)
1173 focus_sidebar(&sidebar, cx);
1174 cx.dispatch_action(SelectNext);
1175 cx.dispatch_action(SelectNext);
1176 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1177
1178 assert_eq!(
1179 visible_entries_as_strings(&sidebar, cx),
1180 vec![
1181 //
1182 "v [my-project]",
1183 " Thread 1 <== selected",
1184 ]
1185 );
1186
1187 // Pressing left on a child collapses the parent group and selects it
1188 cx.dispatch_action(SelectParent);
1189 cx.run_until_parked();
1190
1191 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1192 assert_eq!(
1193 visible_entries_as_strings(&sidebar, cx),
1194 vec![
1195 //
1196 "> [my-project] <== selected",
1197 ]
1198 );
1199}
1200
1201#[gpui::test]
1202async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
1203 let project = init_test_project_with_agent_panel("/empty-project", cx).await;
1204 let (multi_workspace, cx) =
1205 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1206 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1207
1208 // An empty project has only the header (no auto-created draft).
1209 assert_eq!(
1210 visible_entries_as_strings(&sidebar, cx),
1211 vec!["v [empty-project]"]
1212 );
1213
1214 // Focus sidebar — focus_in does not set a selection
1215 focus_sidebar(&sidebar, cx);
1216 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1217
1218 // First SelectNext from None starts at index 0 (header)
1219 cx.dispatch_action(SelectNext);
1220 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1221
1222 // SelectNext with only one entry stays at index 0
1223 cx.dispatch_action(SelectNext);
1224 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1225
1226 // SelectPrevious from first entry clears selection (returns to editor)
1227 cx.dispatch_action(SelectPrevious);
1228 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1229
1230 // SelectPrevious from None selects the last entry
1231 cx.dispatch_action(SelectPrevious);
1232 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1233}
1234
1235#[gpui::test]
1236async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
1237 let project = init_test_project("/my-project", cx).await;
1238 let (multi_workspace, cx) =
1239 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1240 let sidebar = setup_sidebar(&multi_workspace, cx);
1241
1242 save_n_test_threads(1, &project, cx).await;
1243 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1244 cx.run_until_parked();
1245
1246 // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
1247 focus_sidebar(&sidebar, cx);
1248 cx.dispatch_action(SelectNext);
1249 cx.dispatch_action(SelectNext);
1250 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1251
1252 // Collapse the group, which removes the thread from the list
1253 cx.dispatch_action(SelectParent);
1254 cx.run_until_parked();
1255
1256 // Selection should be clamped to the last valid index (0 = header)
1257 let selection = sidebar.read_with(cx, |s, _| s.selection);
1258 let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
1259 assert!(
1260 selection.unwrap_or(0) < entry_count,
1261 "selection {} should be within bounds (entries: {})",
1262 selection.unwrap_or(0),
1263 entry_count,
1264 );
1265}
1266
1267async fn init_test_project_with_agent_panel(
1268 worktree_path: &str,
1269 cx: &mut TestAppContext,
1270) -> Entity<project::Project> {
1271 agent_ui::test_support::init_test(cx);
1272 cx.update(|cx| {
1273 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
1274 ThreadStore::init_global(cx);
1275 ThreadMetadataStore::init_global(cx);
1276 language_model::LanguageModelRegistry::test(cx);
1277 prompt_store::init(cx);
1278 });
1279
1280 let fs = FakeFs::new(cx.executor());
1281 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1282 .await;
1283 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1284 project::Project::test(fs, [worktree_path.as_ref()], cx).await
1285}
1286
1287fn add_agent_panel(
1288 workspace: &Entity<Workspace>,
1289 cx: &mut gpui::VisualTestContext,
1290) -> Entity<AgentPanel> {
1291 workspace.update_in(cx, |workspace, window, cx| {
1292 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
1293 workspace.add_panel(panel.clone(), window, cx);
1294 panel
1295 })
1296}
1297
1298fn setup_sidebar_with_agent_panel(
1299 multi_workspace: &Entity<MultiWorkspace>,
1300 cx: &mut gpui::VisualTestContext,
1301) -> (Entity<Sidebar>, Entity<AgentPanel>) {
1302 let sidebar = setup_sidebar(multi_workspace, cx);
1303 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
1304 let panel = add_agent_panel(&workspace, cx);
1305 (sidebar, panel)
1306}
1307
1308#[gpui::test]
1309async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
1310 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1311 let (multi_workspace, cx) =
1312 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1313 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1314
1315 // Open thread A and keep it generating.
1316 let connection = StubAgentConnection::new();
1317 open_thread_with_connection(&panel, connection.clone(), cx);
1318 send_message(&panel, cx);
1319
1320 let session_id_a = active_session_id(&panel, cx);
1321 save_test_thread_metadata(&session_id_a, &project, cx).await;
1322
1323 cx.update(|_, cx| {
1324 connection.send_update(
1325 session_id_a.clone(),
1326 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
1327 cx,
1328 );
1329 });
1330 cx.run_until_parked();
1331
1332 // Open thread B (idle, default response) — thread A goes to background.
1333 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1334 acp::ContentChunk::new("Done".into()),
1335 )]);
1336 open_thread_with_connection(&panel, connection, cx);
1337 send_message(&panel, cx);
1338
1339 let session_id_b = active_session_id(&panel, cx);
1340 save_test_thread_metadata(&session_id_b, &project, cx).await;
1341
1342 cx.run_until_parked();
1343
1344 let mut entries = visible_entries_as_strings(&sidebar, cx);
1345 entries[1..].sort();
1346 assert_eq!(
1347 entries,
1348 vec![
1349 //
1350 "v [my-project]",
1351 " Hello *",
1352 " Hello * (running)",
1353 ]
1354 );
1355}
1356
1357#[gpui::test]
1358async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
1359 cx: &mut TestAppContext,
1360) {
1361 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1362 let (multi_workspace, cx) =
1363 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1364 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1365
1366 let connection = StubAgentConnection::new().with_supports_load_session(true);
1367 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1368 acp::ContentChunk::new("Done".into()),
1369 )]);
1370 open_thread_with_connection(&panel, connection, cx);
1371 send_message(&panel, cx);
1372
1373 let parent_session_id = active_session_id(&panel, cx);
1374 save_test_thread_metadata(&parent_session_id, &project, cx).await;
1375
1376 let subagent_session_id = acp::SessionId::new("subagent-session");
1377 cx.update(|_, cx| {
1378 let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
1379 parent_thread.update(cx, |thread: &mut AcpThread, cx| {
1380 thread.subagent_spawned(subagent_session_id.clone(), cx);
1381 });
1382 });
1383 cx.run_until_parked();
1384
1385 let subagent_thread = panel.read_with(cx, |panel, cx| {
1386 panel
1387 .active_conversation_view()
1388 .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
1389 .map(|thread_view| thread_view.read(cx).thread.clone())
1390 .expect("Expected subagent thread to be loaded into the conversation")
1391 });
1392 request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
1393
1394 let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
1395 sidebar
1396 .contents
1397 .entries
1398 .iter()
1399 .find_map(|entry| match entry {
1400 ListEntry::Thread(thread)
1401 if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
1402 {
1403 Some(thread.status)
1404 }
1405 _ => None,
1406 })
1407 .expect("Expected parent thread entry in sidebar")
1408 });
1409
1410 assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
1411}
1412
1413#[gpui::test]
1414async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
1415 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
1416 let (multi_workspace, cx) =
1417 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1418 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1419
1420 // Open thread on workspace A and keep it generating.
1421 let connection_a = StubAgentConnection::new();
1422 open_thread_with_connection(&panel_a, connection_a.clone(), cx);
1423 send_message(&panel_a, cx);
1424
1425 let session_id_a = active_session_id(&panel_a, cx);
1426 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
1427
1428 cx.update(|_, cx| {
1429 connection_a.send_update(
1430 session_id_a.clone(),
1431 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
1432 cx,
1433 );
1434 });
1435 cx.run_until_parked();
1436
1437 // Add a second workspace and activate it (making workspace A the background).
1438 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
1439 let project_b = project::Project::test(fs, [], cx).await;
1440 multi_workspace.update_in(cx, |mw, window, cx| {
1441 mw.test_add_workspace(project_b, window, cx);
1442 });
1443 cx.run_until_parked();
1444
1445 // Thread A is still running; no notification yet.
1446 assert_eq!(
1447 visible_entries_as_strings(&sidebar, cx),
1448 vec![
1449 //
1450 "v [project-a]",
1451 " Hello * (running)",
1452 ]
1453 );
1454
1455 // Complete thread A's turn (transition Running → Completed).
1456 connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
1457 cx.run_until_parked();
1458
1459 // The completed background thread shows a notification indicator.
1460 assert_eq!(
1461 visible_entries_as_strings(&sidebar, cx),
1462 vec![
1463 //
1464 "v [project-a]",
1465 " Hello * (!)",
1466 ]
1467 );
1468}
1469
1470fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
1471 sidebar.update_in(cx, |sidebar, window, cx| {
1472 window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
1473 sidebar.filter_editor.update(cx, |editor, cx| {
1474 editor.set_text(query, window, cx);
1475 });
1476 });
1477 cx.run_until_parked();
1478}
1479
1480#[gpui::test]
1481async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
1482 let project = init_test_project("/my-project", cx).await;
1483 let (multi_workspace, cx) =
1484 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1485 let sidebar = setup_sidebar(&multi_workspace, cx);
1486
1487 for (id, title, hour) in [
1488 ("t-1", "Fix crash in project panel", 3),
1489 ("t-2", "Add inline diff view", 2),
1490 ("t-3", "Refactor settings module", 1),
1491 ] {
1492 save_thread_metadata(
1493 acp::SessionId::new(Arc::from(id)),
1494 Some(title.into()),
1495 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1496 None,
1497 &project,
1498 cx,
1499 );
1500 }
1501 cx.run_until_parked();
1502
1503 assert_eq!(
1504 visible_entries_as_strings(&sidebar, cx),
1505 vec![
1506 //
1507 "v [my-project]",
1508 " Fix crash in project panel",
1509 " Add inline diff view",
1510 " Refactor settings module",
1511 ]
1512 );
1513
1514 // User types "diff" in the search box — only the matching thread remains,
1515 // with its workspace header preserved for context.
1516 type_in_search(&sidebar, "diff", cx);
1517 assert_eq!(
1518 visible_entries_as_strings(&sidebar, cx),
1519 vec![
1520 //
1521 "v [my-project]",
1522 " Add inline diff view <== selected",
1523 ]
1524 );
1525
1526 // User changes query to something with no matches — list is empty.
1527 type_in_search(&sidebar, "nonexistent", cx);
1528 assert_eq!(
1529 visible_entries_as_strings(&sidebar, cx),
1530 Vec::<String>::new()
1531 );
1532}
1533
1534#[gpui::test]
1535async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
1536 // Scenario: A user remembers a thread title but not the exact casing.
1537 // Search should match case-insensitively so they can still find it.
1538 let project = init_test_project("/my-project", cx).await;
1539 let (multi_workspace, cx) =
1540 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1541 let sidebar = setup_sidebar(&multi_workspace, cx);
1542
1543 save_thread_metadata(
1544 acp::SessionId::new(Arc::from("thread-1")),
1545 Some("Fix Crash In Project Panel".into()),
1546 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1547 None,
1548 &project,
1549 cx,
1550 );
1551 cx.run_until_parked();
1552
1553 // Lowercase query matches mixed-case title.
1554 type_in_search(&sidebar, "fix crash", cx);
1555 assert_eq!(
1556 visible_entries_as_strings(&sidebar, cx),
1557 vec![
1558 //
1559 "v [my-project]",
1560 " Fix Crash In Project Panel <== selected",
1561 ]
1562 );
1563
1564 // Uppercase query also matches the same title.
1565 type_in_search(&sidebar, "FIX CRASH", cx);
1566 assert_eq!(
1567 visible_entries_as_strings(&sidebar, cx),
1568 vec![
1569 //
1570 "v [my-project]",
1571 " Fix Crash In Project Panel <== selected",
1572 ]
1573 );
1574}
1575
1576#[gpui::test]
1577async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
1578 // Scenario: A user searches, finds what they need, then presses Escape
1579 // to dismiss the filter and see the full list again.
1580 let project = init_test_project("/my-project", cx).await;
1581 let (multi_workspace, cx) =
1582 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1583 let sidebar = setup_sidebar(&multi_workspace, cx);
1584
1585 for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
1586 save_thread_metadata(
1587 acp::SessionId::new(Arc::from(id)),
1588 Some(title.into()),
1589 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1590 None,
1591 &project,
1592 cx,
1593 )
1594 }
1595 cx.run_until_parked();
1596
1597 // Confirm the full list is showing.
1598 assert_eq!(
1599 visible_entries_as_strings(&sidebar, cx),
1600 vec![
1601 //
1602 "v [my-project]",
1603 " Alpha thread",
1604 " Beta thread",
1605 ]
1606 );
1607
1608 // User types a search query to filter down.
1609 focus_sidebar(&sidebar, cx);
1610 type_in_search(&sidebar, "alpha", cx);
1611 assert_eq!(
1612 visible_entries_as_strings(&sidebar, cx),
1613 vec![
1614 //
1615 "v [my-project]",
1616 " Alpha thread <== selected",
1617 ]
1618 );
1619
1620 // User presses Escape — filter clears, full list is restored.
1621 // The selection index (1) now points at the first thread entry.
1622 cx.dispatch_action(Cancel);
1623 cx.run_until_parked();
1624 assert_eq!(
1625 visible_entries_as_strings(&sidebar, cx),
1626 vec![
1627 //
1628 "v [my-project]",
1629 " Alpha thread <== selected",
1630 " Beta thread",
1631 ]
1632 );
1633}
1634
1635#[gpui::test]
1636async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1637 let project_a = init_test_project("/project-a", cx).await;
1638 let (multi_workspace, cx) =
1639 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1640 let sidebar = setup_sidebar(&multi_workspace, cx);
1641
1642 for (id, title, hour) in [
1643 ("a1", "Fix bug in sidebar", 2),
1644 ("a2", "Add tests for editor", 1),
1645 ] {
1646 save_thread_metadata(
1647 acp::SessionId::new(Arc::from(id)),
1648 Some(title.into()),
1649 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1650 None,
1651 &project_a,
1652 cx,
1653 )
1654 }
1655
1656 // Add a second workspace.
1657 multi_workspace.update_in(cx, |mw, window, cx| {
1658 mw.create_test_workspace(window, cx).detach();
1659 });
1660 cx.run_until_parked();
1661
1662 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1663 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1664 });
1665
1666 for (id, title, hour) in [
1667 ("b1", "Refactor sidebar layout", 3),
1668 ("b2", "Fix typo in README", 1),
1669 ] {
1670 save_thread_metadata(
1671 acp::SessionId::new(Arc::from(id)),
1672 Some(title.into()),
1673 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1674 None,
1675 &project_b,
1676 cx,
1677 )
1678 }
1679 cx.run_until_parked();
1680
1681 assert_eq!(
1682 visible_entries_as_strings(&sidebar, cx),
1683 vec![
1684 //
1685 "v [project-a]",
1686 " Fix bug in sidebar",
1687 " Add tests for editor",
1688 ]
1689 );
1690
1691 // "sidebar" matches a thread in each workspace — both headers stay visible.
1692 type_in_search(&sidebar, "sidebar", cx);
1693 assert_eq!(
1694 visible_entries_as_strings(&sidebar, cx),
1695 vec![
1696 //
1697 "v [project-a]",
1698 " Fix bug in sidebar <== selected",
1699 ]
1700 );
1701
1702 // "typo" only matches in the second workspace — the first header disappears.
1703 type_in_search(&sidebar, "typo", cx);
1704 assert_eq!(
1705 visible_entries_as_strings(&sidebar, cx),
1706 Vec::<String>::new()
1707 );
1708
1709 // "project-a" matches the first workspace name — the header appears
1710 // with all child threads included.
1711 type_in_search(&sidebar, "project-a", cx);
1712 assert_eq!(
1713 visible_entries_as_strings(&sidebar, cx),
1714 vec![
1715 //
1716 "v [project-a]",
1717 " Fix bug in sidebar <== selected",
1718 " Add tests for editor",
1719 ]
1720 );
1721}
1722
1723#[gpui::test]
1724async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
1725 let project_a = init_test_project("/alpha-project", cx).await;
1726 let (multi_workspace, cx) =
1727 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1728 let sidebar = setup_sidebar(&multi_workspace, cx);
1729
1730 for (id, title, hour) in [
1731 ("a1", "Fix bug in sidebar", 2),
1732 ("a2", "Add tests for editor", 1),
1733 ] {
1734 save_thread_metadata(
1735 acp::SessionId::new(Arc::from(id)),
1736 Some(title.into()),
1737 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1738 None,
1739 &project_a,
1740 cx,
1741 )
1742 }
1743
1744 // Add a second workspace.
1745 multi_workspace.update_in(cx, |mw, window, cx| {
1746 mw.create_test_workspace(window, cx).detach();
1747 });
1748 cx.run_until_parked();
1749
1750 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1751 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1752 });
1753
1754 for (id, title, hour) in [
1755 ("b1", "Refactor sidebar layout", 3),
1756 ("b2", "Fix typo in README", 1),
1757 ] {
1758 save_thread_metadata(
1759 acp::SessionId::new(Arc::from(id)),
1760 Some(title.into()),
1761 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1762 None,
1763 &project_b,
1764 cx,
1765 )
1766 }
1767 cx.run_until_parked();
1768
1769 // "alpha" matches the workspace name "alpha-project" but no thread titles.
1770 // The workspace header should appear with all child threads included.
1771 type_in_search(&sidebar, "alpha", cx);
1772 assert_eq!(
1773 visible_entries_as_strings(&sidebar, cx),
1774 vec![
1775 //
1776 "v [alpha-project]",
1777 " Fix bug in sidebar <== selected",
1778 " Add tests for editor",
1779 ]
1780 );
1781
1782 // "sidebar" matches thread titles in both workspaces but not workspace names.
1783 // Both headers appear with their matching threads.
1784 type_in_search(&sidebar, "sidebar", cx);
1785 assert_eq!(
1786 visible_entries_as_strings(&sidebar, cx),
1787 vec![
1788 //
1789 "v [alpha-project]",
1790 " Fix bug in sidebar <== selected",
1791 ]
1792 );
1793
1794 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
1795 // doesn't match) — but does not match either workspace name or any thread.
1796 // Actually let's test something simpler: a query that matches both a workspace
1797 // name AND some threads in that workspace. Matching threads should still appear.
1798 type_in_search(&sidebar, "fix", cx);
1799 assert_eq!(
1800 visible_entries_as_strings(&sidebar, cx),
1801 vec![
1802 //
1803 "v [alpha-project]",
1804 " Fix bug in sidebar <== selected",
1805 ]
1806 );
1807
1808 // A query that matches a workspace name AND a thread in that same workspace.
1809 // Both the header (highlighted) and all child threads should appear.
1810 type_in_search(&sidebar, "alpha", cx);
1811 assert_eq!(
1812 visible_entries_as_strings(&sidebar, cx),
1813 vec![
1814 //
1815 "v [alpha-project]",
1816 " Fix bug in sidebar <== selected",
1817 " Add tests for editor",
1818 ]
1819 );
1820
1821 // Now search for something that matches only a workspace name when there
1822 // are also threads with matching titles — the non-matching workspace's
1823 // threads should still appear if their titles match.
1824 type_in_search(&sidebar, "alp", cx);
1825 assert_eq!(
1826 visible_entries_as_strings(&sidebar, cx),
1827 vec![
1828 //
1829 "v [alpha-project]",
1830 " Fix bug in sidebar <== selected",
1831 " Add tests for editor",
1832 ]
1833 );
1834}
1835
1836#[gpui::test]
1837async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
1838 let project = init_test_project("/my-project", cx).await;
1839 let (multi_workspace, cx) =
1840 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1841 let sidebar = setup_sidebar(&multi_workspace, cx);
1842
1843 save_thread_metadata(
1844 acp::SessionId::new(Arc::from("thread-1")),
1845 Some("Important thread".into()),
1846 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1847 None,
1848 &project,
1849 cx,
1850 );
1851 cx.run_until_parked();
1852
1853 // User focuses the sidebar and collapses the group using keyboard:
1854 // manually select the header, then press SelectParent to collapse.
1855 focus_sidebar(&sidebar, cx);
1856 sidebar.update_in(cx, |sidebar, _window, _cx| {
1857 sidebar.selection = Some(0);
1858 });
1859 cx.dispatch_action(SelectParent);
1860 cx.run_until_parked();
1861
1862 assert_eq!(
1863 visible_entries_as_strings(&sidebar, cx),
1864 vec![
1865 //
1866 "> [my-project] <== selected",
1867 ]
1868 );
1869
1870 // User types a search — the thread appears even though its group is collapsed.
1871 type_in_search(&sidebar, "important", cx);
1872 assert_eq!(
1873 visible_entries_as_strings(&sidebar, cx),
1874 vec![
1875 //
1876 "> [my-project]",
1877 " Important thread <== selected",
1878 ]
1879 );
1880}
1881
1882#[gpui::test]
1883async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
1884 let project = init_test_project("/my-project", cx).await;
1885 let (multi_workspace, cx) =
1886 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1887 let sidebar = setup_sidebar(&multi_workspace, cx);
1888
1889 for (id, title, hour) in [
1890 ("t-1", "Fix crash in panel", 3),
1891 ("t-2", "Fix lint warnings", 2),
1892 ("t-3", "Add new feature", 1),
1893 ] {
1894 save_thread_metadata(
1895 acp::SessionId::new(Arc::from(id)),
1896 Some(title.into()),
1897 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1898 None,
1899 &project,
1900 cx,
1901 )
1902 }
1903 cx.run_until_parked();
1904
1905 focus_sidebar(&sidebar, cx);
1906
1907 // User types "fix" — two threads match.
1908 type_in_search(&sidebar, "fix", cx);
1909 assert_eq!(
1910 visible_entries_as_strings(&sidebar, cx),
1911 vec![
1912 //
1913 "v [my-project]",
1914 " Fix crash in panel <== selected",
1915 " Fix lint warnings",
1916 ]
1917 );
1918
1919 // Selection starts on the first matching thread. User presses
1920 // SelectNext to move to the second match.
1921 cx.dispatch_action(SelectNext);
1922 assert_eq!(
1923 visible_entries_as_strings(&sidebar, cx),
1924 vec![
1925 //
1926 "v [my-project]",
1927 " Fix crash in panel",
1928 " Fix lint warnings <== selected",
1929 ]
1930 );
1931
1932 // User can also jump back with SelectPrevious.
1933 cx.dispatch_action(SelectPrevious);
1934 assert_eq!(
1935 visible_entries_as_strings(&sidebar, cx),
1936 vec![
1937 //
1938 "v [my-project]",
1939 " Fix crash in panel <== selected",
1940 " Fix lint warnings",
1941 ]
1942 );
1943}
1944
1945#[gpui::test]
1946async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
1947 let project = init_test_project("/my-project", cx).await;
1948 let (multi_workspace, cx) =
1949 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1950 let sidebar = setup_sidebar(&multi_workspace, cx);
1951
1952 multi_workspace.update_in(cx, |mw, window, cx| {
1953 mw.create_test_workspace(window, cx).detach();
1954 });
1955 cx.run_until_parked();
1956
1957 let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
1958 (
1959 mw.workspaces().next().unwrap().clone(),
1960 mw.workspaces().nth(1).unwrap().clone(),
1961 )
1962 });
1963
1964 save_thread_metadata(
1965 acp::SessionId::new(Arc::from("hist-1")),
1966 Some("Historical Thread".into()),
1967 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
1968 None,
1969 &project,
1970 cx,
1971 );
1972 cx.run_until_parked();
1973 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1974 cx.run_until_parked();
1975
1976 assert_eq!(
1977 visible_entries_as_strings(&sidebar, cx),
1978 vec![
1979 //
1980 "v [my-project]",
1981 " Historical Thread",
1982 ]
1983 );
1984
1985 // Switch to workspace 1 so we can verify the confirm switches back.
1986 multi_workspace.update_in(cx, |mw, window, cx| {
1987 let workspace = mw.workspaces().nth(1).unwrap().clone();
1988 mw.activate(workspace, window, cx);
1989 });
1990 cx.run_until_parked();
1991 assert_eq!(
1992 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
1993 workspace_1
1994 );
1995
1996 // Confirm on the historical (non-live) thread at index 1.
1997 // Before a previous fix, the workspace field was Option<usize> and
1998 // historical threads had None, so activate_thread early-returned
1999 // without switching the workspace.
2000 sidebar.update_in(cx, |sidebar, window, cx| {
2001 sidebar.selection = Some(1);
2002 sidebar.confirm(&Confirm, window, cx);
2003 });
2004 cx.run_until_parked();
2005
2006 assert_eq!(
2007 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2008 workspace_0
2009 );
2010}
2011
2012#[gpui::test]
2013async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
2014 cx: &mut TestAppContext,
2015) {
2016 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2017 let (multi_workspace, cx) =
2018 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2019 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2020
2021 let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
2022 let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
2023 save_thread_metadata(
2024 newer_session_id,
2025 Some("Newer Historical Thread".into()),
2026 newer_timestamp,
2027 Some(newer_timestamp),
2028 &project,
2029 cx,
2030 );
2031
2032 let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
2033 let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
2034 save_thread_metadata(
2035 older_session_id.clone(),
2036 Some("Older Historical Thread".into()),
2037 older_timestamp,
2038 Some(older_timestamp),
2039 &project,
2040 cx,
2041 );
2042
2043 cx.run_until_parked();
2044 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2045 cx.run_until_parked();
2046
2047 let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2048 .into_iter()
2049 .filter(|entry| entry.contains("Historical Thread"))
2050 .collect();
2051 assert_eq!(
2052 historical_entries_before,
2053 vec![
2054 " Newer Historical Thread".to_string(),
2055 " Older Historical Thread".to_string(),
2056 ],
2057 "expected the sidebar to sort historical threads by their saved timestamp before activation"
2058 );
2059
2060 let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
2061 sidebar
2062 .contents
2063 .entries
2064 .iter()
2065 .position(|entry| {
2066 matches!(entry, ListEntry::Thread(thread)
2067 if thread.metadata.session_id.as_ref() == Some(&older_session_id))
2068 })
2069 .expect("expected Older Historical Thread to appear in the sidebar")
2070 });
2071
2072 sidebar.update_in(cx, |sidebar, window, cx| {
2073 sidebar.selection = Some(older_entry_index);
2074 sidebar.confirm(&Confirm, window, cx);
2075 });
2076 cx.run_until_parked();
2077
2078 let older_metadata = cx.update(|_, cx| {
2079 ThreadMetadataStore::global(cx)
2080 .read(cx)
2081 .entry_by_session(&older_session_id)
2082 .cloned()
2083 .expect("expected metadata for Older Historical Thread after activation")
2084 });
2085 assert_eq!(
2086 older_metadata.created_at,
2087 Some(older_timestamp),
2088 "activating a historical thread should not rewrite its saved created_at timestamp"
2089 );
2090
2091 let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2092 .into_iter()
2093 .filter(|entry| entry.contains("Historical Thread"))
2094 .collect();
2095 assert_eq!(
2096 historical_entries_after,
2097 vec![
2098 " Newer Historical Thread".to_string(),
2099 " Older Historical Thread <== selected".to_string(),
2100 ],
2101 "activating an older historical thread should not reorder it ahead of a newer historical thread"
2102 );
2103}
2104
2105#[gpui::test]
2106async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
2107 cx: &mut TestAppContext,
2108) {
2109 use workspace::ProjectGroup;
2110
2111 agent_ui::test_support::init_test(cx);
2112 cx.update(|cx| {
2113 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
2114 ThreadStore::init_global(cx);
2115 ThreadMetadataStore::init_global(cx);
2116 language_model::LanguageModelRegistry::test(cx);
2117 prompt_store::init(cx);
2118 });
2119
2120 let fs = FakeFs::new(cx.executor());
2121 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
2122 .await;
2123 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
2124 .await;
2125 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2126
2127 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
2128 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
2129
2130 let (multi_workspace, cx) =
2131 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2132 let sidebar = setup_sidebar(&multi_workspace, cx);
2133
2134 let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
2135 multi_workspace.update(cx, |mw, _cx| {
2136 mw.test_add_project_group(ProjectGroup {
2137 key: project_b_key.clone(),
2138 workspaces: Vec::new(),
2139 expanded: true,
2140 });
2141 });
2142
2143 let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
2144 save_thread_metadata(
2145 session_id.clone(),
2146 Some("Historical Thread in New Group".into()),
2147 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2148 None,
2149 &project_b,
2150 cx,
2151 );
2152 cx.run_until_parked();
2153
2154 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2155 cx.run_until_parked();
2156
2157 let entries_before = visible_entries_as_strings(&sidebar, cx);
2158 assert_eq!(
2159 entries_before,
2160 vec![
2161 "v [project-a]",
2162 "v [project-b]",
2163 " Historical Thread in New Group",
2164 ],
2165 "expected the closed project group to show the historical thread before first open"
2166 );
2167
2168 assert_eq!(
2169 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2170 1,
2171 "should start without an open workspace for the new project group"
2172 );
2173
2174 sidebar.update_in(cx, |sidebar, window, cx| {
2175 sidebar.selection = Some(2);
2176 sidebar.confirm(&Confirm, window, cx);
2177 });
2178
2179 cx.run_until_parked();
2180
2181 assert_eq!(
2182 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2183 2,
2184 "confirming the historical thread should open a workspace for the new project group"
2185 );
2186
2187 let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
2188 mw.workspaces()
2189 .find(|workspace| {
2190 PathList::new(&workspace.read(cx).root_paths(cx))
2191 == project_b_key.path_list().clone()
2192 })
2193 .cloned()
2194 .expect("expected workspace for project-b after opening the historical thread")
2195 });
2196
2197 assert_eq!(
2198 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2199 workspace_b,
2200 "opening the historical thread should activate the new project's workspace"
2201 );
2202
2203 let panel = workspace_b.read_with(cx, |workspace, cx| {
2204 workspace
2205 .panel::<AgentPanel>(cx)
2206 .expect("expected first-open activation to bootstrap the agent panel")
2207 });
2208
2209 let expected_thread_id = cx.update(|_, cx| {
2210 ThreadMetadataStore::global(cx)
2211 .read(cx)
2212 .entries()
2213 .find(|e| e.session_id.as_ref() == Some(&session_id))
2214 .map(|e| e.thread_id)
2215 .expect("metadata should still map session id to thread id")
2216 });
2217
2218 assert_eq!(
2219 panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
2220 Some(expected_thread_id),
2221 "expected the agent panel to activate the real historical thread rather than a draft"
2222 );
2223
2224 let entries_after = visible_entries_as_strings(&sidebar, cx);
2225 let matching_rows: Vec<_> = entries_after
2226 .iter()
2227 .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
2228 .cloned()
2229 .collect();
2230 assert_eq!(
2231 matching_rows.len(),
2232 1,
2233 "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
2234 );
2235 assert!(
2236 matching_rows[0].contains("Historical Thread in New Group"),
2237 "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
2238 );
2239 assert!(
2240 !matching_rows[0].contains("Draft"),
2241 "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
2242 );
2243}
2244
2245#[gpui::test]
2246async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
2247 let project = init_test_project("/my-project", cx).await;
2248 let (multi_workspace, cx) =
2249 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2250 let sidebar = setup_sidebar(&multi_workspace, cx);
2251
2252 save_thread_metadata(
2253 acp::SessionId::new(Arc::from("t-1")),
2254 Some("Thread A".into()),
2255 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2256 None,
2257 &project,
2258 cx,
2259 );
2260
2261 save_thread_metadata(
2262 acp::SessionId::new(Arc::from("t-2")),
2263 Some("Thread B".into()),
2264 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2265 None,
2266 &project,
2267 cx,
2268 );
2269
2270 cx.run_until_parked();
2271 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2272 cx.run_until_parked();
2273
2274 assert_eq!(
2275 visible_entries_as_strings(&sidebar, cx),
2276 vec![
2277 //
2278 "v [my-project]",
2279 " Thread A",
2280 " Thread B",
2281 ]
2282 );
2283
2284 // Keyboard confirm preserves selection.
2285 sidebar.update_in(cx, |sidebar, window, cx| {
2286 sidebar.selection = Some(1);
2287 sidebar.confirm(&Confirm, window, cx);
2288 });
2289 assert_eq!(
2290 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
2291 Some(1)
2292 );
2293
2294 // Click handlers clear selection to None so no highlight lingers
2295 // after a click regardless of focus state. The hover style provides
2296 // visual feedback during mouse interaction instead.
2297 sidebar.update_in(cx, |sidebar, window, cx| {
2298 sidebar.selection = None;
2299 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2300 let project_group_key = ProjectGroupKey::new(None, path_list);
2301 sidebar.toggle_collapse(&project_group_key, window, cx);
2302 });
2303 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2304
2305 // When the user tabs back into the sidebar, focus_in no longer
2306 // restores selection — it stays None.
2307 sidebar.update_in(cx, |sidebar, window, cx| {
2308 sidebar.focus_in(window, cx);
2309 });
2310 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2311}
2312
2313#[gpui::test]
2314async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
2315 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2316 let (multi_workspace, cx) =
2317 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2318 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2319
2320 let connection = StubAgentConnection::new();
2321 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2322 acp::ContentChunk::new("Hi there!".into()),
2323 )]);
2324 open_thread_with_connection(&panel, connection, cx);
2325 send_message(&panel, cx);
2326
2327 let session_id = active_session_id(&panel, cx);
2328 save_test_thread_metadata(&session_id, &project, cx).await;
2329 cx.run_until_parked();
2330
2331 assert_eq!(
2332 visible_entries_as_strings(&sidebar, cx),
2333 vec![
2334 //
2335 "v [my-project]",
2336 " Hello *",
2337 ]
2338 );
2339
2340 // Simulate the agent generating a title. The notification chain is:
2341 // AcpThread::set_title emits TitleUpdated →
2342 // ConnectionView::handle_thread_event calls cx.notify() →
2343 // AgentPanel observer fires and emits AgentPanelEvent →
2344 // Sidebar subscription calls update_entries / rebuild_contents.
2345 //
2346 // Before the fix, handle_thread_event did NOT call cx.notify() for
2347 // TitleUpdated, so the AgentPanel observer never fired and the
2348 // sidebar kept showing the old title.
2349 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2350 thread.update(cx, |thread, cx| {
2351 thread
2352 .set_title("Friendly Greeting with AI".into(), cx)
2353 .detach();
2354 });
2355 cx.run_until_parked();
2356
2357 assert_eq!(
2358 visible_entries_as_strings(&sidebar, cx),
2359 vec![
2360 //
2361 "v [my-project]",
2362 " Friendly Greeting with AI *",
2363 ]
2364 );
2365}
2366
2367#[gpui::test]
2368async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2369 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2370 let (multi_workspace, cx) =
2371 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2372 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2373
2374 // Save a thread so it appears in the list.
2375 let connection_a = StubAgentConnection::new();
2376 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2377 acp::ContentChunk::new("Done".into()),
2378 )]);
2379 open_thread_with_connection(&panel_a, connection_a, cx);
2380 send_message(&panel_a, cx);
2381 let session_id_a = active_session_id(&panel_a, cx);
2382 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2383
2384 // Add a second workspace with its own agent panel.
2385 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2386 fs.as_fake()
2387 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2388 .await;
2389 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2390 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2391 mw.test_add_workspace(project_b.clone(), window, cx)
2392 });
2393 let panel_b = add_agent_panel(&workspace_b, cx);
2394 cx.run_until_parked();
2395
2396 let workspace_a =
2397 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2398
2399 // ── 1. Initial state: focused thread derived from active panel ─────
2400 sidebar.read_with(cx, |sidebar, _cx| {
2401 assert_active_thread(
2402 sidebar,
2403 &session_id_a,
2404 "The active panel's thread should be focused on startup",
2405 );
2406 });
2407
2408 let thread_metadata_a = cx.update(|_window, cx| {
2409 ThreadMetadataStore::global(cx)
2410 .read(cx)
2411 .entry_by_session(&session_id_a)
2412 .cloned()
2413 .expect("session_id_a should exist in metadata store")
2414 });
2415 sidebar.update_in(cx, |sidebar, window, cx| {
2416 sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
2417 });
2418 cx.run_until_parked();
2419
2420 sidebar.read_with(cx, |sidebar, _cx| {
2421 assert_active_thread(
2422 sidebar,
2423 &session_id_a,
2424 "After clicking a thread, it should be the focused thread",
2425 );
2426 assert!(
2427 has_thread_entry(sidebar, &session_id_a),
2428 "The clicked thread should be present in the entries"
2429 );
2430 });
2431
2432 workspace_a.read_with(cx, |workspace, cx| {
2433 assert!(
2434 workspace.panel::<AgentPanel>(cx).is_some(),
2435 "Agent panel should exist"
2436 );
2437 let dock = workspace.left_dock().read(cx);
2438 assert!(
2439 dock.is_open(),
2440 "Clicking a thread should open the agent panel dock"
2441 );
2442 });
2443
2444 let connection_b = StubAgentConnection::new();
2445 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2446 acp::ContentChunk::new("Thread B".into()),
2447 )]);
2448 open_thread_with_connection(&panel_b, connection_b, cx);
2449 send_message(&panel_b, cx);
2450 let session_id_b = active_session_id(&panel_b, cx);
2451 save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2452 cx.run_until_parked();
2453
2454 // Workspace A is currently active. Click a thread in workspace B,
2455 // which also triggers a workspace switch.
2456 let thread_metadata_b = cx.update(|_window, cx| {
2457 ThreadMetadataStore::global(cx)
2458 .read(cx)
2459 .entry_by_session(&session_id_b)
2460 .cloned()
2461 .expect("session_id_b should exist in metadata store")
2462 });
2463 sidebar.update_in(cx, |sidebar, window, cx| {
2464 sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
2465 });
2466 cx.run_until_parked();
2467
2468 sidebar.read_with(cx, |sidebar, _cx| {
2469 assert_active_thread(
2470 sidebar,
2471 &session_id_b,
2472 "Clicking a thread in another workspace should focus that thread",
2473 );
2474 assert!(
2475 has_thread_entry(sidebar, &session_id_b),
2476 "The cross-workspace thread should be present in the entries"
2477 );
2478 });
2479
2480 multi_workspace.update_in(cx, |mw, window, cx| {
2481 let workspace = mw.workspaces().next().unwrap().clone();
2482 mw.activate(workspace, window, cx);
2483 });
2484 cx.run_until_parked();
2485
2486 sidebar.read_with(cx, |sidebar, _cx| {
2487 assert_active_thread(
2488 sidebar,
2489 &session_id_a,
2490 "Switching workspace should seed focused_thread from the new active panel",
2491 );
2492 assert!(
2493 has_thread_entry(sidebar, &session_id_a),
2494 "The seeded thread should be present in the entries"
2495 );
2496 });
2497
2498 let connection_b2 = StubAgentConnection::new();
2499 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2500 acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2501 )]);
2502 open_thread_with_connection(&panel_b, connection_b2, cx);
2503 send_message(&panel_b, cx);
2504 let session_id_b2 = active_session_id(&panel_b, cx);
2505 save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2506 cx.run_until_parked();
2507
2508 // Panel B is not the active workspace's panel (workspace A is
2509 // active), so opening a thread there should not change focused_thread.
2510 // This prevents running threads in background workspaces from causing
2511 // the selection highlight to jump around.
2512 sidebar.read_with(cx, |sidebar, _cx| {
2513 assert_active_thread(
2514 sidebar,
2515 &session_id_a,
2516 "Opening a thread in a non-active panel should not change focused_thread",
2517 );
2518 });
2519
2520 workspace_b.update_in(cx, |workspace, window, cx| {
2521 workspace.focus_handle(cx).focus(window, cx);
2522 });
2523 cx.run_until_parked();
2524
2525 sidebar.read_with(cx, |sidebar, _cx| {
2526 assert_active_thread(
2527 sidebar,
2528 &session_id_a,
2529 "Defocusing the sidebar should not change focused_thread",
2530 );
2531 });
2532
2533 // Switching workspaces via the multi_workspace (simulates clicking
2534 // a workspace header) should clear focused_thread.
2535 multi_workspace.update_in(cx, |mw, window, cx| {
2536 let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2537 if let Some(workspace) = workspace {
2538 mw.activate(workspace, window, cx);
2539 }
2540 });
2541 cx.run_until_parked();
2542
2543 sidebar.read_with(cx, |sidebar, _cx| {
2544 assert_active_thread(
2545 sidebar,
2546 &session_id_b2,
2547 "Switching workspace should seed focused_thread from the new active panel",
2548 );
2549 assert!(
2550 has_thread_entry(sidebar, &session_id_b2),
2551 "The seeded thread should be present in the entries"
2552 );
2553 });
2554
2555 // ── 8. Focusing the agent panel thread keeps focused_thread ────
2556 // Workspace B still has session_id_b2 loaded in the agent panel.
2557 // Clicking into the thread (simulated by focusing its view) should
2558 // keep focused_thread since it was already seeded on workspace switch.
2559 panel_b.update_in(cx, |panel, window, cx| {
2560 if let Some(thread_view) = panel.active_conversation_view() {
2561 thread_view.read(cx).focus_handle(cx).focus(window, cx);
2562 }
2563 });
2564 cx.run_until_parked();
2565
2566 sidebar.read_with(cx, |sidebar, _cx| {
2567 assert_active_thread(
2568 sidebar,
2569 &session_id_b2,
2570 "Focusing the agent panel thread should set focused_thread",
2571 );
2572 assert!(
2573 has_thread_entry(sidebar, &session_id_b2),
2574 "The focused thread should be present in the entries"
2575 );
2576 });
2577}
2578
2579#[gpui::test]
2580async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2581 let project = init_test_project_with_agent_panel("/project-a", cx).await;
2582 let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2583 let (multi_workspace, cx) =
2584 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2585 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2586
2587 // Start a thread and send a message so it has history.
2588 let connection = StubAgentConnection::new();
2589 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2590 acp::ContentChunk::new("Done".into()),
2591 )]);
2592 open_thread_with_connection(&panel, connection, cx);
2593 send_message(&panel, cx);
2594 let session_id = active_session_id(&panel, cx);
2595 save_test_thread_metadata(&session_id, &project, cx).await;
2596 cx.run_until_parked();
2597
2598 // Verify the thread appears in the sidebar.
2599 assert_eq!(
2600 visible_entries_as_strings(&sidebar, cx),
2601 vec![
2602 //
2603 "v [project-a]",
2604 " Hello *",
2605 ]
2606 );
2607
2608 // The "New Thread" button should NOT be in "active/draft" state
2609 // because the panel has a thread with messages.
2610 sidebar.read_with(cx, |sidebar, _cx| {
2611 assert!(
2612 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2613 "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2614 sidebar.active_entry,
2615 );
2616 });
2617
2618 // Now add a second folder to the workspace, changing the path_list.
2619 fs.as_fake()
2620 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2621 .await;
2622 project
2623 .update(cx, |project, cx| {
2624 project.find_or_create_worktree("/project-b", true, cx)
2625 })
2626 .await
2627 .expect("should add worktree");
2628 cx.run_until_parked();
2629
2630 // The workspace path_list is now [project-a, project-b]. The active
2631 // thread's metadata was re-saved with the new paths by the agent panel's
2632 // project subscription. The old [project-a] key is replaced by the new
2633 // key since no other workspace claims it.
2634 let entries = visible_entries_as_strings(&sidebar, cx);
2635 // After adding a worktree, the thread migrates to the new group key.
2636 // A reconciliation draft may appear during the transition.
2637 assert!(
2638 entries.contains(&" Hello *".to_string()),
2639 "thread should still be present after adding folder: {entries:?}"
2640 );
2641 assert_eq!(entries[0], "v [project-a, project-b]");
2642
2643 // The "New Thread" button must still be clickable (not stuck in
2644 // "active/draft" state). Verify that `active_thread_is_draft` is
2645 // false — the panel still has the old thread with messages.
2646 sidebar.read_with(cx, |sidebar, _cx| {
2647 assert!(
2648 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2649 "After adding a folder the panel still has a thread with messages, \
2650 so active_entry should be Thread, got {:?}",
2651 sidebar.active_entry,
2652 );
2653 });
2654
2655 // Actually click "New Thread" by calling create_new_thread and
2656 // verify a new draft is created.
2657 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2658 sidebar.update_in(cx, |sidebar, window, cx| {
2659 sidebar.create_new_thread(&workspace, window, cx);
2660 });
2661 cx.run_until_parked();
2662
2663 // After creating a new thread, the panel should now be in draft
2664 // state (no messages on the new thread).
2665 sidebar.read_with(cx, |sidebar, _cx| {
2666 assert_active_draft(
2667 sidebar,
2668 &workspace,
2669 "After creating a new thread active_entry should be Draft",
2670 );
2671 });
2672}
2673#[gpui::test]
2674async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
2675 // When the user presses Cmd-N (NewThread action) while viewing a
2676 // non-empty thread, the panel should switch to the draft thread.
2677 // Drafts are not shown as sidebar rows.
2678 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2679 let (multi_workspace, cx) =
2680 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2681 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2682
2683 // Create a non-empty thread (has messages).
2684 let connection = StubAgentConnection::new();
2685 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2686 acp::ContentChunk::new("Done".into()),
2687 )]);
2688 open_thread_with_connection(&panel, connection, cx);
2689 send_message(&panel, cx);
2690
2691 let session_id = active_session_id(&panel, cx);
2692 save_test_thread_metadata(&session_id, &project, cx).await;
2693 cx.run_until_parked();
2694
2695 assert_eq!(
2696 visible_entries_as_strings(&sidebar, cx),
2697 vec![
2698 //
2699 "v [my-project]",
2700 " Hello *",
2701 ]
2702 );
2703
2704 // Simulate cmd-n
2705 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2706 panel.update_in(cx, |panel, window, cx| {
2707 panel.new_thread(&NewThread, window, cx);
2708 });
2709 workspace.update_in(cx, |workspace, window, cx| {
2710 workspace.focus_panel::<AgentPanel>(window, cx);
2711 });
2712 cx.run_until_parked();
2713
2714 // Drafts are not shown as sidebar rows, so entries stay the same.
2715 assert_eq!(
2716 visible_entries_as_strings(&sidebar, cx),
2717 vec!["v [my-project]", " Hello *"],
2718 "After Cmd-N the sidebar should not show a Draft entry"
2719 );
2720
2721 // The panel should be on the draft and active_entry should track it.
2722 panel.read_with(cx, |panel, cx| {
2723 assert!(
2724 panel.active_thread_is_draft(cx),
2725 "panel should be showing the draft after Cmd-N",
2726 );
2727 });
2728 sidebar.read_with(cx, |sidebar, _cx| {
2729 assert_active_draft(
2730 sidebar,
2731 &workspace,
2732 "active_entry should be Draft after Cmd-N",
2733 );
2734 });
2735}
2736
2737#[gpui::test]
2738async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
2739 // When the active workspace is an absorbed git worktree, cmd-n
2740 // should activate the draft thread in the panel. Drafts are not
2741 // shown as sidebar rows.
2742 agent_ui::test_support::init_test(cx);
2743 cx.update(|cx| {
2744 ThreadStore::init_global(cx);
2745 ThreadMetadataStore::init_global(cx);
2746 language_model::LanguageModelRegistry::test(cx);
2747 prompt_store::init(cx);
2748 });
2749
2750 let fs = FakeFs::new(cx.executor());
2751
2752 // Main repo with a linked worktree.
2753 fs.insert_tree(
2754 "/project",
2755 serde_json::json!({
2756 ".git": {},
2757 "src": {},
2758 }),
2759 )
2760 .await;
2761
2762 // Worktree checkout pointing back to the main repo.
2763 fs.add_linked_worktree_for_repo(
2764 Path::new("/project/.git"),
2765 false,
2766 git::repository::Worktree {
2767 path: std::path::PathBuf::from("/wt-feature-a"),
2768 ref_name: Some("refs/heads/feature-a".into()),
2769 sha: "aaa".into(),
2770 is_main: false,
2771 is_bare: false,
2772 },
2773 )
2774 .await;
2775
2776 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2777
2778 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2779 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2780
2781 main_project
2782 .update(cx, |p, cx| p.git_scans_complete(cx))
2783 .await;
2784 worktree_project
2785 .update(cx, |p, cx| p.git_scans_complete(cx))
2786 .await;
2787
2788 let (multi_workspace, cx) =
2789 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
2790
2791 let sidebar = setup_sidebar(&multi_workspace, cx);
2792
2793 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
2794 mw.test_add_workspace(worktree_project.clone(), window, cx)
2795 });
2796
2797 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
2798
2799 // Switch to the worktree workspace.
2800 multi_workspace.update_in(cx, |mw, window, cx| {
2801 let workspace = mw.workspaces().nth(1).unwrap().clone();
2802 mw.activate(workspace, window, cx);
2803 });
2804
2805 // Create a non-empty thread in the worktree workspace.
2806 let connection = StubAgentConnection::new();
2807 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2808 acp::ContentChunk::new("Done".into()),
2809 )]);
2810 open_thread_with_connection(&worktree_panel, connection, cx);
2811 send_message(&worktree_panel, cx);
2812
2813 let session_id = active_session_id(&worktree_panel, cx);
2814 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
2815 cx.run_until_parked();
2816
2817 assert_eq!(
2818 visible_entries_as_strings(&sidebar, cx),
2819 vec![
2820 //
2821 "v [project]",
2822 " Hello {wt-feature-a} *",
2823 ]
2824 );
2825
2826 // Simulate Cmd-N in the worktree workspace.
2827 worktree_panel.update_in(cx, |panel, window, cx| {
2828 panel.new_thread(&NewThread, window, cx);
2829 });
2830 worktree_workspace.update_in(cx, |workspace, window, cx| {
2831 workspace.focus_panel::<AgentPanel>(window, cx);
2832 });
2833 cx.run_until_parked();
2834
2835 // Drafts are not shown as sidebar rows, so entries stay the same.
2836 assert_eq!(
2837 visible_entries_as_strings(&sidebar, cx),
2838 vec![
2839 //
2840 "v [project]",
2841 " Hello {wt-feature-a} *"
2842 ],
2843 "After Cmd-N the sidebar should not show a Draft entry"
2844 );
2845
2846 // The panel should be on the draft and active_entry should track it.
2847 worktree_panel.read_with(cx, |panel, cx| {
2848 assert!(
2849 panel.active_thread_is_draft(cx),
2850 "panel should be showing the draft after Cmd-N",
2851 );
2852 });
2853 sidebar.read_with(cx, |sidebar, _cx| {
2854 assert_active_draft(
2855 sidebar,
2856 &worktree_workspace,
2857 "active_entry should be Draft after Cmd-N",
2858 );
2859 });
2860}
2861
2862async fn init_test_project_with_git(
2863 worktree_path: &str,
2864 cx: &mut TestAppContext,
2865) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
2866 init_test(cx);
2867 let fs = FakeFs::new(cx.executor());
2868 fs.insert_tree(
2869 worktree_path,
2870 serde_json::json!({
2871 ".git": {},
2872 "src": {},
2873 }),
2874 )
2875 .await;
2876 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2877 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
2878 (project, fs)
2879}
2880
2881#[gpui::test]
2882async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
2883 let (project, fs) = init_test_project_with_git("/project", cx).await;
2884
2885 fs.as_fake()
2886 .add_linked_worktree_for_repo(
2887 Path::new("/project/.git"),
2888 false,
2889 git::repository::Worktree {
2890 path: std::path::PathBuf::from("/wt/rosewood"),
2891 ref_name: Some("refs/heads/rosewood".into()),
2892 sha: "abc".into(),
2893 is_main: false,
2894 is_bare: false,
2895 },
2896 )
2897 .await;
2898
2899 project
2900 .update(cx, |project, cx| project.git_scans_complete(cx))
2901 .await;
2902
2903 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
2904 worktree_project
2905 .update(cx, |p, cx| p.git_scans_complete(cx))
2906 .await;
2907
2908 let (multi_workspace, cx) =
2909 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2910 let sidebar = setup_sidebar(&multi_workspace, cx);
2911
2912 save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
2913 save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
2914
2915 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2916 cx.run_until_parked();
2917
2918 // Search for "rosewood" — should match the worktree name, not the title.
2919 type_in_search(&sidebar, "rosewood", cx);
2920
2921 assert_eq!(
2922 visible_entries_as_strings(&sidebar, cx),
2923 vec![
2924 //
2925 "v [project]",
2926 " Fix Bug {rosewood} <== selected",
2927 ],
2928 );
2929}
2930
2931#[gpui::test]
2932async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
2933 let (project, fs) = init_test_project_with_git("/project", cx).await;
2934
2935 project
2936 .update(cx, |project, cx| project.git_scans_complete(cx))
2937 .await;
2938
2939 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
2940 worktree_project
2941 .update(cx, |p, cx| p.git_scans_complete(cx))
2942 .await;
2943
2944 let (multi_workspace, cx) =
2945 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2946 let sidebar = setup_sidebar(&multi_workspace, cx);
2947
2948 // Save a thread against a worktree path with the correct main
2949 // worktree association (as if the git state had been resolved).
2950 save_thread_metadata_with_main_paths(
2951 "wt-thread",
2952 "Worktree Thread",
2953 PathList::new(&[PathBuf::from("/wt/rosewood")]),
2954 PathList::new(&[PathBuf::from("/project")]),
2955 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2956 cx,
2957 );
2958
2959 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2960 cx.run_until_parked();
2961
2962 // Thread is visible because its main_worktree_paths match the group.
2963 // The chip name is derived from the path even before git discovery.
2964 assert_eq!(
2965 visible_entries_as_strings(&sidebar, cx),
2966 vec!["v [project]", " Worktree Thread {rosewood}"]
2967 );
2968
2969 // Now add the worktree to the git state and trigger a rescan.
2970 fs.as_fake()
2971 .add_linked_worktree_for_repo(
2972 Path::new("/project/.git"),
2973 true,
2974 git::repository::Worktree {
2975 path: std::path::PathBuf::from("/wt/rosewood"),
2976 ref_name: Some("refs/heads/rosewood".into()),
2977 sha: "abc".into(),
2978 is_main: false,
2979 is_bare: false,
2980 },
2981 )
2982 .await;
2983
2984 cx.run_until_parked();
2985
2986 assert_eq!(
2987 visible_entries_as_strings(&sidebar, cx),
2988 vec![
2989 //
2990 "v [project]",
2991 " Worktree Thread {rosewood}",
2992 ]
2993 );
2994}
2995
2996#[gpui::test]
2997async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
2998 init_test(cx);
2999 let fs = FakeFs::new(cx.executor());
3000
3001 // Create the main repo directory (not opened as a workspace yet).
3002 fs.insert_tree(
3003 "/project",
3004 serde_json::json!({
3005 ".git": {
3006 },
3007 "src": {},
3008 }),
3009 )
3010 .await;
3011
3012 // Two worktree checkouts whose .git files point back to the main repo.
3013 fs.add_linked_worktree_for_repo(
3014 Path::new("/project/.git"),
3015 false,
3016 git::repository::Worktree {
3017 path: std::path::PathBuf::from("/wt-feature-a"),
3018 ref_name: Some("refs/heads/feature-a".into()),
3019 sha: "aaa".into(),
3020 is_main: false,
3021 is_bare: false,
3022 },
3023 )
3024 .await;
3025 fs.add_linked_worktree_for_repo(
3026 Path::new("/project/.git"),
3027 false,
3028 git::repository::Worktree {
3029 path: std::path::PathBuf::from("/wt-feature-b"),
3030 ref_name: Some("refs/heads/feature-b".into()),
3031 sha: "bbb".into(),
3032 is_main: false,
3033 is_bare: false,
3034 },
3035 )
3036 .await;
3037
3038 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3039
3040 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3041 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3042
3043 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3044 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3045
3046 // Open both worktrees as workspaces — no main repo yet.
3047 let (multi_workspace, cx) =
3048 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3049 multi_workspace.update_in(cx, |mw, window, cx| {
3050 mw.test_add_workspace(project_b.clone(), window, cx);
3051 });
3052 let sidebar = setup_sidebar(&multi_workspace, cx);
3053
3054 save_thread_metadata(
3055 acp::SessionId::new(Arc::from("thread-a")),
3056 Some("Thread A".into()),
3057 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3058 None,
3059 &project_a,
3060 cx,
3061 );
3062 save_thread_metadata(
3063 acp::SessionId::new(Arc::from("thread-b")),
3064 Some("Thread B".into()),
3065 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
3066 None,
3067 &project_b,
3068 cx,
3069 );
3070
3071 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3072 cx.run_until_parked();
3073
3074 // Without the main repo, each worktree has its own header.
3075 assert_eq!(
3076 visible_entries_as_strings(&sidebar, cx),
3077 vec![
3078 //
3079 "v [project]",
3080 " Thread B {wt-feature-b}",
3081 " Thread A {wt-feature-a}",
3082 ]
3083 );
3084
3085 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3086 main_project
3087 .update(cx, |p, cx| p.git_scans_complete(cx))
3088 .await;
3089
3090 multi_workspace.update_in(cx, |mw, window, cx| {
3091 mw.test_add_workspace(main_project.clone(), window, cx);
3092 });
3093 cx.run_until_parked();
3094
3095 // Both worktree workspaces should now be absorbed under the main
3096 // repo header, with worktree chips.
3097 assert_eq!(
3098 visible_entries_as_strings(&sidebar, cx),
3099 vec![
3100 //
3101 "v [project]",
3102 " Thread B {wt-feature-b}",
3103 " Thread A {wt-feature-a}",
3104 ]
3105 );
3106}
3107
3108#[gpui::test]
3109async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
3110 // When a group has two workspaces — one with threads and one
3111 // without — the threadless workspace should appear as a
3112 // "New Thread" button with its worktree chip.
3113 init_test(cx);
3114 let fs = FakeFs::new(cx.executor());
3115
3116 // Main repo with two linked worktrees.
3117 fs.insert_tree(
3118 "/project",
3119 serde_json::json!({
3120 ".git": {},
3121 "src": {},
3122 }),
3123 )
3124 .await;
3125 fs.add_linked_worktree_for_repo(
3126 Path::new("/project/.git"),
3127 false,
3128 git::repository::Worktree {
3129 path: std::path::PathBuf::from("/wt-feature-a"),
3130 ref_name: Some("refs/heads/feature-a".into()),
3131 sha: "aaa".into(),
3132 is_main: false,
3133 is_bare: false,
3134 },
3135 )
3136 .await;
3137 fs.add_linked_worktree_for_repo(
3138 Path::new("/project/.git"),
3139 false,
3140 git::repository::Worktree {
3141 path: std::path::PathBuf::from("/wt-feature-b"),
3142 ref_name: Some("refs/heads/feature-b".into()),
3143 sha: "bbb".into(),
3144 is_main: false,
3145 is_bare: false,
3146 },
3147 )
3148 .await;
3149
3150 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3151
3152 // Workspace A: worktree feature-a (has threads).
3153 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3154 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3155
3156 // Workspace B: worktree feature-b (no threads).
3157 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3158 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3159
3160 let (multi_workspace, cx) =
3161 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3162 multi_workspace.update_in(cx, |mw, window, cx| {
3163 mw.test_add_workspace(project_b.clone(), window, cx);
3164 });
3165 let sidebar = setup_sidebar(&multi_workspace, cx);
3166
3167 // Only save a thread for workspace A.
3168 save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3169
3170 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3171 cx.run_until_parked();
3172
3173 // Workspace A's thread appears normally. Workspace B (threadless)
3174 // appears as a "New Thread" button with its worktree chip.
3175 assert_eq!(
3176 visible_entries_as_strings(&sidebar, cx),
3177 vec!["v [project]", " Thread A {wt-feature-a}",]
3178 );
3179}
3180
3181#[gpui::test]
3182async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
3183 // A thread created in a workspace with roots from different git
3184 // worktrees should show a chip for each distinct worktree name.
3185 init_test(cx);
3186 let fs = FakeFs::new(cx.executor());
3187
3188 // Two main repos.
3189 fs.insert_tree(
3190 "/project_a",
3191 serde_json::json!({
3192 ".git": {},
3193 "src": {},
3194 }),
3195 )
3196 .await;
3197 fs.insert_tree(
3198 "/project_b",
3199 serde_json::json!({
3200 ".git": {},
3201 "src": {},
3202 }),
3203 )
3204 .await;
3205
3206 // Worktree checkouts.
3207 for repo in &["project_a", "project_b"] {
3208 let git_path = format!("/{repo}/.git");
3209 for branch in &["olivetti", "selectric"] {
3210 fs.add_linked_worktree_for_repo(
3211 Path::new(&git_path),
3212 false,
3213 git::repository::Worktree {
3214 path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
3215 ref_name: Some(format!("refs/heads/{branch}").into()),
3216 sha: "aaa".into(),
3217 is_main: false,
3218 is_bare: false,
3219 },
3220 )
3221 .await;
3222 }
3223 }
3224
3225 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3226
3227 // Open a workspace with the worktree checkout paths as roots
3228 // (this is the workspace the thread was created in).
3229 let project = project::Project::test(
3230 fs.clone(),
3231 [
3232 "/worktrees/project_a/olivetti/project_a".as_ref(),
3233 "/worktrees/project_b/selectric/project_b".as_ref(),
3234 ],
3235 cx,
3236 )
3237 .await;
3238 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3239
3240 let (multi_workspace, cx) =
3241 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3242 let sidebar = setup_sidebar(&multi_workspace, cx);
3243
3244 // Save a thread under the same paths as the workspace roots.
3245 save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
3246
3247 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3248 cx.run_until_parked();
3249
3250 // Should show two distinct worktree chips.
3251 assert_eq!(
3252 visible_entries_as_strings(&sidebar, cx),
3253 vec![
3254 //
3255 "v [project_a, project_b]",
3256 " Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
3257 ]
3258 );
3259}
3260
3261#[gpui::test]
3262async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
3263 // When a thread's roots span multiple repos but share the same
3264 // worktree name (e.g. both in "olivetti"), only one chip should
3265 // appear.
3266 init_test(cx);
3267 let fs = FakeFs::new(cx.executor());
3268
3269 fs.insert_tree(
3270 "/project_a",
3271 serde_json::json!({
3272 ".git": {},
3273 "src": {},
3274 }),
3275 )
3276 .await;
3277 fs.insert_tree(
3278 "/project_b",
3279 serde_json::json!({
3280 ".git": {},
3281 "src": {},
3282 }),
3283 )
3284 .await;
3285
3286 for repo in &["project_a", "project_b"] {
3287 let git_path = format!("/{repo}/.git");
3288 fs.add_linked_worktree_for_repo(
3289 Path::new(&git_path),
3290 false,
3291 git::repository::Worktree {
3292 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
3293 ref_name: Some("refs/heads/olivetti".into()),
3294 sha: "aaa".into(),
3295 is_main: false,
3296 is_bare: false,
3297 },
3298 )
3299 .await;
3300 }
3301
3302 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3303
3304 let project = project::Project::test(
3305 fs.clone(),
3306 [
3307 "/worktrees/project_a/olivetti/project_a".as_ref(),
3308 "/worktrees/project_b/olivetti/project_b".as_ref(),
3309 ],
3310 cx,
3311 )
3312 .await;
3313 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3314
3315 let (multi_workspace, cx) =
3316 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3317 let sidebar = setup_sidebar(&multi_workspace, cx);
3318
3319 // Thread with roots in both repos' "olivetti" worktrees.
3320 save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
3321
3322 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3323 cx.run_until_parked();
3324
3325 // Both worktree paths have the name "olivetti", so only one chip.
3326 assert_eq!(
3327 visible_entries_as_strings(&sidebar, cx),
3328 vec![
3329 //
3330 "v [project_a, project_b]",
3331 " Same Branch Thread {olivetti}",
3332 ]
3333 );
3334}
3335
3336#[gpui::test]
3337async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
3338 // When a worktree workspace is absorbed under the main repo, a
3339 // running thread in the worktree's agent panel should still show
3340 // live status (spinner + "(running)") in the sidebar.
3341 agent_ui::test_support::init_test(cx);
3342 cx.update(|cx| {
3343 ThreadStore::init_global(cx);
3344 ThreadMetadataStore::init_global(cx);
3345 language_model::LanguageModelRegistry::test(cx);
3346 prompt_store::init(cx);
3347 });
3348
3349 let fs = FakeFs::new(cx.executor());
3350
3351 // Main repo with a linked worktree.
3352 fs.insert_tree(
3353 "/project",
3354 serde_json::json!({
3355 ".git": {},
3356 "src": {},
3357 }),
3358 )
3359 .await;
3360
3361 // Worktree checkout pointing back to the main repo.
3362 fs.add_linked_worktree_for_repo(
3363 Path::new("/project/.git"),
3364 false,
3365 git::repository::Worktree {
3366 path: std::path::PathBuf::from("/wt-feature-a"),
3367 ref_name: Some("refs/heads/feature-a".into()),
3368 sha: "aaa".into(),
3369 is_main: false,
3370 is_bare: false,
3371 },
3372 )
3373 .await;
3374
3375 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3376
3377 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3378 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3379
3380 main_project
3381 .update(cx, |p, cx| p.git_scans_complete(cx))
3382 .await;
3383 worktree_project
3384 .update(cx, |p, cx| p.git_scans_complete(cx))
3385 .await;
3386
3387 // Create the MultiWorkspace with both projects.
3388 let (multi_workspace, cx) =
3389 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3390
3391 let sidebar = setup_sidebar(&multi_workspace, cx);
3392
3393 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3394 mw.test_add_workspace(worktree_project.clone(), window, cx)
3395 });
3396
3397 // Add an agent panel to the worktree workspace so we can run a
3398 // thread inside it.
3399 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3400
3401 // Switch back to the main workspace before setting up the sidebar.
3402 multi_workspace.update_in(cx, |mw, window, cx| {
3403 let workspace = mw.workspaces().next().unwrap().clone();
3404 mw.activate(workspace, window, cx);
3405 });
3406
3407 // Start a thread in the worktree workspace's panel and keep it
3408 // generating (don't resolve it).
3409 let connection = StubAgentConnection::new();
3410 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3411 send_message(&worktree_panel, cx);
3412
3413 let session_id = active_session_id(&worktree_panel, cx);
3414
3415 // Save metadata so the sidebar knows about this thread.
3416 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3417
3418 // Keep the thread generating by sending a chunk without ending
3419 // the turn.
3420 cx.update(|_, cx| {
3421 connection.send_update(
3422 session_id.clone(),
3423 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3424 cx,
3425 );
3426 });
3427 cx.run_until_parked();
3428
3429 // The worktree thread should be absorbed under the main project
3430 // and show live running status.
3431 let entries = visible_entries_as_strings(&sidebar, cx);
3432 assert_eq!(
3433 entries,
3434 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3435 );
3436}
3437
3438#[gpui::test]
3439async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
3440 agent_ui::test_support::init_test(cx);
3441 cx.update(|cx| {
3442 ThreadStore::init_global(cx);
3443 ThreadMetadataStore::init_global(cx);
3444 language_model::LanguageModelRegistry::test(cx);
3445 prompt_store::init(cx);
3446 });
3447
3448 let fs = FakeFs::new(cx.executor());
3449
3450 fs.insert_tree(
3451 "/project",
3452 serde_json::json!({
3453 ".git": {},
3454 "src": {},
3455 }),
3456 )
3457 .await;
3458
3459 fs.add_linked_worktree_for_repo(
3460 Path::new("/project/.git"),
3461 false,
3462 git::repository::Worktree {
3463 path: std::path::PathBuf::from("/wt-feature-a"),
3464 ref_name: Some("refs/heads/feature-a".into()),
3465 sha: "aaa".into(),
3466 is_main: false,
3467 is_bare: false,
3468 },
3469 )
3470 .await;
3471
3472 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3473
3474 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3475 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3476
3477 main_project
3478 .update(cx, |p, cx| p.git_scans_complete(cx))
3479 .await;
3480 worktree_project
3481 .update(cx, |p, cx| p.git_scans_complete(cx))
3482 .await;
3483
3484 let (multi_workspace, cx) =
3485 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3486
3487 let sidebar = setup_sidebar(&multi_workspace, cx);
3488
3489 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3490 mw.test_add_workspace(worktree_project.clone(), window, cx)
3491 });
3492
3493 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3494
3495 multi_workspace.update_in(cx, |mw, window, cx| {
3496 let workspace = mw.workspaces().next().unwrap().clone();
3497 mw.activate(workspace, window, cx);
3498 });
3499
3500 let connection = StubAgentConnection::new();
3501 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3502 send_message(&worktree_panel, cx);
3503
3504 let session_id = active_session_id(&worktree_panel, cx);
3505 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3506
3507 cx.update(|_, cx| {
3508 connection.send_update(
3509 session_id.clone(),
3510 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3511 cx,
3512 );
3513 });
3514 cx.run_until_parked();
3515
3516 assert_eq!(
3517 visible_entries_as_strings(&sidebar, cx),
3518 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3519 );
3520
3521 connection.end_turn(session_id, acp::StopReason::EndTurn);
3522 cx.run_until_parked();
3523
3524 assert_eq!(
3525 visible_entries_as_strings(&sidebar, cx),
3526 vec!["v [project]", " Hello {wt-feature-a} * (!)",]
3527 );
3528}
3529
3530#[gpui::test]
3531async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
3532 init_test(cx);
3533 let fs = FakeFs::new(cx.executor());
3534
3535 fs.insert_tree(
3536 "/project",
3537 serde_json::json!({
3538 ".git": {},
3539 "src": {},
3540 }),
3541 )
3542 .await;
3543
3544 fs.add_linked_worktree_for_repo(
3545 Path::new("/project/.git"),
3546 false,
3547 git::repository::Worktree {
3548 path: std::path::PathBuf::from("/wt-feature-a"),
3549 ref_name: Some("refs/heads/feature-a".into()),
3550 sha: "aaa".into(),
3551 is_main: false,
3552 is_bare: false,
3553 },
3554 )
3555 .await;
3556
3557 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3558
3559 // Only open the main repo — no workspace for the worktree.
3560 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3561 main_project
3562 .update(cx, |p, cx| p.git_scans_complete(cx))
3563 .await;
3564
3565 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3566 worktree_project
3567 .update(cx, |p, cx| p.git_scans_complete(cx))
3568 .await;
3569
3570 let (multi_workspace, cx) =
3571 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3572 let sidebar = setup_sidebar(&multi_workspace, cx);
3573
3574 // Save a thread for the worktree path (no workspace for it).
3575 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3576
3577 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3578 cx.run_until_parked();
3579
3580 // Thread should appear under the main repo with a worktree chip.
3581 assert_eq!(
3582 visible_entries_as_strings(&sidebar, cx),
3583 vec![
3584 //
3585 "v [project]",
3586 " WT Thread {wt-feature-a}",
3587 ],
3588 );
3589
3590 // Only 1 workspace should exist.
3591 assert_eq!(
3592 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3593 1,
3594 );
3595
3596 // Focus the sidebar and select the worktree thread.
3597 focus_sidebar(&sidebar, cx);
3598 sidebar.update_in(cx, |sidebar, _window, _cx| {
3599 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3600 });
3601
3602 // Confirm to open the worktree thread.
3603 cx.dispatch_action(Confirm);
3604 cx.run_until_parked();
3605
3606 // A new workspace should have been created for the worktree path.
3607 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
3608 assert_eq!(
3609 mw.workspaces().count(),
3610 2,
3611 "confirming a worktree thread without a workspace should open one",
3612 );
3613 mw.workspaces().nth(1).unwrap().clone()
3614 });
3615
3616 let new_path_list =
3617 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
3618 assert_eq!(
3619 new_path_list,
3620 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
3621 "the new workspace should have been opened for the worktree path",
3622 );
3623}
3624
3625#[gpui::test]
3626async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
3627 cx: &mut TestAppContext,
3628) {
3629 init_test(cx);
3630 let fs = FakeFs::new(cx.executor());
3631
3632 fs.insert_tree(
3633 "/project",
3634 serde_json::json!({
3635 ".git": {},
3636 "src": {},
3637 }),
3638 )
3639 .await;
3640
3641 fs.add_linked_worktree_for_repo(
3642 Path::new("/project/.git"),
3643 false,
3644 git::repository::Worktree {
3645 path: std::path::PathBuf::from("/wt-feature-a"),
3646 ref_name: Some("refs/heads/feature-a".into()),
3647 sha: "aaa".into(),
3648 is_main: false,
3649 is_bare: false,
3650 },
3651 )
3652 .await;
3653
3654 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3655
3656 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3657 main_project
3658 .update(cx, |p, cx| p.git_scans_complete(cx))
3659 .await;
3660
3661 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3662 worktree_project
3663 .update(cx, |p, cx| p.git_scans_complete(cx))
3664 .await;
3665
3666 let (multi_workspace, cx) =
3667 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3668 let sidebar = setup_sidebar(&multi_workspace, cx);
3669
3670 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3671
3672 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3673 cx.run_until_parked();
3674
3675 assert_eq!(
3676 visible_entries_as_strings(&sidebar, cx),
3677 vec![
3678 //
3679 "v [project]",
3680 " WT Thread {wt-feature-a}",
3681 ],
3682 );
3683
3684 focus_sidebar(&sidebar, cx);
3685 sidebar.update_in(cx, |sidebar, _window, _cx| {
3686 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3687 });
3688
3689 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
3690 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
3691 if let ListEntry::ProjectHeader { label, .. } = entry {
3692 Some(label.as_ref())
3693 } else {
3694 None
3695 }
3696 });
3697
3698 let Some(project_header) = project_headers.next() else {
3699 panic!("expected exactly one sidebar project header named `project`, found none");
3700 };
3701 assert_eq!(
3702 project_header, "project",
3703 "expected the only sidebar project header to be `project`"
3704 );
3705 if let Some(unexpected_header) = project_headers.next() {
3706 panic!(
3707 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
3708 );
3709 }
3710
3711 let mut saw_expected_thread = false;
3712 for entry in &sidebar.contents.entries {
3713 match entry {
3714 ListEntry::ProjectHeader { label, .. } => {
3715 assert_eq!(
3716 label.as_ref(),
3717 "project",
3718 "expected the only sidebar project header to be `project`"
3719 );
3720 }
3721 ListEntry::Thread(thread)
3722 if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
3723 && thread.worktrees.first().map(|wt| wt.name.as_ref())
3724 == Some("wt-feature-a") =>
3725 {
3726 saw_expected_thread = true;
3727 }
3728 ListEntry::Thread(thread) => {
3729 let title = thread.metadata.display_title();
3730 let worktree_name = thread
3731 .worktrees
3732 .first()
3733 .map(|wt| wt.name.as_ref())
3734 .unwrap_or("<none>");
3735 panic!(
3736 "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
3737 title, worktree_name
3738 );
3739 }
3740 }
3741 }
3742
3743 assert!(
3744 saw_expected_thread,
3745 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
3746 );
3747 };
3748
3749 sidebar
3750 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
3751 .detach();
3752
3753 let window = cx.windows()[0];
3754 cx.update_window(window, |_, window, cx| {
3755 window.dispatch_action(Confirm.boxed_clone(), cx);
3756 })
3757 .unwrap();
3758
3759 cx.run_until_parked();
3760
3761 sidebar.update(cx, assert_sidebar_state);
3762}
3763
3764#[gpui::test]
3765async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
3766 cx: &mut TestAppContext,
3767) {
3768 init_test(cx);
3769 let fs = FakeFs::new(cx.executor());
3770
3771 fs.insert_tree(
3772 "/project",
3773 serde_json::json!({
3774 ".git": {},
3775 "src": {},
3776 }),
3777 )
3778 .await;
3779
3780 fs.add_linked_worktree_for_repo(
3781 Path::new("/project/.git"),
3782 false,
3783 git::repository::Worktree {
3784 path: std::path::PathBuf::from("/wt-feature-a"),
3785 ref_name: Some("refs/heads/feature-a".into()),
3786 sha: "aaa".into(),
3787 is_main: false,
3788 is_bare: false,
3789 },
3790 )
3791 .await;
3792
3793 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3794
3795 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3796 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3797
3798 main_project
3799 .update(cx, |p, cx| p.git_scans_complete(cx))
3800 .await;
3801 worktree_project
3802 .update(cx, |p, cx| p.git_scans_complete(cx))
3803 .await;
3804
3805 let (multi_workspace, cx) =
3806 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3807
3808 let sidebar = setup_sidebar(&multi_workspace, cx);
3809
3810 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3811 mw.test_add_workspace(worktree_project.clone(), window, cx)
3812 });
3813
3814 // Activate the main workspace before setting up the sidebar.
3815 let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3816 let workspace = mw.workspaces().next().unwrap().clone();
3817 mw.activate(workspace.clone(), window, cx);
3818 workspace
3819 });
3820
3821 save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
3822 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3823
3824 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3825 cx.run_until_parked();
3826
3827 // The worktree workspace should be absorbed under the main repo.
3828 let entries = visible_entries_as_strings(&sidebar, cx);
3829 assert_eq!(entries.len(), 3);
3830 assert_eq!(entries[0], "v [project]");
3831 assert!(entries.contains(&" Main Thread".to_string()));
3832 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
3833
3834 let wt_thread_index = entries
3835 .iter()
3836 .position(|e| e.contains("WT Thread"))
3837 .expect("should find the worktree thread entry");
3838
3839 assert_eq!(
3840 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3841 main_workspace,
3842 "main workspace should be active initially"
3843 );
3844
3845 // Focus the sidebar and select the absorbed worktree thread.
3846 focus_sidebar(&sidebar, cx);
3847 sidebar.update_in(cx, |sidebar, _window, _cx| {
3848 sidebar.selection = Some(wt_thread_index);
3849 });
3850
3851 // Confirm to activate the worktree thread.
3852 cx.dispatch_action(Confirm);
3853 cx.run_until_parked();
3854
3855 // The worktree workspace should now be active, not the main one.
3856 let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3857 assert_eq!(
3858 active_workspace, worktree_workspace,
3859 "clicking an absorbed worktree thread should activate the worktree workspace"
3860 );
3861}
3862
3863#[gpui::test]
3864async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
3865 cx: &mut TestAppContext,
3866) {
3867 // Thread has saved metadata in ThreadStore. A matching workspace is
3868 // already open. Expected: activates the matching workspace.
3869 init_test(cx);
3870 let fs = FakeFs::new(cx.executor());
3871 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3872 .await;
3873 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3874 .await;
3875 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3876
3877 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3878 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3879
3880 let (multi_workspace, cx) =
3881 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3882
3883 let sidebar = setup_sidebar(&multi_workspace, cx);
3884
3885 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3886 mw.test_add_workspace(project_b.clone(), window, cx)
3887 });
3888 let workspace_a =
3889 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
3890
3891 // Save a thread with path_list pointing to project-b.
3892 let session_id = acp::SessionId::new(Arc::from("archived-1"));
3893 save_test_thread_metadata(&session_id, &project_b, cx).await;
3894
3895 // Ensure workspace A is active.
3896 multi_workspace.update_in(cx, |mw, window, cx| {
3897 let workspace = mw.workspaces().next().unwrap().clone();
3898 mw.activate(workspace, window, cx);
3899 });
3900 cx.run_until_parked();
3901 assert_eq!(
3902 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3903 workspace_a
3904 );
3905
3906 // Call activate_archived_thread – should resolve saved paths and
3907 // switch to the workspace for project-b.
3908 sidebar.update_in(cx, |sidebar, window, cx| {
3909 sidebar.open_thread_from_archive(
3910 ThreadMetadata {
3911 thread_id: ThreadId::new(),
3912 session_id: Some(session_id.clone()),
3913 agent_id: agent::ZED_AGENT_ID.clone(),
3914 title: Some("Archived Thread".into()),
3915 updated_at: Utc::now(),
3916 created_at: None,
3917 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
3918 "/project-b",
3919 )])),
3920 archived: false,
3921 remote_connection: None,
3922 },
3923 window,
3924 cx,
3925 );
3926 });
3927 cx.run_until_parked();
3928
3929 assert_eq!(
3930 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3931 workspace_b,
3932 "should have switched to the workspace matching the saved paths"
3933 );
3934}
3935
3936#[gpui::test]
3937async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
3938 cx: &mut TestAppContext,
3939) {
3940 // Thread has no saved metadata but session_info has cwd. A matching
3941 // workspace is open. Expected: uses cwd to find and activate it.
3942 init_test(cx);
3943 let fs = FakeFs::new(cx.executor());
3944 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3945 .await;
3946 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3947 .await;
3948 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3949
3950 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3951 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3952
3953 let (multi_workspace, cx) =
3954 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3955
3956 let sidebar = setup_sidebar(&multi_workspace, cx);
3957
3958 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3959 mw.test_add_workspace(project_b, window, cx)
3960 });
3961 let workspace_a =
3962 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
3963
3964 // Start with workspace A active.
3965 multi_workspace.update_in(cx, |mw, window, cx| {
3966 let workspace = mw.workspaces().next().unwrap().clone();
3967 mw.activate(workspace, window, cx);
3968 });
3969 cx.run_until_parked();
3970 assert_eq!(
3971 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3972 workspace_a
3973 );
3974
3975 // No thread saved to the store – cwd is the only path hint.
3976 sidebar.update_in(cx, |sidebar, window, cx| {
3977 sidebar.open_thread_from_archive(
3978 ThreadMetadata {
3979 thread_id: ThreadId::new(),
3980 session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
3981 agent_id: agent::ZED_AGENT_ID.clone(),
3982 title: Some("CWD Thread".into()),
3983 updated_at: Utc::now(),
3984 created_at: None,
3985 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
3986 std::path::PathBuf::from("/project-b"),
3987 ])),
3988 archived: false,
3989 remote_connection: None,
3990 },
3991 window,
3992 cx,
3993 );
3994 });
3995 cx.run_until_parked();
3996
3997 assert_eq!(
3998 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3999 workspace_b,
4000 "should have activated the workspace matching the cwd"
4001 );
4002}
4003
4004#[gpui::test]
4005async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4006 cx: &mut TestAppContext,
4007) {
4008 // Thread has no saved metadata and no cwd. Expected: falls back to
4009 // the currently active workspace.
4010 init_test(cx);
4011 let fs = FakeFs::new(cx.executor());
4012 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4013 .await;
4014 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4015 .await;
4016 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4017
4018 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4019 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4020
4021 let (multi_workspace, cx) =
4022 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4023
4024 let sidebar = setup_sidebar(&multi_workspace, cx);
4025
4026 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4027 mw.test_add_workspace(project_b, window, cx)
4028 });
4029
4030 // Activate workspace B (index 1) to make it the active one.
4031 multi_workspace.update_in(cx, |mw, window, cx| {
4032 let workspace = mw.workspaces().nth(1).unwrap().clone();
4033 mw.activate(workspace, window, cx);
4034 });
4035 cx.run_until_parked();
4036 assert_eq!(
4037 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4038 workspace_b
4039 );
4040
4041 // No saved thread, no cwd – should fall back to the active workspace.
4042 sidebar.update_in(cx, |sidebar, window, cx| {
4043 sidebar.open_thread_from_archive(
4044 ThreadMetadata {
4045 thread_id: ThreadId::new(),
4046 session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
4047 agent_id: agent::ZED_AGENT_ID.clone(),
4048 title: Some("Contextless Thread".into()),
4049 updated_at: Utc::now(),
4050 created_at: None,
4051 worktree_paths: WorktreePaths::default(),
4052 archived: false,
4053 remote_connection: None,
4054 },
4055 window,
4056 cx,
4057 );
4058 });
4059 cx.run_until_parked();
4060
4061 assert_eq!(
4062 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4063 workspace_b,
4064 "should have stayed on the active workspace when no path info is available"
4065 );
4066}
4067
4068#[gpui::test]
4069async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
4070 // Thread has saved metadata pointing to a path with no open workspace.
4071 // Expected: opens a new workspace for that path.
4072 init_test(cx);
4073 let fs = FakeFs::new(cx.executor());
4074 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4075 .await;
4076 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4077 .await;
4078 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4079
4080 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4081
4082 let (multi_workspace, cx) =
4083 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4084
4085 let sidebar = setup_sidebar(&multi_workspace, cx);
4086
4087 // Save a thread with path_list pointing to project-b – which has no
4088 // open workspace.
4089 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4090 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4091
4092 assert_eq!(
4093 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4094 1,
4095 "should start with one workspace"
4096 );
4097
4098 sidebar.update_in(cx, |sidebar, window, cx| {
4099 sidebar.open_thread_from_archive(
4100 ThreadMetadata {
4101 thread_id: ThreadId::new(),
4102 session_id: Some(session_id.clone()),
4103 agent_id: agent::ZED_AGENT_ID.clone(),
4104 title: Some("New WS Thread".into()),
4105 updated_at: Utc::now(),
4106 created_at: None,
4107 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
4108 archived: false,
4109 remote_connection: None,
4110 },
4111 window,
4112 cx,
4113 );
4114 });
4115 cx.run_until_parked();
4116
4117 assert_eq!(
4118 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4119 2,
4120 "should have opened a second workspace for the archived thread's saved paths"
4121 );
4122}
4123
4124#[gpui::test]
4125async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
4126 init_test(cx);
4127 let fs = FakeFs::new(cx.executor());
4128 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4129 .await;
4130 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4131 .await;
4132 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4133
4134 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4135 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4136
4137 let multi_workspace_a =
4138 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4139 let multi_workspace_b =
4140 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4141
4142 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4143 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4144
4145 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4146 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4147
4148 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4149 let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
4150
4151 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
4152
4153 sidebar.update_in(cx_a, |sidebar, window, cx| {
4154 sidebar.open_thread_from_archive(
4155 ThreadMetadata {
4156 thread_id: ThreadId::new(),
4157 session_id: Some(session_id.clone()),
4158 agent_id: agent::ZED_AGENT_ID.clone(),
4159 title: Some("Cross Window Thread".into()),
4160 updated_at: Utc::now(),
4161 created_at: None,
4162 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4163 "/project-b",
4164 )])),
4165 archived: false,
4166 remote_connection: None,
4167 },
4168 window,
4169 cx,
4170 );
4171 });
4172 cx_a.run_until_parked();
4173
4174 assert_eq!(
4175 multi_workspace_a
4176 .read_with(cx_a, |mw, _| mw.workspaces().count())
4177 .unwrap(),
4178 1,
4179 "should not add the other window's workspace into the current window"
4180 );
4181 assert_eq!(
4182 multi_workspace_b
4183 .read_with(cx_a, |mw, _| mw.workspaces().count())
4184 .unwrap(),
4185 1,
4186 "should reuse the existing workspace in the other window"
4187 );
4188 assert!(
4189 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4190 "should activate the window that already owns the matching workspace"
4191 );
4192 sidebar.read_with(cx_a, |sidebar, _| {
4193 assert!(
4194 !is_active_session(&sidebar, &session_id),
4195 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4196 );
4197 });
4198}
4199
4200#[gpui::test]
4201async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
4202 cx: &mut TestAppContext,
4203) {
4204 init_test(cx);
4205 let fs = FakeFs::new(cx.executor());
4206 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4207 .await;
4208 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4209 .await;
4210 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4211
4212 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4213 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4214
4215 let multi_workspace_a =
4216 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4217 let multi_workspace_b =
4218 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
4219
4220 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4221 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4222
4223 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4224 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4225
4226 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4227 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4228 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
4229 let _panel_b = add_agent_panel(&workspace_b, cx_b);
4230
4231 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
4232
4233 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4234 sidebar.open_thread_from_archive(
4235 ThreadMetadata {
4236 thread_id: ThreadId::new(),
4237 session_id: Some(session_id.clone()),
4238 agent_id: agent::ZED_AGENT_ID.clone(),
4239 title: Some("Cross Window Thread".into()),
4240 updated_at: Utc::now(),
4241 created_at: None,
4242 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4243 "/project-b",
4244 )])),
4245 archived: false,
4246 remote_connection: None,
4247 },
4248 window,
4249 cx,
4250 );
4251 });
4252 cx_a.run_until_parked();
4253
4254 assert_eq!(
4255 multi_workspace_a
4256 .read_with(cx_a, |mw, _| mw.workspaces().count())
4257 .unwrap(),
4258 1,
4259 "should not add the other window's workspace into the current window"
4260 );
4261 assert_eq!(
4262 multi_workspace_b
4263 .read_with(cx_a, |mw, _| mw.workspaces().count())
4264 .unwrap(),
4265 1,
4266 "should reuse the existing workspace in the other window"
4267 );
4268 assert!(
4269 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4270 "should activate the window that already owns the matching workspace"
4271 );
4272 sidebar_a.read_with(cx_a, |sidebar, _| {
4273 assert!(
4274 !is_active_session(&sidebar, &session_id),
4275 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4276 );
4277 });
4278 sidebar_b.read_with(cx_b, |sidebar, _| {
4279 assert_active_thread(
4280 sidebar,
4281 &session_id,
4282 "target window's sidebar should eagerly focus the activated archived thread",
4283 );
4284 });
4285}
4286
4287#[gpui::test]
4288async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
4289 cx: &mut TestAppContext,
4290) {
4291 init_test(cx);
4292 let fs = FakeFs::new(cx.executor());
4293 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4294 .await;
4295 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4296
4297 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4298 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4299
4300 let multi_workspace_b =
4301 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4302 let multi_workspace_a =
4303 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4304
4305 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4306 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4307
4308 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4309 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4310
4311 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4312 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4313
4314 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
4315
4316 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4317 sidebar.open_thread_from_archive(
4318 ThreadMetadata {
4319 thread_id: ThreadId::new(),
4320 session_id: Some(session_id.clone()),
4321 agent_id: agent::ZED_AGENT_ID.clone(),
4322 title: Some("Current Window Thread".into()),
4323 updated_at: Utc::now(),
4324 created_at: None,
4325 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4326 "/project-a",
4327 )])),
4328 archived: false,
4329 remote_connection: None,
4330 },
4331 window,
4332 cx,
4333 );
4334 });
4335 cx_a.run_until_parked();
4336
4337 assert!(
4338 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
4339 "should keep activation in the current window when it already has a matching workspace"
4340 );
4341 sidebar_a.read_with(cx_a, |sidebar, _| {
4342 assert_active_thread(
4343 sidebar,
4344 &session_id,
4345 "current window's sidebar should eagerly focus the activated archived thread",
4346 );
4347 });
4348 assert_eq!(
4349 multi_workspace_a
4350 .read_with(cx_a, |mw, _| mw.workspaces().count())
4351 .unwrap(),
4352 1,
4353 "current window should continue reusing its existing workspace"
4354 );
4355 assert_eq!(
4356 multi_workspace_b
4357 .read_with(cx_a, |mw, _| mw.workspaces().count())
4358 .unwrap(),
4359 1,
4360 "other windows should not be activated just because they also match the saved paths"
4361 );
4362}
4363
4364#[gpui::test]
4365async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
4366 // Regression test: archive_thread previously always loaded the next thread
4367 // through group_workspace (the main workspace's ProjectHeader), even when
4368 // the next thread belonged to an absorbed linked-worktree workspace. That
4369 // caused the worktree thread to be loaded in the main panel, which bound it
4370 // to the main project and corrupted its stored folder_paths.
4371 //
4372 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
4373 // falling back to group_workspace only for Closed workspaces.
4374 agent_ui::test_support::init_test(cx);
4375 cx.update(|cx| {
4376 ThreadStore::init_global(cx);
4377 ThreadMetadataStore::init_global(cx);
4378 language_model::LanguageModelRegistry::test(cx);
4379 prompt_store::init(cx);
4380 });
4381
4382 let fs = FakeFs::new(cx.executor());
4383
4384 fs.insert_tree(
4385 "/project",
4386 serde_json::json!({
4387 ".git": {},
4388 "src": {},
4389 }),
4390 )
4391 .await;
4392
4393 fs.add_linked_worktree_for_repo(
4394 Path::new("/project/.git"),
4395 false,
4396 git::repository::Worktree {
4397 path: std::path::PathBuf::from("/wt-feature-a"),
4398 ref_name: Some("refs/heads/feature-a".into()),
4399 sha: "aaa".into(),
4400 is_main: false,
4401 is_bare: false,
4402 },
4403 )
4404 .await;
4405
4406 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4407
4408 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4409 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4410
4411 main_project
4412 .update(cx, |p, cx| p.git_scans_complete(cx))
4413 .await;
4414 worktree_project
4415 .update(cx, |p, cx| p.git_scans_complete(cx))
4416 .await;
4417
4418 let (multi_workspace, cx) =
4419 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4420
4421 let sidebar = setup_sidebar(&multi_workspace, cx);
4422
4423 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4424 mw.test_add_workspace(worktree_project.clone(), window, cx)
4425 });
4426
4427 // Activate main workspace so the sidebar tracks the main panel.
4428 multi_workspace.update_in(cx, |mw, window, cx| {
4429 let workspace = mw.workspaces().next().unwrap().clone();
4430 mw.activate(workspace, window, cx);
4431 });
4432
4433 let main_workspace =
4434 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4435 let main_panel = add_agent_panel(&main_workspace, cx);
4436 let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
4437
4438 // Open Thread 2 in the main panel and keep it running.
4439 let connection = StubAgentConnection::new();
4440 open_thread_with_connection(&main_panel, connection.clone(), cx);
4441 send_message(&main_panel, cx);
4442
4443 let thread2_session_id = active_session_id(&main_panel, cx);
4444
4445 cx.update(|_, cx| {
4446 connection.send_update(
4447 thread2_session_id.clone(),
4448 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4449 cx,
4450 );
4451 });
4452
4453 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
4454 save_thread_metadata(
4455 thread2_session_id.clone(),
4456 Some("Thread 2".into()),
4457 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4458 None,
4459 &main_project,
4460 cx,
4461 );
4462
4463 // Save thread 1's metadata with the worktree path and an older timestamp so
4464 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
4465 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
4466 save_thread_metadata(
4467 thread1_session_id,
4468 Some("Thread 1".into()),
4469 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4470 None,
4471 &worktree_project,
4472 cx,
4473 );
4474
4475 cx.run_until_parked();
4476
4477 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
4478 let entries_before = visible_entries_as_strings(&sidebar, cx);
4479 assert!(
4480 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
4481 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
4482 entries_before
4483 );
4484
4485 // The sidebar should track T2 as the focused thread (derived from the
4486 // main panel's active view).
4487 sidebar.read_with(cx, |s, _| {
4488 assert_active_thread(
4489 s,
4490 &thread2_session_id,
4491 "focused thread should be Thread 2 before archiving",
4492 );
4493 });
4494
4495 // Archive thread 2.
4496 sidebar.update_in(cx, |sidebar, window, cx| {
4497 sidebar.archive_thread(&thread2_session_id, window, cx);
4498 });
4499
4500 cx.run_until_parked();
4501
4502 // The main panel's active thread must still be thread 2.
4503 let main_active = main_panel.read_with(cx, |panel, cx| {
4504 panel
4505 .active_agent_thread(cx)
4506 .map(|t| t.read(cx).session_id().clone())
4507 });
4508 assert_eq!(
4509 main_active,
4510 Some(thread2_session_id.clone()),
4511 "main panel should not have been taken over by loading the linked-worktree thread T1; \
4512 before the fix, archive_thread used group_workspace instead of next.workspace, \
4513 causing T1 to be loaded in the wrong panel"
4514 );
4515
4516 // Thread 1 should still appear in the sidebar with its worktree chip
4517 // (Thread 2 was archived so it is gone from the list).
4518 let entries_after = visible_entries_as_strings(&sidebar, cx);
4519 assert!(
4520 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
4521 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
4522 entries_after
4523 );
4524}
4525
4526#[gpui::test]
4527async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
4528 // When the last non-archived thread for a linked worktree is archived,
4529 // the linked worktree workspace should be removed from the multi-workspace.
4530 // The main worktree workspace should remain (it's always reachable via
4531 // the project header).
4532 init_test(cx);
4533 let fs = FakeFs::new(cx.executor());
4534
4535 fs.insert_tree(
4536 "/project",
4537 serde_json::json!({
4538 ".git": {
4539 "worktrees": {
4540 "feature-a": {
4541 "commondir": "../../",
4542 "HEAD": "ref: refs/heads/feature-a",
4543 },
4544 },
4545 },
4546 "src": {},
4547 }),
4548 )
4549 .await;
4550
4551 fs.insert_tree(
4552 "/worktrees/project/feature-a/project",
4553 serde_json::json!({
4554 ".git": "gitdir: /project/.git/worktrees/feature-a",
4555 "src": {},
4556 }),
4557 )
4558 .await;
4559
4560 fs.add_linked_worktree_for_repo(
4561 Path::new("/project/.git"),
4562 false,
4563 git::repository::Worktree {
4564 path: PathBuf::from("/worktrees/project/feature-a/project"),
4565 ref_name: Some("refs/heads/feature-a".into()),
4566 sha: "abc".into(),
4567 is_main: false,
4568 is_bare: false,
4569 },
4570 )
4571 .await;
4572
4573 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4574
4575 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4576 let worktree_project = project::Project::test(
4577 fs.clone(),
4578 ["/worktrees/project/feature-a/project".as_ref()],
4579 cx,
4580 )
4581 .await;
4582
4583 main_project
4584 .update(cx, |p, cx| p.git_scans_complete(cx))
4585 .await;
4586 worktree_project
4587 .update(cx, |p, cx| p.git_scans_complete(cx))
4588 .await;
4589
4590 let (multi_workspace, cx) =
4591 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4592 let sidebar = setup_sidebar(&multi_workspace, cx);
4593
4594 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4595 mw.test_add_workspace(worktree_project.clone(), window, cx)
4596 });
4597
4598 // Save a thread for the main project.
4599 save_thread_metadata(
4600 acp::SessionId::new(Arc::from("main-thread")),
4601 Some("Main Thread".into()),
4602 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4603 None,
4604 &main_project,
4605 cx,
4606 );
4607
4608 // Save a thread for the linked worktree.
4609 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
4610 save_thread_metadata(
4611 wt_thread_id.clone(),
4612 Some("Worktree Thread".into()),
4613 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4614 None,
4615 &worktree_project,
4616 cx,
4617 );
4618 cx.run_until_parked();
4619
4620 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4621 cx.run_until_parked();
4622
4623 // Should have 2 workspaces.
4624 assert_eq!(
4625 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4626 2,
4627 "should start with 2 workspaces (main + linked worktree)"
4628 );
4629
4630 // Archive the worktree thread (the only thread for /wt-feature-a).
4631 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
4632 sidebar.archive_thread(&wt_thread_id, window, cx);
4633 });
4634
4635 // archive_thread spawns a multi-layered chain of tasks (workspace
4636 // removal → git persist → disk removal), each of which may spawn
4637 // further background work. Each run_until_parked() call drives one
4638 // layer of pending work.
4639
4640 cx.run_until_parked();
4641
4642 // The linked worktree workspace should have been removed.
4643 assert_eq!(
4644 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4645 1,
4646 "linked worktree workspace should be removed after archiving its last thread"
4647 );
4648
4649 // The linked worktree checkout directory should also be removed from disk.
4650 assert!(
4651 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
4652 .await,
4653 "linked worktree directory should be removed from disk after archiving its last thread"
4654 );
4655
4656 // The main thread should still be visible.
4657 let entries = visible_entries_as_strings(&sidebar, cx);
4658 assert!(
4659 entries.iter().any(|e| e.contains("Main Thread")),
4660 "main thread should still be visible: {entries:?}"
4661 );
4662 assert!(
4663 !entries.iter().any(|e| e.contains("Worktree Thread")),
4664 "archived worktree thread should not be visible: {entries:?}"
4665 );
4666
4667 // The archived thread must retain its folder_paths so it can be
4668 // restored to the correct workspace later.
4669 let wt_thread_id = cx.update(|_window, cx| {
4670 ThreadMetadataStore::global(cx)
4671 .read(cx)
4672 .entry_by_session(&wt_thread_id)
4673 .unwrap()
4674 .thread_id
4675 });
4676 let archived_paths = cx.update(|_window, cx| {
4677 ThreadMetadataStore::global(cx)
4678 .read(cx)
4679 .entry(wt_thread_id)
4680 .unwrap()
4681 .folder_paths()
4682 .clone()
4683 });
4684 assert_eq!(
4685 archived_paths.paths(),
4686 &[PathBuf::from("/worktrees/project/feature-a/project")],
4687 "archived thread must retain its folder_paths for restore"
4688 );
4689}
4690
4691#[gpui::test]
4692async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
4693 // restore_worktree_via_git should succeed when the branch has moved
4694 // to a different SHA since archival. The worktree stays in detached
4695 // HEAD and the moved branch is left untouched.
4696 init_test(cx);
4697 let fs = FakeFs::new(cx.executor());
4698
4699 fs.insert_tree(
4700 "/project",
4701 serde_json::json!({
4702 ".git": {
4703 "worktrees": {
4704 "feature-a": {
4705 "commondir": "../../",
4706 "HEAD": "ref: refs/heads/feature-a",
4707 },
4708 },
4709 },
4710 "src": {},
4711 }),
4712 )
4713 .await;
4714 fs.insert_tree(
4715 "/wt-feature-a",
4716 serde_json::json!({
4717 ".git": "gitdir: /project/.git/worktrees/feature-a",
4718 "src": {},
4719 }),
4720 )
4721 .await;
4722 fs.add_linked_worktree_for_repo(
4723 Path::new("/project/.git"),
4724 false,
4725 git::repository::Worktree {
4726 path: PathBuf::from("/wt-feature-a"),
4727 ref_name: Some("refs/heads/feature-a".into()),
4728 sha: "original-sha".into(),
4729 is_main: false,
4730 is_bare: false,
4731 },
4732 )
4733 .await;
4734 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4735
4736 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4737 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4738 main_project
4739 .update(cx, |p, cx| p.git_scans_complete(cx))
4740 .await;
4741 worktree_project
4742 .update(cx, |p, cx| p.git_scans_complete(cx))
4743 .await;
4744
4745 let (multi_workspace, _cx) =
4746 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4747 multi_workspace.update_in(_cx, |mw, window, cx| {
4748 mw.test_add_workspace(worktree_project.clone(), window, cx)
4749 });
4750
4751 let wt_repo = worktree_project.read_with(cx, |project, cx| {
4752 project.repositories(cx).values().next().unwrap().clone()
4753 });
4754 let (staged_hash, unstaged_hash) = cx
4755 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
4756 .await
4757 .unwrap()
4758 .unwrap();
4759
4760 // Move the branch to a different SHA.
4761 fs.with_git_state(Path::new("/project/.git"), false, |state| {
4762 state
4763 .refs
4764 .insert("refs/heads/feature-a".into(), "moved-sha".into());
4765 })
4766 .unwrap();
4767
4768 let result = cx
4769 .spawn(|mut cx| async move {
4770 agent_ui::thread_worktree_archive::restore_worktree_via_git(
4771 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
4772 id: 1,
4773 worktree_path: PathBuf::from("/wt-feature-a"),
4774 main_repo_path: PathBuf::from("/project"),
4775 branch_name: Some("feature-a".to_string()),
4776 staged_commit_hash: staged_hash,
4777 unstaged_commit_hash: unstaged_hash,
4778 original_commit_hash: "original-sha".to_string(),
4779 },
4780 &mut cx,
4781 )
4782 .await
4783 })
4784 .await;
4785
4786 assert!(
4787 result.is_ok(),
4788 "restore should succeed even when branch has moved: {:?}",
4789 result.err()
4790 );
4791
4792 // The moved branch ref should be completely untouched.
4793 let branch_sha = fs
4794 .with_git_state(Path::new("/project/.git"), false, |state| {
4795 state.refs.get("refs/heads/feature-a").cloned()
4796 })
4797 .unwrap();
4798 assert_eq!(
4799 branch_sha.as_deref(),
4800 Some("moved-sha"),
4801 "the moved branch ref should not be modified by the restore"
4802 );
4803}
4804
4805#[gpui::test]
4806async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
4807 // restore_worktree_via_git should succeed when the branch still
4808 // points at the same SHA as at archive time.
4809 init_test(cx);
4810 let fs = FakeFs::new(cx.executor());
4811
4812 fs.insert_tree(
4813 "/project",
4814 serde_json::json!({
4815 ".git": {
4816 "worktrees": {
4817 "feature-b": {
4818 "commondir": "../../",
4819 "HEAD": "ref: refs/heads/feature-b",
4820 },
4821 },
4822 },
4823 "src": {},
4824 }),
4825 )
4826 .await;
4827 fs.insert_tree(
4828 "/wt-feature-b",
4829 serde_json::json!({
4830 ".git": "gitdir: /project/.git/worktrees/feature-b",
4831 "src": {},
4832 }),
4833 )
4834 .await;
4835 fs.add_linked_worktree_for_repo(
4836 Path::new("/project/.git"),
4837 false,
4838 git::repository::Worktree {
4839 path: PathBuf::from("/wt-feature-b"),
4840 ref_name: Some("refs/heads/feature-b".into()),
4841 sha: "original-sha".into(),
4842 is_main: false,
4843 is_bare: false,
4844 },
4845 )
4846 .await;
4847 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4848
4849 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4850 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
4851 main_project
4852 .update(cx, |p, cx| p.git_scans_complete(cx))
4853 .await;
4854 worktree_project
4855 .update(cx, |p, cx| p.git_scans_complete(cx))
4856 .await;
4857
4858 let (multi_workspace, _cx) =
4859 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4860 multi_workspace.update_in(_cx, |mw, window, cx| {
4861 mw.test_add_workspace(worktree_project.clone(), window, cx)
4862 });
4863
4864 let wt_repo = worktree_project.read_with(cx, |project, cx| {
4865 project.repositories(cx).values().next().unwrap().clone()
4866 });
4867 let (staged_hash, unstaged_hash) = cx
4868 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
4869 .await
4870 .unwrap()
4871 .unwrap();
4872
4873 // refs/heads/feature-b already points at "original-sha" (set by
4874 // add_linked_worktree_for_repo), matching original_commit_hash.
4875
4876 let result = cx
4877 .spawn(|mut cx| async move {
4878 agent_ui::thread_worktree_archive::restore_worktree_via_git(
4879 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
4880 id: 1,
4881 worktree_path: PathBuf::from("/wt-feature-b"),
4882 main_repo_path: PathBuf::from("/project"),
4883 branch_name: Some("feature-b".to_string()),
4884 staged_commit_hash: staged_hash,
4885 unstaged_commit_hash: unstaged_hash,
4886 original_commit_hash: "original-sha".to_string(),
4887 },
4888 &mut cx,
4889 )
4890 .await
4891 })
4892 .await;
4893
4894 assert!(
4895 result.is_ok(),
4896 "restore should succeed when branch has not moved: {:?}",
4897 result.err()
4898 );
4899}
4900
4901#[gpui::test]
4902async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
4903 // restore_worktree_via_git should succeed when the branch no longer
4904 // exists (e.g. it was deleted while the thread was archived). The
4905 // code should attempt to recreate the branch.
4906 init_test(cx);
4907 let fs = FakeFs::new(cx.executor());
4908
4909 fs.insert_tree(
4910 "/project",
4911 serde_json::json!({
4912 ".git": {
4913 "worktrees": {
4914 "feature-d": {
4915 "commondir": "../../",
4916 "HEAD": "ref: refs/heads/feature-d",
4917 },
4918 },
4919 },
4920 "src": {},
4921 }),
4922 )
4923 .await;
4924 fs.insert_tree(
4925 "/wt-feature-d",
4926 serde_json::json!({
4927 ".git": "gitdir: /project/.git/worktrees/feature-d",
4928 "src": {},
4929 }),
4930 )
4931 .await;
4932 fs.add_linked_worktree_for_repo(
4933 Path::new("/project/.git"),
4934 false,
4935 git::repository::Worktree {
4936 path: PathBuf::from("/wt-feature-d"),
4937 ref_name: Some("refs/heads/feature-d".into()),
4938 sha: "original-sha".into(),
4939 is_main: false,
4940 is_bare: false,
4941 },
4942 )
4943 .await;
4944 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4945
4946 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4947 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
4948 main_project
4949 .update(cx, |p, cx| p.git_scans_complete(cx))
4950 .await;
4951 worktree_project
4952 .update(cx, |p, cx| p.git_scans_complete(cx))
4953 .await;
4954
4955 let (multi_workspace, _cx) =
4956 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4957 multi_workspace.update_in(_cx, |mw, window, cx| {
4958 mw.test_add_workspace(worktree_project.clone(), window, cx)
4959 });
4960
4961 let wt_repo = worktree_project.read_with(cx, |project, cx| {
4962 project.repositories(cx).values().next().unwrap().clone()
4963 });
4964 let (staged_hash, unstaged_hash) = cx
4965 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
4966 .await
4967 .unwrap()
4968 .unwrap();
4969
4970 // Remove the branch ref so change_branch will fail.
4971 fs.with_git_state(Path::new("/project/.git"), false, |state| {
4972 state.refs.remove("refs/heads/feature-d");
4973 })
4974 .unwrap();
4975
4976 let result = cx
4977 .spawn(|mut cx| async move {
4978 agent_ui::thread_worktree_archive::restore_worktree_via_git(
4979 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
4980 id: 1,
4981 worktree_path: PathBuf::from("/wt-feature-d"),
4982 main_repo_path: PathBuf::from("/project"),
4983 branch_name: Some("feature-d".to_string()),
4984 staged_commit_hash: staged_hash,
4985 unstaged_commit_hash: unstaged_hash,
4986 original_commit_hash: "original-sha".to_string(),
4987 },
4988 &mut cx,
4989 )
4990 .await
4991 })
4992 .await;
4993
4994 assert!(
4995 result.is_ok(),
4996 "restore should succeed when branch does not exist: {:?}",
4997 result.err()
4998 );
4999}
5000
5001#[gpui::test]
5002async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
5003 // Activating an archived linked worktree thread whose directory has
5004 // been deleted should reuse the existing main repo workspace, not
5005 // create a new one. The provisional ProjectGroupKey must be derived
5006 // from main_worktree_paths so that find_or_create_local_workspace
5007 // matches the main repo workspace when the worktree path is absent.
5008 init_test(cx);
5009 let fs = FakeFs::new(cx.executor());
5010
5011 fs.insert_tree(
5012 "/project",
5013 serde_json::json!({
5014 ".git": {
5015 "worktrees": {
5016 "feature-c": {
5017 "commondir": "../../",
5018 "HEAD": "ref: refs/heads/feature-c",
5019 },
5020 },
5021 },
5022 "src": {},
5023 }),
5024 )
5025 .await;
5026
5027 fs.insert_tree(
5028 "/wt-feature-c",
5029 serde_json::json!({
5030 ".git": "gitdir: /project/.git/worktrees/feature-c",
5031 "src": {},
5032 }),
5033 )
5034 .await;
5035
5036 fs.add_linked_worktree_for_repo(
5037 Path::new("/project/.git"),
5038 false,
5039 git::repository::Worktree {
5040 path: PathBuf::from("/wt-feature-c"),
5041 ref_name: Some("refs/heads/feature-c".into()),
5042 sha: "original-sha".into(),
5043 is_main: false,
5044 is_bare: false,
5045 },
5046 )
5047 .await;
5048
5049 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5050
5051 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5052 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
5053
5054 main_project
5055 .update(cx, |p, cx| p.git_scans_complete(cx))
5056 .await;
5057 worktree_project
5058 .update(cx, |p, cx| p.git_scans_complete(cx))
5059 .await;
5060
5061 let (multi_workspace, cx) =
5062 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5063 let sidebar = setup_sidebar(&multi_workspace, cx);
5064
5065 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5066 mw.test_add_workspace(worktree_project.clone(), window, cx)
5067 });
5068
5069 // Save thread metadata for the linked worktree.
5070 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
5071 save_thread_metadata(
5072 wt_session_id.clone(),
5073 Some("Worktree Thread C".into()),
5074 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5075 None,
5076 &worktree_project,
5077 cx,
5078 );
5079 cx.run_until_parked();
5080
5081 let thread_id = cx.update(|_window, cx| {
5082 ThreadMetadataStore::global(cx)
5083 .read(cx)
5084 .entry_by_session(&wt_session_id)
5085 .unwrap()
5086 .thread_id
5087 });
5088
5089 // Archive the thread without creating ArchivedGitWorktree records.
5090 let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
5091 cx.update(|_window, cx| {
5092 store.update(cx, |store, cx| store.archive(thread_id, None, cx));
5093 });
5094 cx.run_until_parked();
5095
5096 // Remove the worktree workspace and delete the worktree from disk.
5097 let main_workspace =
5098 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5099 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
5100 mw.remove(
5101 vec![worktree_workspace],
5102 move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
5103 window,
5104 cx,
5105 )
5106 });
5107 remove_task.await.ok();
5108 cx.run_until_parked();
5109 cx.run_until_parked();
5110 fs.remove_dir(
5111 Path::new("/wt-feature-c"),
5112 fs::RemoveOptions {
5113 recursive: true,
5114 ignore_if_not_exists: true,
5115 },
5116 )
5117 .await
5118 .unwrap();
5119
5120 let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5121 assert_eq!(
5122 workspace_count_before, 1,
5123 "should have only the main workspace"
5124 );
5125
5126 // Activate the archived thread. The worktree path is missing from
5127 // disk, so find_or_create_local_workspace falls back to the
5128 // provisional ProjectGroupKey to find a matching workspace.
5129 let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
5130 sidebar.update_in(cx, |sidebar, window, cx| {
5131 sidebar.open_thread_from_archive(metadata, window, cx);
5132 });
5133 cx.run_until_parked();
5134
5135 // The provisional key should use [/project] (the main repo),
5136 // which matches the existing main workspace. If it incorrectly
5137 // used [/wt-feature-c] (the linked worktree path), no workspace
5138 // would match and a spurious new one would be created.
5139 let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5140 assert_eq!(
5141 workspace_count_after, 1,
5142 "restoring a linked worktree thread should reuse the main repo workspace, \
5143 not create a new one (workspace count went from {workspace_count_before} to \
5144 {workspace_count_after})"
5145 );
5146}
5147
5148#[gpui::test]
5149async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
5150 cx: &mut TestAppContext,
5151) {
5152 // A remote thread at the same path as a local linked worktree thread
5153 // should not prevent the local workspace from being removed when the
5154 // local thread is archived (the last local thread for that worktree).
5155 init_test(cx);
5156 let fs = FakeFs::new(cx.executor());
5157
5158 fs.insert_tree(
5159 "/project",
5160 serde_json::json!({
5161 ".git": {
5162 "worktrees": {
5163 "feature-a": {
5164 "commondir": "../../",
5165 "HEAD": "ref: refs/heads/feature-a",
5166 },
5167 },
5168 },
5169 "src": {},
5170 }),
5171 )
5172 .await;
5173
5174 fs.insert_tree(
5175 "/wt-feature-a",
5176 serde_json::json!({
5177 ".git": "gitdir: /project/.git/worktrees/feature-a",
5178 "src": {},
5179 }),
5180 )
5181 .await;
5182
5183 fs.add_linked_worktree_for_repo(
5184 Path::new("/project/.git"),
5185 false,
5186 git::repository::Worktree {
5187 path: PathBuf::from("/wt-feature-a"),
5188 ref_name: Some("refs/heads/feature-a".into()),
5189 sha: "abc".into(),
5190 is_main: false,
5191 is_bare: false,
5192 },
5193 )
5194 .await;
5195
5196 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5197
5198 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5199 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5200
5201 main_project
5202 .update(cx, |p, cx| p.git_scans_complete(cx))
5203 .await;
5204 worktree_project
5205 .update(cx, |p, cx| p.git_scans_complete(cx))
5206 .await;
5207
5208 let (multi_workspace, cx) =
5209 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5210 let sidebar = setup_sidebar(&multi_workspace, cx);
5211
5212 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5213 mw.test_add_workspace(worktree_project.clone(), window, cx)
5214 });
5215
5216 // Save a thread for the main project.
5217 save_thread_metadata(
5218 acp::SessionId::new(Arc::from("main-thread")),
5219 Some("Main Thread".into()),
5220 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5221 None,
5222 &main_project,
5223 cx,
5224 );
5225
5226 // Save a local thread for the linked worktree.
5227 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5228 save_thread_metadata(
5229 wt_thread_id.clone(),
5230 Some("Local Worktree Thread".into()),
5231 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5232 None,
5233 &worktree_project,
5234 cx,
5235 );
5236
5237 // Save a remote thread at the same /wt-feature-a path but on a
5238 // different host. This should NOT count as a remaining thread for
5239 // the local linked worktree workspace.
5240 let remote_host =
5241 remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
5242 cx.update(|_window, cx| {
5243 let metadata = ThreadMetadata {
5244 thread_id: ThreadId::new(),
5245 session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
5246 agent_id: agent::ZED_AGENT_ID.clone(),
5247 title: Some("Remote Worktree Thread".into()),
5248 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5249 created_at: None,
5250 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
5251 "/wt-feature-a",
5252 )])),
5253 archived: false,
5254 remote_connection: Some(remote_host),
5255 };
5256 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5257 store.save(metadata, cx);
5258 });
5259 });
5260 cx.run_until_parked();
5261
5262 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5263 cx.run_until_parked();
5264
5265 assert_eq!(
5266 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5267 2,
5268 "should start with 2 workspaces (main + linked worktree)"
5269 );
5270
5271 // The remote thread should NOT appear in the sidebar (it belongs
5272 // to a different host and no matching remote project group exists).
5273 let entries_before = visible_entries_as_strings(&sidebar, cx);
5274 assert!(
5275 !entries_before
5276 .iter()
5277 .any(|e| e.contains("Remote Worktree Thread")),
5278 "remote thread should not appear in local sidebar: {entries_before:?}"
5279 );
5280
5281 // Archive the local worktree thread.
5282 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5283 sidebar.archive_thread(&wt_thread_id, window, cx);
5284 });
5285
5286 cx.run_until_parked();
5287
5288 // The linked worktree workspace should be removed because the
5289 // only *local* thread for it was archived. The remote thread at
5290 // the same path should not have prevented removal.
5291 assert_eq!(
5292 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5293 1,
5294 "linked worktree workspace should be removed; the remote thread at the same path \
5295 should not count as a remaining local thread"
5296 );
5297
5298 let entries = visible_entries_as_strings(&sidebar, cx);
5299 assert!(
5300 entries.iter().any(|e| e.contains("Main Thread")),
5301 "main thread should still be visible: {entries:?}"
5302 );
5303 assert!(
5304 !entries.iter().any(|e| e.contains("Local Worktree Thread")),
5305 "archived local worktree thread should not be visible: {entries:?}"
5306 );
5307 assert!(
5308 !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
5309 "remote thread should still not appear in local sidebar: {entries:?}"
5310 );
5311}
5312
5313#[gpui::test]
5314async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
5315 // When a multi-root workspace (e.g. [/other, /project]) shares a
5316 // repo with a single-root workspace (e.g. [/project]), linked
5317 // worktree threads from the shared repo should only appear under
5318 // the dedicated group [project], not under [other, project].
5319 agent_ui::test_support::init_test(cx);
5320 cx.update(|cx| {
5321 ThreadStore::init_global(cx);
5322 ThreadMetadataStore::init_global(cx);
5323 language_model::LanguageModelRegistry::test(cx);
5324 prompt_store::init(cx);
5325 });
5326 let fs = FakeFs::new(cx.executor());
5327
5328 // Two independent repos, each with their own git history.
5329 fs.insert_tree(
5330 "/project",
5331 serde_json::json!({
5332 ".git": {},
5333 "src": {},
5334 }),
5335 )
5336 .await;
5337 fs.insert_tree(
5338 "/other",
5339 serde_json::json!({
5340 ".git": {},
5341 "src": {},
5342 }),
5343 )
5344 .await;
5345
5346 // Register the linked worktree in the main repo.
5347 fs.add_linked_worktree_for_repo(
5348 Path::new("/project/.git"),
5349 false,
5350 git::repository::Worktree {
5351 path: std::path::PathBuf::from("/wt-feature-a"),
5352 ref_name: Some("refs/heads/feature-a".into()),
5353 sha: "aaa".into(),
5354 is_main: false,
5355 is_bare: false,
5356 },
5357 )
5358 .await;
5359
5360 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5361
5362 // Workspace 1: just /project.
5363 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5364 project_only
5365 .update(cx, |p, cx| p.git_scans_complete(cx))
5366 .await;
5367
5368 // Workspace 2: /other and /project together (multi-root).
5369 let multi_root =
5370 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
5371 multi_root
5372 .update(cx, |p, cx| p.git_scans_complete(cx))
5373 .await;
5374
5375 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5376 worktree_project
5377 .update(cx, |p, cx| p.git_scans_complete(cx))
5378 .await;
5379
5380 // Save a thread under the linked worktree path BEFORE setting up
5381 // the sidebar and panels, so that reconciliation sees the [project]
5382 // group as non-empty and doesn't create a spurious draft there.
5383 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
5384 save_thread_metadata(
5385 wt_session_id,
5386 Some("Worktree Thread".into()),
5387 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5388 None,
5389 &worktree_project,
5390 cx,
5391 );
5392
5393 let (multi_workspace, cx) =
5394 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
5395 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5396 let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5397 mw.test_add_workspace(multi_root.clone(), window, cx)
5398 });
5399 add_agent_panel(&multi_root_workspace, cx);
5400 cx.run_until_parked();
5401
5402 // The thread should appear only under [project] (the dedicated
5403 // group for the /project repo), not under [other, project].
5404 assert_eq!(
5405 visible_entries_as_strings(&sidebar, cx),
5406 vec![
5407 //
5408 "v [other, project]",
5409 "v [project]",
5410 " Worktree Thread {wt-feature-a}",
5411 ]
5412 );
5413}
5414
5415#[gpui::test]
5416async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
5417 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5418 let (multi_workspace, cx) =
5419 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5420 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5421
5422 let switcher_ids =
5423 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
5424 sidebar.read_with(cx, |sidebar, cx| {
5425 let switcher = sidebar
5426 .thread_switcher
5427 .as_ref()
5428 .expect("switcher should be open");
5429 switcher
5430 .read(cx)
5431 .entries()
5432 .iter()
5433 .map(|e| e.session_id.clone())
5434 .collect()
5435 })
5436 };
5437
5438 let switcher_selected_id =
5439 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
5440 sidebar.read_with(cx, |sidebar, cx| {
5441 let switcher = sidebar
5442 .thread_switcher
5443 .as_ref()
5444 .expect("switcher should be open");
5445 let s = switcher.read(cx);
5446 s.selected_entry()
5447 .expect("should have selection")
5448 .session_id
5449 .clone()
5450 })
5451 };
5452
5453 // ── Setup: create three threads with distinct created_at times ──────
5454 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
5455 // We send messages in each so they also get last_message_sent_or_queued timestamps.
5456 let connection_c = StubAgentConnection::new();
5457 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5458 acp::ContentChunk::new("Done C".into()),
5459 )]);
5460 open_thread_with_connection(&panel, connection_c, cx);
5461 send_message(&panel, cx);
5462 let session_id_c = active_session_id(&panel, cx);
5463 save_thread_metadata(
5464 session_id_c.clone(),
5465 Some("Thread C".into()),
5466 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5467 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
5468 &project,
5469 cx,
5470 );
5471
5472 let connection_b = StubAgentConnection::new();
5473 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5474 acp::ContentChunk::new("Done B".into()),
5475 )]);
5476 open_thread_with_connection(&panel, connection_b, cx);
5477 send_message(&panel, cx);
5478 let session_id_b = active_session_id(&panel, cx);
5479 save_thread_metadata(
5480 session_id_b.clone(),
5481 Some("Thread B".into()),
5482 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5483 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
5484 &project,
5485 cx,
5486 );
5487
5488 let connection_a = StubAgentConnection::new();
5489 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5490 acp::ContentChunk::new("Done A".into()),
5491 )]);
5492 open_thread_with_connection(&panel, connection_a, cx);
5493 send_message(&panel, cx);
5494 let session_id_a = active_session_id(&panel, cx);
5495 save_thread_metadata(
5496 session_id_a.clone(),
5497 Some("Thread A".into()),
5498 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
5499 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
5500 &project,
5501 cx,
5502 );
5503
5504 // All three threads are now live. Thread A was opened last, so it's
5505 // the one being viewed. Opening each thread called record_thread_access,
5506 // so all three have last_accessed_at set.
5507 // Access order is: A (most recent), B, C (oldest).
5508
5509 // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
5510 focus_sidebar(&sidebar, cx);
5511 sidebar.update_in(cx, |sidebar, window, cx| {
5512 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5513 });
5514 cx.run_until_parked();
5515
5516 // All three have last_accessed_at, so they sort by access time.
5517 // A was accessed most recently (it's the currently viewed thread),
5518 // then B, then C.
5519 assert_eq!(
5520 switcher_ids(&sidebar, cx),
5521 vec![
5522 session_id_a.clone(),
5523 session_id_b.clone(),
5524 session_id_c.clone()
5525 ],
5526 );
5527 // First ctrl-tab selects the second entry (B).
5528 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
5529
5530 // Dismiss the switcher without confirming.
5531 sidebar.update_in(cx, |sidebar, _window, cx| {
5532 sidebar.dismiss_thread_switcher(cx);
5533 });
5534 cx.run_until_parked();
5535
5536 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
5537 sidebar.update_in(cx, |sidebar, window, cx| {
5538 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5539 });
5540 cx.run_until_parked();
5541
5542 // Cycle twice to land on Thread C (index 2).
5543 sidebar.read_with(cx, |sidebar, cx| {
5544 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5545 assert_eq!(switcher.read(cx).selected_index(), 1);
5546 });
5547 sidebar.update_in(cx, |sidebar, _window, cx| {
5548 sidebar
5549 .thread_switcher
5550 .as_ref()
5551 .unwrap()
5552 .update(cx, |s, cx| s.cycle_selection(cx));
5553 });
5554 cx.run_until_parked();
5555 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
5556
5557 assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
5558
5559 // Confirm on Thread C.
5560 sidebar.update_in(cx, |sidebar, window, cx| {
5561 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5562 let focus = switcher.focus_handle(cx);
5563 focus.dispatch_action(&menu::Confirm, window, cx);
5564 });
5565 cx.run_until_parked();
5566
5567 // Switcher should be dismissed after confirm.
5568 sidebar.read_with(cx, |sidebar, _cx| {
5569 assert!(
5570 sidebar.thread_switcher.is_none(),
5571 "switcher should be dismissed"
5572 );
5573 });
5574
5575 sidebar.update(cx, |sidebar, _cx| {
5576 let last_accessed = sidebar
5577 .thread_last_accessed
5578 .keys()
5579 .cloned()
5580 .collect::<Vec<_>>();
5581 assert_eq!(last_accessed.len(), 1);
5582 assert!(last_accessed.contains(&session_id_c));
5583 assert!(
5584 is_active_session(&sidebar, &session_id_c),
5585 "active_entry should be Thread({session_id_c:?})"
5586 );
5587 });
5588
5589 sidebar.update_in(cx, |sidebar, window, cx| {
5590 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5591 });
5592 cx.run_until_parked();
5593
5594 assert_eq!(
5595 switcher_ids(&sidebar, cx),
5596 vec![
5597 session_id_c.clone(),
5598 session_id_a.clone(),
5599 session_id_b.clone()
5600 ],
5601 );
5602
5603 // Confirm on Thread A.
5604 sidebar.update_in(cx, |sidebar, window, cx| {
5605 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5606 let focus = switcher.focus_handle(cx);
5607 focus.dispatch_action(&menu::Confirm, window, cx);
5608 });
5609 cx.run_until_parked();
5610
5611 sidebar.update(cx, |sidebar, _cx| {
5612 let last_accessed = sidebar
5613 .thread_last_accessed
5614 .keys()
5615 .cloned()
5616 .collect::<Vec<_>>();
5617 assert_eq!(last_accessed.len(), 2);
5618 assert!(last_accessed.contains(&session_id_c));
5619 assert!(last_accessed.contains(&session_id_a));
5620 assert!(
5621 is_active_session(&sidebar, &session_id_a),
5622 "active_entry should be Thread({session_id_a:?})"
5623 );
5624 });
5625
5626 sidebar.update_in(cx, |sidebar, window, cx| {
5627 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5628 });
5629 cx.run_until_parked();
5630
5631 assert_eq!(
5632 switcher_ids(&sidebar, cx),
5633 vec![
5634 session_id_a.clone(),
5635 session_id_c.clone(),
5636 session_id_b.clone(),
5637 ],
5638 );
5639
5640 sidebar.update_in(cx, |sidebar, _window, cx| {
5641 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5642 switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
5643 });
5644 cx.run_until_parked();
5645
5646 // Confirm on Thread B.
5647 sidebar.update_in(cx, |sidebar, window, cx| {
5648 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5649 let focus = switcher.focus_handle(cx);
5650 focus.dispatch_action(&menu::Confirm, window, cx);
5651 });
5652 cx.run_until_parked();
5653
5654 sidebar.update(cx, |sidebar, _cx| {
5655 let last_accessed = sidebar
5656 .thread_last_accessed
5657 .keys()
5658 .cloned()
5659 .collect::<Vec<_>>();
5660 assert_eq!(last_accessed.len(), 3);
5661 assert!(last_accessed.contains(&session_id_c));
5662 assert!(last_accessed.contains(&session_id_a));
5663 assert!(last_accessed.contains(&session_id_b));
5664 assert!(
5665 is_active_session(&sidebar, &session_id_b),
5666 "active_entry should be Thread({session_id_b:?})"
5667 );
5668 });
5669
5670 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
5671 // This thread was never opened in a panel — it only exists in metadata.
5672 save_thread_metadata(
5673 acp::SessionId::new(Arc::from("thread-historical")),
5674 Some("Historical Thread".into()),
5675 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
5676 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
5677 &project,
5678 cx,
5679 );
5680
5681 sidebar.update_in(cx, |sidebar, window, cx| {
5682 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5683 });
5684 cx.run_until_parked();
5685
5686 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
5687 // so it falls to tier 3 (sorted by created_at). It should appear after all
5688 // accessed threads, even though its created_at (June 2024) is much later
5689 // than the others.
5690 //
5691 // But the live threads (A, B, C) each had send_message called which sets
5692 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
5693 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
5694 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
5695
5696 let ids = switcher_ids(&sidebar, cx);
5697 assert_eq!(
5698 ids,
5699 vec![
5700 session_id_b.clone(),
5701 session_id_a.clone(),
5702 session_id_c.clone(),
5703 session_id_hist.clone()
5704 ],
5705 );
5706
5707 sidebar.update_in(cx, |sidebar, _window, cx| {
5708 sidebar.dismiss_thread_switcher(cx);
5709 });
5710 cx.run_until_parked();
5711
5712 // ── 4. Add another historical thread with older created_at ─────────
5713 save_thread_metadata(
5714 acp::SessionId::new(Arc::from("thread-old-historical")),
5715 Some("Old Historical Thread".into()),
5716 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
5717 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
5718 &project,
5719 cx,
5720 );
5721
5722 sidebar.update_in(cx, |sidebar, window, cx| {
5723 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5724 });
5725 cx.run_until_parked();
5726
5727 // Both historical threads have no access or message times. They should
5728 // appear after accessed threads, sorted by created_at (newest first).
5729 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
5730 let ids = switcher_ids(&sidebar, cx);
5731 assert_eq!(
5732 ids,
5733 vec![
5734 session_id_b,
5735 session_id_a,
5736 session_id_c,
5737 session_id_hist,
5738 session_id_old_hist,
5739 ],
5740 );
5741
5742 sidebar.update_in(cx, |sidebar, _window, cx| {
5743 sidebar.dismiss_thread_switcher(cx);
5744 });
5745 cx.run_until_parked();
5746}
5747
5748#[gpui::test]
5749async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
5750 let project = init_test_project("/my-project", cx).await;
5751 let (multi_workspace, cx) =
5752 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5753 let sidebar = setup_sidebar(&multi_workspace, cx);
5754
5755 save_thread_metadata(
5756 acp::SessionId::new(Arc::from("thread-to-archive")),
5757 Some("Thread To Archive".into()),
5758 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5759 None,
5760 &project,
5761 cx,
5762 );
5763 cx.run_until_parked();
5764
5765 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5766 cx.run_until_parked();
5767
5768 let entries = visible_entries_as_strings(&sidebar, cx);
5769 assert!(
5770 entries.iter().any(|e| e.contains("Thread To Archive")),
5771 "expected thread to be visible before archiving, got: {entries:?}"
5772 );
5773
5774 sidebar.update_in(cx, |sidebar, window, cx| {
5775 sidebar.archive_thread(
5776 &acp::SessionId::new(Arc::from("thread-to-archive")),
5777 window,
5778 cx,
5779 );
5780 });
5781 cx.run_until_parked();
5782
5783 let entries = visible_entries_as_strings(&sidebar, cx);
5784 assert!(
5785 !entries.iter().any(|e| e.contains("Thread To Archive")),
5786 "expected thread to be hidden after archiving, got: {entries:?}"
5787 );
5788
5789 cx.update(|_, cx| {
5790 let store = ThreadMetadataStore::global(cx);
5791 let archived: Vec<_> = store.read(cx).archived_entries().collect();
5792 assert_eq!(archived.len(), 1);
5793 assert_eq!(
5794 archived[0].session_id.as_ref().unwrap().0.as_ref(),
5795 "thread-to-archive"
5796 );
5797 assert!(archived[0].archived);
5798 });
5799}
5800
5801#[gpui::test]
5802async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
5803 // Tests two archive scenarios:
5804 // 1. Archiving a thread in a non-active workspace leaves active_entry
5805 // as the current draft.
5806 // 2. Archiving the thread the user is looking at falls back to a draft
5807 // on the same workspace.
5808 agent_ui::test_support::init_test(cx);
5809 cx.update(|cx| {
5810 ThreadStore::init_global(cx);
5811 ThreadMetadataStore::init_global(cx);
5812 language_model::LanguageModelRegistry::test(cx);
5813 prompt_store::init(cx);
5814 });
5815
5816 let fs = FakeFs::new(cx.executor());
5817 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
5818 .await;
5819 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
5820 .await;
5821 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5822
5823 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
5824 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
5825
5826 let (multi_workspace, cx) =
5827 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5828 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5829
5830 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5831 mw.test_add_workspace(project_b.clone(), window, cx)
5832 });
5833 let panel_b = add_agent_panel(&workspace_b, cx);
5834 cx.run_until_parked();
5835
5836 // Explicitly create a draft on workspace_b so the sidebar tracks one.
5837 sidebar.update_in(cx, |sidebar, window, cx| {
5838 sidebar.create_new_thread(&workspace_b, window, cx);
5839 });
5840 cx.run_until_parked();
5841
5842 // --- Scenario 1: archive a thread in the non-active workspace ---
5843
5844 // Create a thread in project-a (non-active — project-b is active).
5845 let connection = acp_thread::StubAgentConnection::new();
5846 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5847 acp::ContentChunk::new("Done".into()),
5848 )]);
5849 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
5850 agent_ui::test_support::send_message(&panel_a, cx);
5851 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
5852 cx.run_until_parked();
5853
5854 sidebar.update_in(cx, |sidebar, window, cx| {
5855 sidebar.archive_thread(&thread_a, window, cx);
5856 });
5857 cx.run_until_parked();
5858
5859 // active_entry should still be a draft on workspace_b (the active one).
5860 sidebar.read_with(cx, |sidebar, _| {
5861 assert!(
5862 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
5863 "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
5864 sidebar.active_entry,
5865 );
5866 });
5867
5868 // --- Scenario 2: archive the thread the user is looking at ---
5869
5870 // Create a thread in project-b (the active workspace) and verify it
5871 // becomes the active entry.
5872 let connection = acp_thread::StubAgentConnection::new();
5873 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5874 acp::ContentChunk::new("Done".into()),
5875 )]);
5876 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
5877 agent_ui::test_support::send_message(&panel_b, cx);
5878 let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
5879 cx.run_until_parked();
5880
5881 sidebar.read_with(cx, |sidebar, _| {
5882 assert!(
5883 is_active_session(&sidebar, &thread_b),
5884 "expected active_entry to be Thread({thread_b}), got: {:?}",
5885 sidebar.active_entry,
5886 );
5887 });
5888
5889 sidebar.update_in(cx, |sidebar, window, cx| {
5890 sidebar.archive_thread(&thread_b, window, cx);
5891 });
5892 cx.run_until_parked();
5893
5894 // Archiving the active thread activates a draft on the same workspace
5895 // (via clear_base_view → activate_draft). The draft is not shown as a
5896 // sidebar row but active_entry tracks it.
5897 sidebar.read_with(cx, |sidebar, _| {
5898 assert!(
5899 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
5900 "expected draft on workspace_b after archiving active thread, got: {:?}",
5901 sidebar.active_entry,
5902 );
5903 });
5904}
5905
5906#[gpui::test]
5907async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
5908 // Full flow: create a thread, archive it (removing the workspace),
5909 // then unarchive. Only the restored thread should appear — no
5910 // leftover drafts or previously-serialized threads.
5911 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5912 let (multi_workspace, cx) =
5913 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5914 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5915 cx.run_until_parked();
5916
5917 // Create a thread and send a message so it's a real thread.
5918 let connection = acp_thread::StubAgentConnection::new();
5919 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5920 acp::ContentChunk::new("Hello".into()),
5921 )]);
5922 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
5923 agent_ui::test_support::send_message(&panel, cx);
5924 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
5925 cx.run_until_parked();
5926
5927 // Archive it.
5928 sidebar.update_in(cx, |sidebar, window, cx| {
5929 sidebar.archive_thread(&session_id, window, cx);
5930 });
5931 cx.run_until_parked();
5932
5933 // Grab metadata for unarchive.
5934 let thread_id = cx.update(|_, cx| {
5935 ThreadMetadataStore::global(cx)
5936 .read(cx)
5937 .entries()
5938 .find(|e| e.session_id.as_ref() == Some(&session_id))
5939 .map(|e| e.thread_id)
5940 .expect("thread should exist")
5941 });
5942 let metadata = cx.update(|_, cx| {
5943 ThreadMetadataStore::global(cx)
5944 .read(cx)
5945 .entry(thread_id)
5946 .cloned()
5947 .expect("metadata should exist")
5948 });
5949
5950 // Unarchive it — the draft should be replaced by the restored thread.
5951 sidebar.update_in(cx, |sidebar, window, cx| {
5952 sidebar.open_thread_from_archive(metadata, window, cx);
5953 });
5954 cx.run_until_parked();
5955
5956 // Only the unarchived thread should be visible — no drafts, no other threads.
5957 let entries = visible_entries_as_strings(&sidebar, cx);
5958 let thread_count = entries
5959 .iter()
5960 .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
5961 .count();
5962 assert_eq!(
5963 thread_count, 1,
5964 "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
5965 );
5966 assert!(
5967 !entries.iter().any(|e| e.contains("Draft")),
5968 "expected no drafts after restoring, got entries: {entries:?}"
5969 );
5970}
5971
5972#[gpui::test]
5973async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
5974 cx: &mut TestAppContext,
5975) {
5976 // When a thread is unarchived into a project group that has no open
5977 // workspace, the sidebar opens a new workspace and loads the thread.
5978 // No spurious draft should appear alongside the unarchived thread.
5979 agent_ui::test_support::init_test(cx);
5980 cx.update(|cx| {
5981 ThreadStore::init_global(cx);
5982 ThreadMetadataStore::init_global(cx);
5983 language_model::LanguageModelRegistry::test(cx);
5984 prompt_store::init(cx);
5985 });
5986
5987 let fs = FakeFs::new(cx.executor());
5988 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
5989 .await;
5990 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
5991 .await;
5992 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5993
5994 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
5995 let (multi_workspace, cx) =
5996 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5997 let sidebar = setup_sidebar(&multi_workspace, cx);
5998 cx.run_until_parked();
5999
6000 // Save an archived thread whose folder_paths point to project-b,
6001 // which has no open workspace.
6002 let session_id = acp::SessionId::new(Arc::from("archived-thread"));
6003 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6004 let thread_id = ThreadId::new();
6005 cx.update(|_, cx| {
6006 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6007 store.save(
6008 ThreadMetadata {
6009 thread_id,
6010 session_id: Some(session_id.clone()),
6011 agent_id: agent::ZED_AGENT_ID.clone(),
6012 title: Some("Unarchived Thread".into()),
6013 updated_at: Utc::now(),
6014 created_at: None,
6015 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6016 archived: true,
6017 remote_connection: None,
6018 },
6019 cx,
6020 )
6021 });
6022 });
6023 cx.run_until_parked();
6024
6025 // Verify no workspace for project-b exists yet.
6026 assert_eq!(
6027 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6028 1,
6029 "should start with only the project-a workspace"
6030 );
6031
6032 // Un-archive the thread — should open project-b workspace and load it.
6033 let metadata = cx.update(|_, cx| {
6034 ThreadMetadataStore::global(cx)
6035 .read(cx)
6036 .entry(thread_id)
6037 .cloned()
6038 .expect("metadata should exist")
6039 });
6040
6041 sidebar.update_in(cx, |sidebar, window, cx| {
6042 sidebar.open_thread_from_archive(metadata, window, cx);
6043 });
6044 cx.run_until_parked();
6045
6046 // A second workspace should have been created for project-b.
6047 assert_eq!(
6048 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6049 2,
6050 "should have opened a workspace for the unarchived thread"
6051 );
6052
6053 // The sidebar should show the unarchived thread without a spurious draft
6054 // in the project-b group.
6055 let entries = visible_entries_as_strings(&sidebar, cx);
6056 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6057 // project-a gets a draft (it's the active workspace with no threads),
6058 // but project-b should NOT have one — only the unarchived thread.
6059 assert!(
6060 draft_count <= 1,
6061 "expected at most one draft (for project-a), got entries: {entries:?}"
6062 );
6063 assert!(
6064 entries.iter().any(|e| e.contains("Unarchived Thread")),
6065 "expected unarchived thread to appear, got entries: {entries:?}"
6066 );
6067}
6068
6069#[gpui::test]
6070async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
6071 cx: &mut TestAppContext,
6072) {
6073 agent_ui::test_support::init_test(cx);
6074 cx.update(|cx| {
6075 ThreadStore::init_global(cx);
6076 ThreadMetadataStore::init_global(cx);
6077 language_model::LanguageModelRegistry::test(cx);
6078 prompt_store::init(cx);
6079 });
6080
6081 let fs = FakeFs::new(cx.executor());
6082 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6083 .await;
6084 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6085 .await;
6086 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6087
6088 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6089 let (multi_workspace, cx) =
6090 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6091 let sidebar = setup_sidebar(&multi_workspace, cx);
6092 cx.run_until_parked();
6093
6094 let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
6095 let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
6096 let original_thread_id = ThreadId::new();
6097 cx.update(|_, cx| {
6098 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6099 store.save(
6100 ThreadMetadata {
6101 thread_id: original_thread_id,
6102 session_id: Some(session_id.clone()),
6103 agent_id: agent::ZED_AGENT_ID.clone(),
6104 title: Some("Unarchived Thread".into()),
6105 updated_at: Utc::now(),
6106 created_at: None,
6107 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6108 archived: true,
6109 remote_connection: None,
6110 },
6111 cx,
6112 )
6113 });
6114 });
6115 cx.run_until_parked();
6116
6117 let metadata = cx.update(|_, cx| {
6118 ThreadMetadataStore::global(cx)
6119 .read(cx)
6120 .entry(original_thread_id)
6121 .cloned()
6122 .expect("metadata should exist before unarchive")
6123 });
6124
6125 sidebar.update_in(cx, |sidebar, window, cx| {
6126 sidebar.open_thread_from_archive(metadata, window, cx);
6127 });
6128
6129 cx.run_until_parked();
6130
6131 assert_eq!(
6132 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6133 2,
6134 "expected unarchive to open the target workspace"
6135 );
6136
6137 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6138 mw.workspaces()
6139 .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
6140 .cloned()
6141 .expect("expected restored workspace for unarchived thread")
6142 });
6143 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6144 workspace
6145 .panel::<AgentPanel>(cx)
6146 .expect("expected unarchive to install an agent panel in the new workspace")
6147 });
6148
6149 let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6150 assert_eq!(
6151 restored_thread_id,
6152 Some(original_thread_id),
6153 "expected the new workspace's agent panel to target the restored archived thread id"
6154 );
6155
6156 let session_entries = cx.update(|_, cx| {
6157 ThreadMetadataStore::global(cx)
6158 .read(cx)
6159 .entries()
6160 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6161 .cloned()
6162 .collect::<Vec<_>>()
6163 });
6164 assert_eq!(
6165 session_entries.len(),
6166 1,
6167 "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
6168 );
6169 assert_eq!(
6170 session_entries[0].thread_id, original_thread_id,
6171 "expected restore into a new workspace to reuse the original thread id"
6172 );
6173 assert!(
6174 !session_entries[0].archived,
6175 "expected restored thread metadata to be unarchived, got: {:?}",
6176 session_entries[0]
6177 );
6178
6179 let mapped_thread_id = cx.update(|_, cx| {
6180 ThreadMetadataStore::global(cx)
6181 .read(cx)
6182 .entries()
6183 .find(|e| e.session_id.as_ref() == Some(&session_id))
6184 .map(|e| e.thread_id)
6185 });
6186 assert_eq!(
6187 mapped_thread_id,
6188 Some(original_thread_id),
6189 "expected session mapping to remain stable after opening the new workspace"
6190 );
6191
6192 let entries = visible_entries_as_strings(&sidebar, cx);
6193 let real_thread_rows = entries
6194 .iter()
6195 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6196 .filter(|entry| !entry.contains("Draft"))
6197 .count();
6198 assert_eq!(
6199 real_thread_rows, 1,
6200 "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
6201 );
6202 assert!(
6203 entries
6204 .iter()
6205 .any(|entry| entry.contains("Unarchived Thread")),
6206 "expected restored thread row to be visible, got entries: {entries:?}"
6207 );
6208}
6209
6210#[gpui::test]
6211async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
6212 // When a workspace already exists with an empty draft and a thread
6213 // is unarchived into it, the draft should be replaced — not kept
6214 // alongside the loaded thread.
6215 agent_ui::test_support::init_test(cx);
6216 cx.update(|cx| {
6217 ThreadStore::init_global(cx);
6218 ThreadMetadataStore::init_global(cx);
6219 language_model::LanguageModelRegistry::test(cx);
6220 prompt_store::init(cx);
6221 });
6222
6223 let fs = FakeFs::new(cx.executor());
6224 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6225 .await;
6226 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6227
6228 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6229 let (multi_workspace, cx) =
6230 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6231 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6232 cx.run_until_parked();
6233
6234 // Create a thread and send a message so it's no longer a draft.
6235 let connection = acp_thread::StubAgentConnection::new();
6236 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6237 acp::ContentChunk::new("Done".into()),
6238 )]);
6239 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6240 agent_ui::test_support::send_message(&panel, cx);
6241 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6242 cx.run_until_parked();
6243
6244 // Archive the thread — the group is left empty (no draft created).
6245 sidebar.update_in(cx, |sidebar, window, cx| {
6246 sidebar.archive_thread(&session_id, window, cx);
6247 });
6248 cx.run_until_parked();
6249
6250 // Un-archive the thread.
6251 let thread_id = cx.update(|_, cx| {
6252 ThreadMetadataStore::global(cx)
6253 .read(cx)
6254 .entries()
6255 .find(|e| e.session_id.as_ref() == Some(&session_id))
6256 .map(|e| e.thread_id)
6257 .expect("thread should exist in store")
6258 });
6259 let metadata = cx.update(|_, cx| {
6260 ThreadMetadataStore::global(cx)
6261 .read(cx)
6262 .entry(thread_id)
6263 .cloned()
6264 .expect("metadata should exist")
6265 });
6266
6267 sidebar.update_in(cx, |sidebar, window, cx| {
6268 sidebar.open_thread_from_archive(metadata, window, cx);
6269 });
6270 cx.run_until_parked();
6271
6272 // The draft should be gone — only the unarchived thread remains.
6273 let entries = visible_entries_as_strings(&sidebar, cx);
6274 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6275 assert_eq!(
6276 draft_count, 0,
6277 "expected no drafts after unarchiving, got entries: {entries:?}"
6278 );
6279}
6280
6281#[gpui::test]
6282async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
6283 cx: &mut TestAppContext,
6284) {
6285 agent_ui::test_support::init_test(cx);
6286 cx.update(|cx| {
6287 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6288 ThreadStore::init_global(cx);
6289 ThreadMetadataStore::init_global(cx);
6290 language_model::LanguageModelRegistry::test(cx);
6291 prompt_store::init(cx);
6292 });
6293
6294 let fs = FakeFs::new(cx.executor());
6295 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6296 .await;
6297 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6298 .await;
6299 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6300
6301 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6302 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6303
6304 let (multi_workspace, cx) =
6305 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6306 let sidebar = setup_sidebar(&multi_workspace, cx);
6307
6308 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6309 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6310 mw.test_add_workspace(project_b.clone(), window, cx)
6311 });
6312 let _panel_b = add_agent_panel(&workspace_b, cx);
6313 cx.run_until_parked();
6314
6315 multi_workspace.update_in(cx, |mw, window, cx| {
6316 mw.activate(workspace_a.clone(), window, cx);
6317 });
6318 cx.run_until_parked();
6319
6320 let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
6321 let thread_id = ThreadId::new();
6322 cx.update(|_, cx| {
6323 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6324 store.save(
6325 ThreadMetadata {
6326 thread_id,
6327 session_id: Some(session_id.clone()),
6328 agent_id: agent::ZED_AGENT_ID.clone(),
6329 title: Some("Restored In Inactive Workspace".into()),
6330 updated_at: Utc::now(),
6331 created_at: None,
6332 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
6333 PathBuf::from("/project-b"),
6334 ])),
6335 archived: true,
6336 remote_connection: None,
6337 },
6338 cx,
6339 )
6340 });
6341 });
6342 cx.run_until_parked();
6343
6344 let metadata = cx.update(|_, cx| {
6345 ThreadMetadataStore::global(cx)
6346 .read(cx)
6347 .entry(thread_id)
6348 .cloned()
6349 .expect("archived metadata should exist before restore")
6350 });
6351
6352 sidebar.update_in(cx, |sidebar, window, cx| {
6353 sidebar.open_thread_from_archive(metadata, window, cx);
6354 });
6355
6356 let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
6357 workspace.panel::<AgentPanel>(cx).expect(
6358 "target workspace should still have an agent panel immediately after activation",
6359 )
6360 });
6361 let immediate_active_thread_id =
6362 panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6363
6364 cx.run_until_parked();
6365
6366 sidebar.read_with(cx, |sidebar, _cx| {
6367 assert_active_thread(
6368 sidebar,
6369 &session_id,
6370 "unarchiving into an inactive existing workspace should end on the restored thread",
6371 );
6372 });
6373
6374 let panel_b = workspace_b.read_with(cx, |workspace, cx| {
6375 workspace
6376 .panel::<AgentPanel>(cx)
6377 .expect("target workspace should still have an agent panel")
6378 });
6379 assert_eq!(
6380 panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6381 Some(thread_id),
6382 "expected target panel to activate the restored thread id"
6383 );
6384 assert!(
6385 immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
6386 "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
6387 );
6388
6389 let entries = visible_entries_as_strings(&sidebar, cx);
6390 let target_rows: Vec<_> = entries
6391 .iter()
6392 .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
6393 .cloned()
6394 .collect();
6395 assert_eq!(
6396 target_rows.len(),
6397 1,
6398 "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
6399 );
6400 assert!(
6401 target_rows[0].contains("Restored In Inactive Workspace"),
6402 "expected the remaining row to be the restored thread, got entries: {entries:?}"
6403 );
6404 assert!(
6405 !target_rows[0].contains("Draft"),
6406 "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
6407 );
6408}
6409
6410#[gpui::test]
6411async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
6412 cx: &mut TestAppContext,
6413) {
6414 agent_ui::test_support::init_test(cx);
6415 cx.update(|cx| {
6416 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6417 ThreadStore::init_global(cx);
6418 ThreadMetadataStore::init_global(cx);
6419 language_model::LanguageModelRegistry::test(cx);
6420 prompt_store::init(cx);
6421 });
6422
6423 let fs = FakeFs::new(cx.executor());
6424 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6425 .await;
6426 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6427 .await;
6428 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6429
6430 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6431 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6432
6433 let (multi_workspace, cx) =
6434 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6435 let sidebar = setup_sidebar(&multi_workspace, cx);
6436
6437 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6438 mw.test_add_workspace(project_b.clone(), window, cx)
6439 });
6440 let panel_b = add_agent_panel(&workspace_b, cx);
6441 cx.run_until_parked();
6442
6443 let connection = acp_thread::StubAgentConnection::new();
6444 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6445 acp::ContentChunk::new("Done".into()),
6446 )]);
6447 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6448 agent_ui::test_support::send_message(&panel_b, cx);
6449 let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
6450 save_test_thread_metadata(&session_id, &project_b, cx).await;
6451 cx.run_until_parked();
6452
6453 sidebar.update_in(cx, |sidebar, window, cx| {
6454 sidebar.archive_thread(&session_id, window, cx);
6455 });
6456
6457 cx.run_until_parked();
6458
6459 let archived_metadata = cx.update(|_, cx| {
6460 let store = ThreadMetadataStore::global(cx).read(cx);
6461 let thread_id = store
6462 .entries()
6463 .find(|e| e.session_id.as_ref() == Some(&session_id))
6464 .map(|e| e.thread_id)
6465 .expect("archived thread should still exist in metadata store");
6466 let metadata = store
6467 .entry(thread_id)
6468 .cloned()
6469 .expect("archived metadata should still exist after archive");
6470 assert!(
6471 metadata.archived,
6472 "thread should be archived before project removal"
6473 );
6474 metadata
6475 });
6476
6477 let group_key_b =
6478 project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
6479 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
6480 mw.remove_project_group(&group_key_b, window, cx)
6481 });
6482 remove_task
6483 .await
6484 .expect("remove project group task should complete");
6485 cx.run_until_parked();
6486
6487 assert_eq!(
6488 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6489 1,
6490 "removing the archived thread's parent project group should remove its workspace"
6491 );
6492
6493 sidebar.update_in(cx, |sidebar, window, cx| {
6494 sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
6495 });
6496 cx.run_until_parked();
6497
6498 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6499 mw.workspaces()
6500 .find(|workspace| {
6501 PathList::new(&workspace.read(cx).root_paths(cx))
6502 == PathList::new(&[PathBuf::from("/project-b")])
6503 })
6504 .cloned()
6505 .expect("expected unarchive to recreate the removed project workspace")
6506 });
6507 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6508 workspace
6509 .panel::<AgentPanel>(cx)
6510 .expect("expected restored workspace to bootstrap an agent panel")
6511 });
6512
6513 let restored_thread_id = cx.update(|_, cx| {
6514 ThreadMetadataStore::global(cx)
6515 .read(cx)
6516 .entries()
6517 .find(|e| e.session_id.as_ref() == Some(&session_id))
6518 .map(|e| e.thread_id)
6519 .expect("session should still map to restored thread id")
6520 });
6521 assert_eq!(
6522 restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6523 Some(restored_thread_id),
6524 "expected unarchive after project removal to activate the restored real thread"
6525 );
6526
6527 sidebar.read_with(cx, |sidebar, _cx| {
6528 assert_active_thread(
6529 sidebar,
6530 &session_id,
6531 "expected sidebar active entry to track the restored thread after project removal",
6532 );
6533 });
6534
6535 let entries = visible_entries_as_strings(&sidebar, cx);
6536 let restored_title = archived_metadata.display_title().to_string();
6537 let matching_rows: Vec<_> = entries
6538 .iter()
6539 .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
6540 .cloned()
6541 .collect();
6542 assert_eq!(
6543 matching_rows.len(),
6544 1,
6545 "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
6546 );
6547 assert!(
6548 !matching_rows[0].contains("Draft"),
6549 "expected no draft row after unarchive following project removal, got entries: {entries:?}"
6550 );
6551}
6552
6553#[gpui::test]
6554async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
6555 agent_ui::test_support::init_test(cx);
6556 cx.update(|cx| {
6557 ThreadStore::init_global(cx);
6558 ThreadMetadataStore::init_global(cx);
6559 language_model::LanguageModelRegistry::test(cx);
6560 prompt_store::init(cx);
6561 });
6562
6563 let fs = FakeFs::new(cx.executor());
6564 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6565 .await;
6566 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6567
6568 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6569 let (multi_workspace, cx) =
6570 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6571 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6572 cx.run_until_parked();
6573
6574 let connection = acp_thread::StubAgentConnection::new();
6575 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6576 acp::ContentChunk::new("Done".into()),
6577 )]);
6578 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6579 agent_ui::test_support::send_message(&panel, cx);
6580 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6581 cx.run_until_parked();
6582
6583 let original_thread_id = cx.update(|_, cx| {
6584 ThreadMetadataStore::global(cx)
6585 .read(cx)
6586 .entries()
6587 .find(|e| e.session_id.as_ref() == Some(&session_id))
6588 .map(|e| e.thread_id)
6589 .expect("thread should exist in store before archiving")
6590 });
6591
6592 sidebar.update_in(cx, |sidebar, window, cx| {
6593 sidebar.archive_thread(&session_id, window, cx);
6594 });
6595 cx.run_until_parked();
6596
6597 let metadata = cx.update(|_, cx| {
6598 ThreadMetadataStore::global(cx)
6599 .read(cx)
6600 .entry(original_thread_id)
6601 .cloned()
6602 .expect("metadata should exist after archiving")
6603 });
6604
6605 sidebar.update_in(cx, |sidebar, window, cx| {
6606 sidebar.open_thread_from_archive(metadata, window, cx);
6607 });
6608 cx.run_until_parked();
6609
6610 let session_entries = cx.update(|_, cx| {
6611 ThreadMetadataStore::global(cx)
6612 .read(cx)
6613 .entries()
6614 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6615 .cloned()
6616 .collect::<Vec<_>>()
6617 });
6618
6619 assert_eq!(
6620 session_entries.len(),
6621 1,
6622 "expected exactly one metadata row for the restored session, got: {session_entries:?}"
6623 );
6624 assert_eq!(
6625 session_entries[0].thread_id, original_thread_id,
6626 "expected unarchive to reuse the original thread id instead of creating a duplicate row"
6627 );
6628 assert!(
6629 session_entries[0].session_id.is_some(),
6630 "expected restored metadata to be a real thread, got: {:?}",
6631 session_entries[0]
6632 );
6633
6634 let entries = visible_entries_as_strings(&sidebar, cx);
6635 let real_thread_rows = entries
6636 .iter()
6637 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6638 .filter(|entry| !entry.contains("Draft"))
6639 .count();
6640 assert_eq!(
6641 real_thread_rows, 1,
6642 "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
6643 );
6644 assert!(
6645 !entries.iter().any(|entry| entry.contains("Draft")),
6646 "expected no draft rows after restoring, got entries: {entries:?}"
6647 );
6648}
6649
6650#[gpui::test]
6651async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
6652 cx: &mut TestAppContext,
6653) {
6654 // When a thread is archived while the user is in a different workspace,
6655 // clear_base_view creates a draft on the archived workspace's panel.
6656 // Switching back to that workspace shows the draft as active_entry.
6657 agent_ui::test_support::init_test(cx);
6658 cx.update(|cx| {
6659 ThreadStore::init_global(cx);
6660 ThreadMetadataStore::init_global(cx);
6661 language_model::LanguageModelRegistry::test(cx);
6662 prompt_store::init(cx);
6663 });
6664
6665 let fs = FakeFs::new(cx.executor());
6666 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6667 .await;
6668 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6669 .await;
6670 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6671
6672 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6673 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6674
6675 let (multi_workspace, cx) =
6676 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6677 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6678
6679 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6680 mw.test_add_workspace(project_b.clone(), window, cx)
6681 });
6682 let _panel_b = add_agent_panel(&workspace_b, cx);
6683 cx.run_until_parked();
6684
6685 // Create a thread in project-a's panel (currently non-active).
6686 let connection = acp_thread::StubAgentConnection::new();
6687 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6688 acp::ContentChunk::new("Done".into()),
6689 )]);
6690 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
6691 agent_ui::test_support::send_message(&panel_a, cx);
6692 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
6693 cx.run_until_parked();
6694
6695 // Archive it while project-b is active.
6696 sidebar.update_in(cx, |sidebar, window, cx| {
6697 sidebar.archive_thread(&thread_a, window, cx);
6698 });
6699 cx.run_until_parked();
6700
6701 // Switch back to project-a. Its panel was cleared during archiving
6702 // (clear_base_view activated a draft), so active_entry should point
6703 // to the draft on workspace_a.
6704 let workspace_a =
6705 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
6706 multi_workspace.update_in(cx, |mw, window, cx| {
6707 mw.activate(workspace_a.clone(), window, cx);
6708 });
6709 cx.run_until_parked();
6710
6711 sidebar.update_in(cx, |sidebar, _window, cx| {
6712 sidebar.update_entries(cx);
6713 });
6714 cx.run_until_parked();
6715
6716 sidebar.read_with(cx, |sidebar, _| {
6717 assert_active_draft(
6718 sidebar,
6719 &workspace_a,
6720 "after switching to workspace with archived thread, active_entry should be the draft",
6721 );
6722 });
6723}
6724
6725#[gpui::test]
6726async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
6727 let project = init_test_project("/my-project", cx).await;
6728 let (multi_workspace, cx) =
6729 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6730 let sidebar = setup_sidebar(&multi_workspace, cx);
6731
6732 save_thread_metadata(
6733 acp::SessionId::new(Arc::from("visible-thread")),
6734 Some("Visible Thread".into()),
6735 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6736 None,
6737 &project,
6738 cx,
6739 );
6740
6741 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
6742 save_thread_metadata(
6743 archived_thread_session_id.clone(),
6744 Some("Archived Thread".into()),
6745 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6746 None,
6747 &project,
6748 cx,
6749 );
6750
6751 cx.update(|_, cx| {
6752 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6753 let thread_id = store
6754 .entries()
6755 .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
6756 .map(|e| e.thread_id)
6757 .unwrap();
6758 store.archive(thread_id, None, cx)
6759 })
6760 });
6761 cx.run_until_parked();
6762
6763 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6764 cx.run_until_parked();
6765
6766 let entries = visible_entries_as_strings(&sidebar, cx);
6767 assert!(
6768 entries.iter().any(|e| e.contains("Visible Thread")),
6769 "expected visible thread in sidebar, got: {entries:?}"
6770 );
6771 assert!(
6772 !entries.iter().any(|e| e.contains("Archived Thread")),
6773 "expected archived thread to be hidden from sidebar, got: {entries:?}"
6774 );
6775
6776 cx.update(|_, cx| {
6777 let store = ThreadMetadataStore::global(cx);
6778 let all: Vec<_> = store.read(cx).entries().collect();
6779 assert_eq!(
6780 all.len(),
6781 2,
6782 "expected 2 total entries in the store, got: {}",
6783 all.len()
6784 );
6785
6786 let archived: Vec<_> = store.read(cx).archived_entries().collect();
6787 assert_eq!(archived.len(), 1);
6788 assert_eq!(
6789 archived[0].session_id.as_ref().unwrap().0.as_ref(),
6790 "archived-thread"
6791 );
6792 });
6793}
6794
6795#[gpui::test]
6796async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
6797 cx: &mut TestAppContext,
6798) {
6799 // When a linked worktree has a single thread and that thread is archived,
6800 // the sidebar must NOT create a new thread on the same worktree (which
6801 // would prevent the worktree from being cleaned up on disk). Instead,
6802 // archive_thread switches to a sibling thread on the main workspace (or
6803 // creates a draft there) before archiving the metadata.
6804 agent_ui::test_support::init_test(cx);
6805 cx.update(|cx| {
6806 ThreadStore::init_global(cx);
6807 ThreadMetadataStore::init_global(cx);
6808 language_model::LanguageModelRegistry::test(cx);
6809 prompt_store::init(cx);
6810 });
6811
6812 let fs = FakeFs::new(cx.executor());
6813
6814 fs.insert_tree(
6815 "/project",
6816 serde_json::json!({
6817 ".git": {},
6818 "src": {},
6819 }),
6820 )
6821 .await;
6822
6823 fs.add_linked_worktree_for_repo(
6824 Path::new("/project/.git"),
6825 false,
6826 git::repository::Worktree {
6827 path: std::path::PathBuf::from("/wt-ochre-drift"),
6828 ref_name: Some("refs/heads/ochre-drift".into()),
6829 sha: "aaa".into(),
6830 is_main: false,
6831 is_bare: false,
6832 },
6833 )
6834 .await;
6835
6836 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6837
6838 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6839 let worktree_project =
6840 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
6841
6842 main_project
6843 .update(cx, |p, cx| p.git_scans_complete(cx))
6844 .await;
6845 worktree_project
6846 .update(cx, |p, cx| p.git_scans_complete(cx))
6847 .await;
6848
6849 let (multi_workspace, cx) =
6850 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
6851
6852 let sidebar = setup_sidebar(&multi_workspace, cx);
6853
6854 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6855 mw.test_add_workspace(worktree_project.clone(), window, cx)
6856 });
6857
6858 // Set up both workspaces with agent panels.
6859 let main_workspace =
6860 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
6861 let _main_panel = add_agent_panel(&main_workspace, cx);
6862 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
6863
6864 // Activate the linked worktree workspace so the sidebar tracks it.
6865 multi_workspace.update_in(cx, |mw, window, cx| {
6866 mw.activate(worktree_workspace.clone(), window, cx);
6867 });
6868
6869 // Open a thread in the linked worktree panel and send a message
6870 // so it becomes the active thread.
6871 let connection = StubAgentConnection::new();
6872 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6873 send_message(&worktree_panel, cx);
6874
6875 let worktree_thread_id = active_session_id(&worktree_panel, cx);
6876
6877 // Give the thread a response chunk so it has content.
6878 cx.update(|_, cx| {
6879 connection.send_update(
6880 worktree_thread_id.clone(),
6881 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
6882 cx,
6883 );
6884 });
6885
6886 // Save the worktree thread's metadata.
6887 save_thread_metadata(
6888 worktree_thread_id.clone(),
6889 Some("Ochre Drift Thread".into()),
6890 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6891 None,
6892 &worktree_project,
6893 cx,
6894 );
6895
6896 // Also save a thread on the main project so there's a sibling in the
6897 // group that can be selected after archiving.
6898 save_thread_metadata(
6899 acp::SessionId::new(Arc::from("main-project-thread")),
6900 Some("Main Project Thread".into()),
6901 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6902 None,
6903 &main_project,
6904 cx,
6905 );
6906
6907 cx.run_until_parked();
6908
6909 // Verify the linked worktree thread appears with its chip.
6910 // The live thread title comes from the message text ("Hello"), not
6911 // the metadata title we saved.
6912 let entries_before = visible_entries_as_strings(&sidebar, cx);
6913 assert!(
6914 entries_before
6915 .iter()
6916 .any(|s| s.contains("{wt-ochre-drift}")),
6917 "expected worktree thread with chip before archiving, got: {entries_before:?}"
6918 );
6919 assert!(
6920 entries_before
6921 .iter()
6922 .any(|s| s.contains("Main Project Thread")),
6923 "expected main project thread before archiving, got: {entries_before:?}"
6924 );
6925
6926 // Confirm the worktree thread is the active entry.
6927 sidebar.read_with(cx, |s, _| {
6928 assert_active_thread(
6929 s,
6930 &worktree_thread_id,
6931 "worktree thread should be active before archiving",
6932 );
6933 });
6934
6935 // Archive the worktree thread — it's the only thread using ochre-drift.
6936 sidebar.update_in(cx, |sidebar, window, cx| {
6937 sidebar.archive_thread(&worktree_thread_id, window, cx);
6938 });
6939
6940 cx.run_until_parked();
6941
6942 // The archived thread should no longer appear in the sidebar.
6943 let entries_after = visible_entries_as_strings(&sidebar, cx);
6944 assert!(
6945 !entries_after
6946 .iter()
6947 .any(|s| s.contains("Ochre Drift Thread")),
6948 "archived thread should be hidden, got: {entries_after:?}"
6949 );
6950
6951 // No "+ New Thread" entry should appear with the ochre-drift worktree
6952 // chip — that would keep the worktree alive and prevent cleanup.
6953 assert!(
6954 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
6955 "no entry should reference the archived worktree, got: {entries_after:?}"
6956 );
6957
6958 // The main project thread should still be visible.
6959 assert!(
6960 entries_after
6961 .iter()
6962 .any(|s| s.contains("Main Project Thread")),
6963 "main project thread should still be visible, got: {entries_after:?}"
6964 );
6965}
6966
6967#[gpui::test]
6968async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
6969 cx: &mut TestAppContext,
6970) {
6971 // When a linked worktree thread is the ONLY thread in the project group
6972 // (no threads on the main repo either), archiving it should leave the
6973 // group empty with no active entry.
6974 agent_ui::test_support::init_test(cx);
6975 cx.update(|cx| {
6976 ThreadStore::init_global(cx);
6977 ThreadMetadataStore::init_global(cx);
6978 language_model::LanguageModelRegistry::test(cx);
6979 prompt_store::init(cx);
6980 });
6981
6982 let fs = FakeFs::new(cx.executor());
6983
6984 fs.insert_tree(
6985 "/project",
6986 serde_json::json!({
6987 ".git": {},
6988 "src": {},
6989 }),
6990 )
6991 .await;
6992
6993 fs.add_linked_worktree_for_repo(
6994 Path::new("/project/.git"),
6995 false,
6996 git::repository::Worktree {
6997 path: std::path::PathBuf::from("/wt-ochre-drift"),
6998 ref_name: Some("refs/heads/ochre-drift".into()),
6999 sha: "aaa".into(),
7000 is_main: false,
7001 is_bare: false,
7002 },
7003 )
7004 .await;
7005
7006 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7007
7008 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7009 let worktree_project =
7010 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7011
7012 main_project
7013 .update(cx, |p, cx| p.git_scans_complete(cx))
7014 .await;
7015 worktree_project
7016 .update(cx, |p, cx| p.git_scans_complete(cx))
7017 .await;
7018
7019 let (multi_workspace, cx) =
7020 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7021
7022 let sidebar = setup_sidebar(&multi_workspace, cx);
7023
7024 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7025 mw.test_add_workspace(worktree_project.clone(), window, cx)
7026 });
7027
7028 let main_workspace =
7029 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7030 let _main_panel = add_agent_panel(&main_workspace, cx);
7031 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7032
7033 // Activate the linked worktree workspace.
7034 multi_workspace.update_in(cx, |mw, window, cx| {
7035 mw.activate(worktree_workspace.clone(), window, cx);
7036 });
7037
7038 // Open a thread on the linked worktree — this is the ONLY thread.
7039 let connection = StubAgentConnection::new();
7040 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7041 send_message(&worktree_panel, cx);
7042
7043 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7044
7045 cx.update(|_, cx| {
7046 connection.send_update(
7047 worktree_thread_id.clone(),
7048 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7049 cx,
7050 );
7051 });
7052
7053 save_thread_metadata(
7054 worktree_thread_id.clone(),
7055 Some("Ochre Drift Thread".into()),
7056 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7057 None,
7058 &worktree_project,
7059 cx,
7060 );
7061
7062 cx.run_until_parked();
7063
7064 // Archive it — there are no other threads in the group.
7065 sidebar.update_in(cx, |sidebar, window, cx| {
7066 sidebar.archive_thread(&worktree_thread_id, window, cx);
7067 });
7068
7069 cx.run_until_parked();
7070
7071 let entries_after = visible_entries_as_strings(&sidebar, cx);
7072
7073 // No entry should reference the linked worktree.
7074 assert!(
7075 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7076 "no entry should reference the archived worktree, got: {entries_after:?}"
7077 );
7078
7079 // The active entry should be None — no draft is created.
7080 sidebar.read_with(cx, |s, _| {
7081 assert!(
7082 s.active_entry.is_none(),
7083 "expected no active entry after archiving the last thread, got: {:?}",
7084 s.active_entry,
7085 );
7086 });
7087}
7088
7089#[gpui::test]
7090async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
7091 cx: &mut TestAppContext,
7092) {
7093 // When an archived thread belongs to a linked worktree whose main repo is
7094 // already open, unarchiving should reopen the linked workspace into the
7095 // same project group and show only the restored real thread row.
7096 agent_ui::test_support::init_test(cx);
7097 cx.update(|cx| {
7098 ThreadStore::init_global(cx);
7099 ThreadMetadataStore::init_global(cx);
7100 language_model::LanguageModelRegistry::test(cx);
7101 prompt_store::init(cx);
7102 });
7103
7104 let fs = FakeFs::new(cx.executor());
7105
7106 fs.insert_tree(
7107 "/project",
7108 serde_json::json!({
7109 ".git": {},
7110 "src": {},
7111 }),
7112 )
7113 .await;
7114
7115 fs.insert_tree(
7116 "/wt-ochre-drift",
7117 serde_json::json!({
7118 ".git": "gitdir: /project/.git/worktrees/ochre-drift",
7119 "src": {},
7120 }),
7121 )
7122 .await;
7123
7124 fs.add_linked_worktree_for_repo(
7125 Path::new("/project/.git"),
7126 false,
7127 git::repository::Worktree {
7128 path: std::path::PathBuf::from("/wt-ochre-drift"),
7129 ref_name: Some("refs/heads/ochre-drift".into()),
7130 sha: "aaa".into(),
7131 is_main: false,
7132 is_bare: false,
7133 },
7134 )
7135 .await;
7136
7137 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7138
7139 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7140 let worktree_project =
7141 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7142
7143 main_project
7144 .update(cx, |p, cx| p.git_scans_complete(cx))
7145 .await;
7146 worktree_project
7147 .update(cx, |p, cx| p.git_scans_complete(cx))
7148 .await;
7149
7150 let (multi_workspace, cx) =
7151 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7152
7153 let sidebar = setup_sidebar(&multi_workspace, cx);
7154 let main_workspace =
7155 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7156 let _main_panel = add_agent_panel(&main_workspace, cx);
7157 cx.run_until_parked();
7158
7159 let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
7160 let original_thread_id = ThreadId::new();
7161 let main_paths = PathList::new(&[PathBuf::from("/project")]);
7162 let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
7163
7164 cx.update(|_, cx| {
7165 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7166 store.save(
7167 ThreadMetadata {
7168 thread_id: original_thread_id,
7169 session_id: Some(session_id.clone()),
7170 agent_id: agent::ZED_AGENT_ID.clone(),
7171 title: Some("Unarchived Linked Thread".into()),
7172 updated_at: Utc::now(),
7173 created_at: None,
7174 worktree_paths: WorktreePaths::from_path_lists(
7175 main_paths.clone(),
7176 folder_paths.clone(),
7177 )
7178 .expect("main and folder paths should be well-formed"),
7179 archived: true,
7180 remote_connection: None,
7181 },
7182 cx,
7183 )
7184 });
7185 });
7186 cx.run_until_parked();
7187
7188 let metadata = cx.update(|_, cx| {
7189 ThreadMetadataStore::global(cx)
7190 .read(cx)
7191 .entry(original_thread_id)
7192 .cloned()
7193 .expect("archived linked-worktree metadata should exist before restore")
7194 });
7195
7196 sidebar.update_in(cx, |sidebar, window, cx| {
7197 sidebar.open_thread_from_archive(metadata, window, cx);
7198 });
7199 cx.run_until_parked();
7200
7201 assert_eq!(
7202 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7203 2,
7204 "expected unarchive to open the linked worktree workspace into the project group"
7205 );
7206
7207 let session_entries = cx.update(|_, cx| {
7208 ThreadMetadataStore::global(cx)
7209 .read(cx)
7210 .entries()
7211 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7212 .cloned()
7213 .collect::<Vec<_>>()
7214 });
7215 assert_eq!(
7216 session_entries.len(),
7217 1,
7218 "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
7219 );
7220 assert_eq!(
7221 session_entries[0].thread_id, original_thread_id,
7222 "expected unarchive to reuse the original linked worktree thread id"
7223 );
7224 assert!(
7225 !session_entries[0].archived,
7226 "expected restored linked worktree metadata to be unarchived, got: {:?}",
7227 session_entries[0]
7228 );
7229
7230 let assert_no_extra_rows = |entries: &[String]| {
7231 let real_thread_rows = entries
7232 .iter()
7233 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7234 .filter(|entry| !entry.contains("Draft"))
7235 .count();
7236 assert_eq!(
7237 real_thread_rows, 1,
7238 "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
7239 );
7240 assert!(
7241 !entries.iter().any(|entry| entry.contains("Draft")),
7242 "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
7243 );
7244 assert!(
7245 !entries
7246 .iter()
7247 .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
7248 "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
7249 );
7250 assert!(
7251 entries
7252 .iter()
7253 .any(|entry| entry.contains("Unarchived Linked Thread")),
7254 "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
7255 );
7256 };
7257
7258 let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
7259 assert_no_extra_rows(&entries_after_restore);
7260
7261 // The reported bug may only appear after an extra scheduling turn.
7262 cx.run_until_parked();
7263
7264 let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
7265 assert_no_extra_rows(&entries_after_extra_turns);
7266}
7267
7268#[gpui::test]
7269async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
7270 // When a linked worktree thread is archived but the group has other
7271 // threads (e.g. on the main project), archive_thread should select
7272 // the nearest sibling.
7273 agent_ui::test_support::init_test(cx);
7274 cx.update(|cx| {
7275 ThreadStore::init_global(cx);
7276 ThreadMetadataStore::init_global(cx);
7277 language_model::LanguageModelRegistry::test(cx);
7278 prompt_store::init(cx);
7279 });
7280
7281 let fs = FakeFs::new(cx.executor());
7282
7283 fs.insert_tree(
7284 "/project",
7285 serde_json::json!({
7286 ".git": {},
7287 "src": {},
7288 }),
7289 )
7290 .await;
7291
7292 fs.add_linked_worktree_for_repo(
7293 Path::new("/project/.git"),
7294 false,
7295 git::repository::Worktree {
7296 path: std::path::PathBuf::from("/wt-ochre-drift"),
7297 ref_name: Some("refs/heads/ochre-drift".into()),
7298 sha: "aaa".into(),
7299 is_main: false,
7300 is_bare: false,
7301 },
7302 )
7303 .await;
7304
7305 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7306
7307 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7308 let worktree_project =
7309 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7310
7311 main_project
7312 .update(cx, |p, cx| p.git_scans_complete(cx))
7313 .await;
7314 worktree_project
7315 .update(cx, |p, cx| p.git_scans_complete(cx))
7316 .await;
7317
7318 let (multi_workspace, cx) =
7319 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7320
7321 let sidebar = setup_sidebar(&multi_workspace, cx);
7322
7323 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7324 mw.test_add_workspace(worktree_project.clone(), window, cx)
7325 });
7326
7327 let main_workspace =
7328 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7329 let _main_panel = add_agent_panel(&main_workspace, cx);
7330 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7331
7332 // Activate the linked worktree workspace.
7333 multi_workspace.update_in(cx, |mw, window, cx| {
7334 mw.activate(worktree_workspace.clone(), window, cx);
7335 });
7336
7337 // Open a thread on the linked worktree.
7338 let connection = StubAgentConnection::new();
7339 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7340 send_message(&worktree_panel, cx);
7341
7342 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7343
7344 cx.update(|_, cx| {
7345 connection.send_update(
7346 worktree_thread_id.clone(),
7347 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7348 cx,
7349 );
7350 });
7351
7352 save_thread_metadata(
7353 worktree_thread_id.clone(),
7354 Some("Ochre Drift Thread".into()),
7355 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7356 None,
7357 &worktree_project,
7358 cx,
7359 );
7360
7361 // Save a sibling thread on the main project.
7362 let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
7363 save_thread_metadata(
7364 main_thread_id,
7365 Some("Main Project Thread".into()),
7366 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7367 None,
7368 &main_project,
7369 cx,
7370 );
7371
7372 cx.run_until_parked();
7373
7374 // Confirm the worktree thread is active.
7375 sidebar.read_with(cx, |s, _| {
7376 assert_active_thread(
7377 s,
7378 &worktree_thread_id,
7379 "worktree thread should be active before archiving",
7380 );
7381 });
7382
7383 // Archive the worktree thread.
7384 sidebar.update_in(cx, |sidebar, window, cx| {
7385 sidebar.archive_thread(&worktree_thread_id, window, cx);
7386 });
7387
7388 cx.run_until_parked();
7389
7390 // The worktree workspace was removed and a draft was created on the
7391 // main workspace. No entry should reference the linked worktree.
7392 let entries_after = visible_entries_as_strings(&sidebar, cx);
7393 assert!(
7394 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7395 "no entry should reference the archived worktree, got: {entries_after:?}"
7396 );
7397
7398 // The main project thread should still be visible.
7399 assert!(
7400 entries_after
7401 .iter()
7402 .any(|s| s.contains("Main Project Thread")),
7403 "main project thread should still be visible, got: {entries_after:?}"
7404 );
7405}
7406
7407// TODO: Restore this test once linked worktree draft entries are re-implemented.
7408// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
7409#[gpui::test]
7410#[ignore = "linked worktree draft entries not yet implemented"]
7411async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
7412 init_test(cx);
7413 let fs = FakeFs::new(cx.executor());
7414
7415 fs.insert_tree(
7416 "/project",
7417 serde_json::json!({
7418 ".git": {
7419 "worktrees": {
7420 "feature-a": {
7421 "commondir": "../../",
7422 "HEAD": "ref: refs/heads/feature-a",
7423 },
7424 },
7425 },
7426 "src": {},
7427 }),
7428 )
7429 .await;
7430
7431 fs.insert_tree(
7432 "/wt-feature-a",
7433 serde_json::json!({
7434 ".git": "gitdir: /project/.git/worktrees/feature-a",
7435 "src": {},
7436 }),
7437 )
7438 .await;
7439
7440 fs.add_linked_worktree_for_repo(
7441 Path::new("/project/.git"),
7442 false,
7443 git::repository::Worktree {
7444 path: PathBuf::from("/wt-feature-a"),
7445 ref_name: Some("refs/heads/feature-a".into()),
7446 sha: "aaa".into(),
7447 is_main: false,
7448 is_bare: false,
7449 },
7450 )
7451 .await;
7452
7453 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7454
7455 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7456 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7457
7458 main_project
7459 .update(cx, |p, cx| p.git_scans_complete(cx))
7460 .await;
7461 worktree_project
7462 .update(cx, |p, cx| p.git_scans_complete(cx))
7463 .await;
7464
7465 let (multi_workspace, cx) =
7466 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7467 let sidebar = setup_sidebar(&multi_workspace, cx);
7468
7469 // Open the linked worktree as a separate workspace (simulates cmd-o).
7470 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7471 mw.test_add_workspace(worktree_project.clone(), window, cx)
7472 });
7473 add_agent_panel(&worktree_workspace, cx);
7474 cx.run_until_parked();
7475
7476 // Explicitly create a draft thread from the linked worktree workspace.
7477 // Auto-created drafts use the group's first workspace (the main one),
7478 // so a user-created draft is needed to make the linked worktree reachable.
7479 sidebar.update_in(cx, |sidebar, window, cx| {
7480 sidebar.create_new_thread(&worktree_workspace, window, cx);
7481 });
7482 cx.run_until_parked();
7483
7484 // Switch back to the main workspace.
7485 multi_workspace.update_in(cx, |mw, window, cx| {
7486 let main_ws = mw.workspaces().next().unwrap().clone();
7487 mw.activate(main_ws, window, cx);
7488 });
7489 cx.run_until_parked();
7490
7491 sidebar.update_in(cx, |sidebar, _window, cx| {
7492 sidebar.update_entries(cx);
7493 });
7494 cx.run_until_parked();
7495
7496 // The linked worktree workspace must be reachable from some sidebar entry.
7497 let worktree_ws_id = worktree_workspace.entity_id();
7498 let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
7499 let mw = multi_workspace.read(cx);
7500 sidebar
7501 .contents
7502 .entries
7503 .iter()
7504 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
7505 .map(|ws| ws.entity_id())
7506 .collect()
7507 });
7508 assert!(
7509 reachable.contains(&worktree_ws_id),
7510 "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
7511 );
7512
7513 // Find the draft Thread entry whose workspace is the linked worktree.
7514 let _ = (worktree_ws_id, sidebar, multi_workspace);
7515 // todo("re-implement once linked worktree draft entries exist");
7516}
7517
7518#[gpui::test]
7519async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
7520 // When only a linked worktree workspace is open (not the main repo),
7521 // threads saved against the main repo should still appear in the sidebar.
7522 init_test(cx);
7523 let fs = FakeFs::new(cx.executor());
7524
7525 // Create the main repo with a linked worktree.
7526 fs.insert_tree(
7527 "/project",
7528 serde_json::json!({
7529 ".git": {
7530 "worktrees": {
7531 "feature-a": {
7532 "commondir": "../../",
7533 "HEAD": "ref: refs/heads/feature-a",
7534 },
7535 },
7536 },
7537 "src": {},
7538 }),
7539 )
7540 .await;
7541
7542 fs.insert_tree(
7543 "/wt-feature-a",
7544 serde_json::json!({
7545 ".git": "gitdir: /project/.git/worktrees/feature-a",
7546 "src": {},
7547 }),
7548 )
7549 .await;
7550
7551 fs.add_linked_worktree_for_repo(
7552 std::path::Path::new("/project/.git"),
7553 false,
7554 git::repository::Worktree {
7555 path: std::path::PathBuf::from("/wt-feature-a"),
7556 ref_name: Some("refs/heads/feature-a".into()),
7557 sha: "abc".into(),
7558 is_main: false,
7559 is_bare: false,
7560 },
7561 )
7562 .await;
7563
7564 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7565
7566 // Only open the linked worktree as a workspace — NOT the main repo.
7567 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7568 worktree_project
7569 .update(cx, |p, cx| p.git_scans_complete(cx))
7570 .await;
7571
7572 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7573 main_project
7574 .update(cx, |p, cx| p.git_scans_complete(cx))
7575 .await;
7576
7577 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7578 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
7579 });
7580 let sidebar = setup_sidebar(&multi_workspace, cx);
7581
7582 // Save a thread against the MAIN repo path.
7583 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
7584
7585 // Save a thread against the linked worktree path.
7586 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
7587
7588 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7589 cx.run_until_parked();
7590
7591 // Both threads should be visible: the worktree thread by direct lookup,
7592 // and the main repo thread because the workspace is a linked worktree
7593 // and we also query the main repo path.
7594 let entries = visible_entries_as_strings(&sidebar, cx);
7595 assert!(
7596 entries.iter().any(|e| e.contains("Main Repo Thread")),
7597 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
7598 );
7599 assert!(
7600 entries.iter().any(|e| e.contains("Worktree Thread")),
7601 "expected worktree thread to be visible, got: {entries:?}"
7602 );
7603}
7604
7605async fn init_multi_project_test(
7606 paths: &[&str],
7607 cx: &mut TestAppContext,
7608) -> (Arc<FakeFs>, Entity<project::Project>) {
7609 agent_ui::test_support::init_test(cx);
7610 cx.update(|cx| {
7611 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
7612 ThreadStore::init_global(cx);
7613 ThreadMetadataStore::init_global(cx);
7614 language_model::LanguageModelRegistry::test(cx);
7615 prompt_store::init(cx);
7616 });
7617 let fs = FakeFs::new(cx.executor());
7618 for path in paths {
7619 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
7620 .await;
7621 }
7622 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7623 let project =
7624 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
7625 (fs, project)
7626}
7627
7628async fn add_test_project(
7629 path: &str,
7630 fs: &Arc<FakeFs>,
7631 multi_workspace: &Entity<MultiWorkspace>,
7632 cx: &mut gpui::VisualTestContext,
7633) -> Entity<Workspace> {
7634 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
7635 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7636 mw.test_add_workspace(project, window, cx)
7637 });
7638 cx.run_until_parked();
7639 workspace
7640}
7641
7642#[gpui::test]
7643async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
7644 let (fs, project_a) =
7645 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
7646 let (multi_workspace, cx) =
7647 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
7648 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
7649
7650 // Sidebar starts closed. Initial workspace A is transient.
7651 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7652 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
7653 assert_eq!(
7654 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7655 1
7656 );
7657 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
7658
7659 // Add B — replaces A as the transient workspace.
7660 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
7661 assert_eq!(
7662 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7663 1
7664 );
7665 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
7666
7667 // Add C — replaces B as the transient workspace.
7668 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
7669 assert_eq!(
7670 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7671 1
7672 );
7673 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
7674}
7675
7676#[gpui::test]
7677async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
7678 let (fs, project_a) = init_multi_project_test(
7679 &["/project-a", "/project-b", "/project-c", "/project-d"],
7680 cx,
7681 )
7682 .await;
7683 let (multi_workspace, cx) =
7684 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
7685 let _sidebar = setup_sidebar(&multi_workspace, cx);
7686 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
7687
7688 // Add B — retained since sidebar is open.
7689 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
7690 assert_eq!(
7691 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7692 2
7693 );
7694
7695 // Switch to A — B survives. (Switching from one internal workspace, to another)
7696 multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
7697 cx.run_until_parked();
7698 assert_eq!(
7699 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7700 2
7701 );
7702
7703 // Close sidebar — both A and B remain retained.
7704 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
7705 cx.run_until_parked();
7706 assert_eq!(
7707 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7708 2
7709 );
7710
7711 // Add C — added as new transient workspace. (switching from retained, to transient)
7712 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
7713 assert_eq!(
7714 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7715 3
7716 );
7717 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
7718
7719 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
7720 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
7721 assert_eq!(
7722 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7723 3
7724 );
7725 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
7726}
7727
7728#[gpui::test]
7729async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
7730 let (fs, project_a) =
7731 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
7732 let (multi_workspace, cx) =
7733 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
7734 setup_sidebar_closed(&multi_workspace, cx);
7735
7736 // Add B — replaces A as the transient workspace (A is discarded).
7737 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
7738 assert_eq!(
7739 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7740 1
7741 );
7742 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
7743
7744 // Open sidebar — promotes the transient B to retained.
7745 multi_workspace.update_in(cx, |mw, window, cx| {
7746 mw.toggle_sidebar(window, cx);
7747 });
7748 cx.run_until_parked();
7749 assert_eq!(
7750 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7751 1
7752 );
7753 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
7754
7755 // Close sidebar — the retained B remains.
7756 multi_workspace.update_in(cx, |mw, window, cx| {
7757 mw.toggle_sidebar(window, cx);
7758 });
7759
7760 // Add C — added as new transient workspace.
7761 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
7762 assert_eq!(
7763 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7764 2
7765 );
7766 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
7767}
7768
7769#[gpui::test]
7770async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
7771 init_test(cx);
7772 let fs = FakeFs::new(cx.executor());
7773
7774 fs.insert_tree(
7775 "/project",
7776 serde_json::json!({
7777 ".git": {
7778 "worktrees": {
7779 "feature-a": {
7780 "commondir": "../../",
7781 "HEAD": "ref: refs/heads/feature-a",
7782 },
7783 },
7784 },
7785 "src": {},
7786 }),
7787 )
7788 .await;
7789
7790 fs.insert_tree(
7791 "/wt-feature-a",
7792 serde_json::json!({
7793 ".git": "gitdir: /project/.git/worktrees/feature-a",
7794 "src": {},
7795 }),
7796 )
7797 .await;
7798
7799 fs.add_linked_worktree_for_repo(
7800 Path::new("/project/.git"),
7801 false,
7802 git::repository::Worktree {
7803 path: PathBuf::from("/wt-feature-a"),
7804 ref_name: Some("refs/heads/feature-a".into()),
7805 sha: "abc".into(),
7806 is_main: false,
7807 is_bare: false,
7808 },
7809 )
7810 .await;
7811
7812 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7813
7814 // Only a linked worktree workspace is open — no workspace for /project.
7815 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7816 worktree_project
7817 .update(cx, |p, cx| p.git_scans_complete(cx))
7818 .await;
7819
7820 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7821 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
7822 });
7823 let sidebar = setup_sidebar(&multi_workspace, cx);
7824
7825 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
7826 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
7827 cx.update(|_, cx| {
7828 let metadata = ThreadMetadata {
7829 thread_id: ThreadId::new(),
7830 session_id: Some(legacy_session.clone()),
7831 agent_id: agent::ZED_AGENT_ID.clone(),
7832 title: Some("Legacy Main Thread".into()),
7833 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7834 created_at: None,
7835 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
7836 "/project",
7837 )])),
7838 archived: false,
7839 remote_connection: None,
7840 };
7841 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
7842 });
7843 cx.run_until_parked();
7844
7845 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7846 cx.run_until_parked();
7847
7848 // The legacy thread should appear in the sidebar under the project group.
7849 let entries = visible_entries_as_strings(&sidebar, cx);
7850 assert!(
7851 entries.iter().any(|e| e.contains("Legacy Main Thread")),
7852 "legacy thread should be visible: {entries:?}",
7853 );
7854
7855 // Verify only 1 workspace before clicking.
7856 assert_eq!(
7857 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7858 1,
7859 );
7860
7861 // Focus and select the legacy thread, then confirm.
7862 focus_sidebar(&sidebar, cx);
7863 let thread_index = sidebar.read_with(cx, |sidebar, _| {
7864 sidebar
7865 .contents
7866 .entries
7867 .iter()
7868 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
7869 .expect("legacy thread should be in entries")
7870 });
7871 sidebar.update_in(cx, |sidebar, _window, _cx| {
7872 sidebar.selection = Some(thread_index);
7873 });
7874 cx.dispatch_action(Confirm);
7875 cx.run_until_parked();
7876
7877 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7878 let new_path_list =
7879 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
7880 assert_eq!(
7881 new_path_list,
7882 PathList::new(&[PathBuf::from("/project")]),
7883 "the new workspace should be for the main repo, not the linked worktree",
7884 );
7885}
7886
7887#[gpui::test]
7888async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
7889 cx: &mut TestAppContext,
7890) {
7891 // Regression test for a property-test finding:
7892 // AddLinkedWorktree { project_group_index: 0 }
7893 // AddProject { use_worktree: true }
7894 // AddProject { use_worktree: false }
7895 // After these three steps, the linked-worktree workspace was not
7896 // reachable from any sidebar entry.
7897 agent_ui::test_support::init_test(cx);
7898 cx.update(|cx| {
7899 ThreadStore::init_global(cx);
7900 ThreadMetadataStore::init_global(cx);
7901 language_model::LanguageModelRegistry::test(cx);
7902 prompt_store::init(cx);
7903
7904 cx.observe_new(
7905 |workspace: &mut Workspace,
7906 window: Option<&mut Window>,
7907 cx: &mut gpui::Context<Workspace>| {
7908 if let Some(window) = window {
7909 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
7910 workspace.add_panel(panel, window, cx);
7911 }
7912 },
7913 )
7914 .detach();
7915 });
7916
7917 let fs = FakeFs::new(cx.executor());
7918 fs.insert_tree(
7919 "/my-project",
7920 serde_json::json!({
7921 ".git": {},
7922 "src": {},
7923 }),
7924 )
7925 .await;
7926 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7927 let project =
7928 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
7929 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
7930
7931 let (multi_workspace, cx) =
7932 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7933 let sidebar = setup_sidebar(&multi_workspace, cx);
7934
7935 // Step 1: Create a linked worktree for the main project.
7936 let worktree_name = "wt-0";
7937 let worktree_path = "/worktrees/wt-0";
7938
7939 fs.insert_tree(
7940 worktree_path,
7941 serde_json::json!({
7942 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
7943 "src": {},
7944 }),
7945 )
7946 .await;
7947 fs.insert_tree(
7948 "/my-project/.git/worktrees/wt-0",
7949 serde_json::json!({
7950 "commondir": "../../",
7951 "HEAD": "ref: refs/heads/wt-0",
7952 }),
7953 )
7954 .await;
7955 fs.add_linked_worktree_for_repo(
7956 Path::new("/my-project/.git"),
7957 false,
7958 git::repository::Worktree {
7959 path: PathBuf::from(worktree_path),
7960 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
7961 sha: "aaa".into(),
7962 is_main: false,
7963 is_bare: false,
7964 },
7965 )
7966 .await;
7967
7968 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7969 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
7970 main_project
7971 .update(cx, |p, cx| p.git_scans_complete(cx))
7972 .await;
7973 cx.run_until_parked();
7974
7975 // Step 2: Open the linked worktree as its own workspace.
7976 let worktree_project =
7977 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
7978 worktree_project
7979 .update(cx, |p, cx| p.git_scans_complete(cx))
7980 .await;
7981 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7982 mw.test_add_workspace(worktree_project.clone(), window, cx)
7983 });
7984 cx.run_until_parked();
7985
7986 // Step 3: Add an unrelated project.
7987 fs.insert_tree(
7988 "/other-project",
7989 serde_json::json!({
7990 ".git": {},
7991 "src": {},
7992 }),
7993 )
7994 .await;
7995 let other_project = project::Project::test(
7996 fs.clone() as Arc<dyn fs::Fs>,
7997 ["/other-project".as_ref()],
7998 cx,
7999 )
8000 .await;
8001 other_project
8002 .update(cx, |p, cx| p.git_scans_complete(cx))
8003 .await;
8004 multi_workspace.update_in(cx, |mw, window, cx| {
8005 mw.test_add_workspace(other_project.clone(), window, cx);
8006 });
8007 cx.run_until_parked();
8008
8009 // Force a full sidebar rebuild with all groups expanded.
8010 sidebar.update_in(cx, |sidebar, _window, cx| {
8011 if let Some(mw) = sidebar.multi_workspace.upgrade() {
8012 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
8013 }
8014 sidebar.update_entries(cx);
8015 });
8016 cx.run_until_parked();
8017
8018 // The linked-worktree workspace must be reachable from at least one
8019 // sidebar entry — otherwise the user has no way to navigate to it.
8020 let worktree_ws_id = worktree_workspace.entity_id();
8021 let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
8022 let mw = multi_workspace.read(cx);
8023
8024 let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
8025 let reachable: HashSet<gpui::EntityId> = sidebar
8026 .contents
8027 .entries
8028 .iter()
8029 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8030 .map(|ws| ws.entity_id())
8031 .collect();
8032 (all, reachable)
8033 });
8034
8035 let unreachable = &all_ids - &reachable_ids;
8036 eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
8037
8038 assert!(
8039 unreachable.is_empty(),
8040 "workspaces not reachable from any sidebar entry: {:?}\n\
8041 (linked-worktree workspace id: {:?})",
8042 unreachable,
8043 worktree_ws_id,
8044 );
8045}
8046
8047#[gpui::test]
8048async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
8049 // Empty project groups no longer auto-create drafts via reconciliation.
8050 // A fresh startup with no restorable thread should show only the header.
8051 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8052 let (multi_workspace, cx) =
8053 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8054 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8055
8056 let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8057
8058 let entries = visible_entries_as_strings(&sidebar, cx);
8059 assert_eq!(
8060 entries,
8061 vec!["v [my-project]"],
8062 "empty group should show only the header, no auto-created draft"
8063 );
8064}
8065
8066#[gpui::test]
8067async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
8068 // Rule 5: When the app starts and the AgentPanel successfully loads
8069 // a thread, no spurious draft should appear.
8070 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8071 let (multi_workspace, cx) =
8072 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8073 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8074
8075 // Create and send a message to make a real thread.
8076 let connection = StubAgentConnection::new();
8077 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8078 acp::ContentChunk::new("Done".into()),
8079 )]);
8080 open_thread_with_connection(&panel, connection, cx);
8081 send_message(&panel, cx);
8082 let session_id = active_session_id(&panel, cx);
8083 save_test_thread_metadata(&session_id, &project, cx).await;
8084 cx.run_until_parked();
8085
8086 // Should show the thread, NOT a spurious draft.
8087 let entries = visible_entries_as_strings(&sidebar, cx);
8088 assert_eq!(entries, vec!["v [my-project]", " Hello *"]);
8089
8090 // active_entry should be Thread, not Draft.
8091 sidebar.read_with(cx, |sidebar, _| {
8092 assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
8093 });
8094}
8095
8096#[gpui::test]
8097async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
8098 // Rule 9: Clicking a project header should restore whatever the
8099 // user was last looking at in that group, not create new drafts
8100 // or jump to the first entry.
8101 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
8102 let (multi_workspace, cx) =
8103 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8104 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8105
8106 // Create two threads in project-a.
8107 let conn1 = StubAgentConnection::new();
8108 conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8109 acp::ContentChunk::new("Done".into()),
8110 )]);
8111 open_thread_with_connection(&panel_a, conn1, cx);
8112 send_message(&panel_a, cx);
8113 let thread_a1 = active_session_id(&panel_a, cx);
8114 save_test_thread_metadata(&thread_a1, &project_a, cx).await;
8115
8116 let conn2 = StubAgentConnection::new();
8117 conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8118 acp::ContentChunk::new("Done".into()),
8119 )]);
8120 open_thread_with_connection(&panel_a, conn2, cx);
8121 send_message(&panel_a, cx);
8122 let thread_a2 = active_session_id(&panel_a, cx);
8123 save_test_thread_metadata(&thread_a2, &project_a, cx).await;
8124 cx.run_until_parked();
8125
8126 // The user is now looking at thread_a2.
8127 sidebar.read_with(cx, |sidebar, _| {
8128 assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
8129 });
8130
8131 // Add project-b and switch to it.
8132 let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
8133 fs.as_fake()
8134 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
8135 .await;
8136 let project_b =
8137 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8138 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8139 mw.test_add_workspace(project_b.clone(), window, cx)
8140 });
8141 let _panel_b = add_agent_panel(&workspace_b, cx);
8142 cx.run_until_parked();
8143
8144 // Now switch BACK to project-a by activating its workspace.
8145 let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
8146 mw.workspaces()
8147 .find(|ws| {
8148 ws.read(cx)
8149 .project()
8150 .read(cx)
8151 .visible_worktrees(cx)
8152 .any(|wt| {
8153 wt.read(cx)
8154 .abs_path()
8155 .to_string_lossy()
8156 .contains("project-a")
8157 })
8158 })
8159 .unwrap()
8160 .clone()
8161 });
8162 multi_workspace.update_in(cx, |mw, window, cx| {
8163 mw.activate(workspace_a.clone(), window, cx);
8164 });
8165 cx.run_until_parked();
8166
8167 // The panel should still show thread_a2 (the last thing the user
8168 // was viewing in project-a), not a draft or thread_a1.
8169 sidebar.read_with(cx, |sidebar, _| {
8170 assert_active_thread(
8171 sidebar,
8172 &thread_a2,
8173 "switching back to project-a should restore thread_a2",
8174 );
8175 });
8176
8177 // No spurious draft entries should have been created in
8178 // project-a's group (project-b may have a placeholder).
8179 let entries = visible_entries_as_strings(&sidebar, cx);
8180 // Find project-a's section and check it has no drafts.
8181 let project_a_start = entries
8182 .iter()
8183 .position(|e| e.contains("project-a"))
8184 .unwrap();
8185 let project_a_end = entries[project_a_start + 1..]
8186 .iter()
8187 .position(|e| e.starts_with("v "))
8188 .map(|i| i + project_a_start + 1)
8189 .unwrap_or(entries.len());
8190 let project_a_drafts = entries[project_a_start..project_a_end]
8191 .iter()
8192 .filter(|e| e.contains("Draft"))
8193 .count();
8194 assert_eq!(
8195 project_a_drafts, 0,
8196 "switching back to project-a should not create drafts in its group"
8197 );
8198}
8199
8200#[gpui::test]
8201async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
8202 // When a workspace has a draft (from the panel's load fallback)
8203 // and the user activates it (e.g. by clicking the placeholder or
8204 // the project header), no extra drafts should be created.
8205 init_test(cx);
8206 let fs = FakeFs::new(cx.executor());
8207 fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
8208 .await;
8209 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8210 .await;
8211 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8212
8213 let project_a =
8214 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
8215 let (multi_workspace, cx) =
8216 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8217 let sidebar = setup_sidebar(&multi_workspace, cx);
8218 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8219 let _panel_a = add_agent_panel(&workspace_a, cx);
8220 cx.run_until_parked();
8221
8222 // Add project-b with its own workspace and agent panel.
8223 let project_b =
8224 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8225 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8226 mw.test_add_workspace(project_b.clone(), window, cx)
8227 });
8228 let _panel_b = add_agent_panel(&workspace_b, cx);
8229 cx.run_until_parked();
8230
8231 // Explicitly create a draft on workspace_b so the sidebar tracks one.
8232 sidebar.update_in(cx, |sidebar, window, cx| {
8233 sidebar.create_new_thread(&workspace_b, window, cx);
8234 });
8235 cx.run_until_parked();
8236
8237 // Count project-b's drafts.
8238 let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
8239 let entries = visible_entries_as_strings(&sidebar, cx);
8240 entries
8241 .iter()
8242 .skip_while(|e| !e.contains("project-b"))
8243 .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
8244 .filter(|e| e.contains("Draft"))
8245 .count()
8246 };
8247 let drafts_before = count_b_drafts(cx);
8248
8249 // Switch away from project-b, then back.
8250 multi_workspace.update_in(cx, |mw, window, cx| {
8251 mw.activate(workspace_a.clone(), window, cx);
8252 });
8253 cx.run_until_parked();
8254 multi_workspace.update_in(cx, |mw, window, cx| {
8255 mw.activate(workspace_b.clone(), window, cx);
8256 });
8257 cx.run_until_parked();
8258
8259 let drafts_after = count_b_drafts(cx);
8260 assert_eq!(
8261 drafts_before, drafts_after,
8262 "activating workspace should not create extra drafts"
8263 );
8264
8265 // The draft should be highlighted as active after switching back.
8266 sidebar.read_with(cx, |sidebar, _| {
8267 assert_active_draft(
8268 sidebar,
8269 &workspace_b,
8270 "draft should be active after switching back to its workspace",
8271 );
8272 });
8273}
8274
8275#[gpui::test]
8276async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
8277 // Historical threads (not open in any agent panel) should have their
8278 // worktree paths updated when a folder is added to or removed from the
8279 // project.
8280 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
8281 let (multi_workspace, cx) =
8282 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8283 let sidebar = setup_sidebar(&multi_workspace, cx);
8284
8285 // Save two threads directly into the metadata store (not via the agent
8286 // panel), so they are purely historical — no open views hold them.
8287 // Use different timestamps so sort order is deterministic.
8288 save_thread_metadata(
8289 acp::SessionId::new(Arc::from("hist-1")),
8290 Some("Historical 1".into()),
8291 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8292 None,
8293 &project,
8294 cx,
8295 );
8296 save_thread_metadata(
8297 acp::SessionId::new(Arc::from("hist-2")),
8298 Some("Historical 2".into()),
8299 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
8300 None,
8301 &project,
8302 cx,
8303 );
8304 cx.run_until_parked();
8305 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8306 cx.run_until_parked();
8307
8308 // Sanity-check: both threads exist under the initial key [/project-a].
8309 let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
8310 cx.update(|_window, cx| {
8311 let store = ThreadMetadataStore::global(cx).read(cx);
8312 assert_eq!(
8313 store
8314 .entries_for_main_worktree_path(&old_key_paths, None)
8315 .count(),
8316 2,
8317 "should have 2 historical threads under old key before worktree add"
8318 );
8319 });
8320
8321 // Add a second worktree to the project.
8322 project
8323 .update(cx, |project, cx| {
8324 project.find_or_create_worktree("/project-b", true, cx)
8325 })
8326 .await
8327 .expect("should add worktree");
8328 cx.run_until_parked();
8329
8330 // The historical threads should now be indexed under the new combined
8331 // key [/project-a, /project-b].
8332 let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
8333 cx.update(|_window, cx| {
8334 let store = ThreadMetadataStore::global(cx).read(cx);
8335 assert_eq!(
8336 store
8337 .entries_for_main_worktree_path(&old_key_paths, None)
8338 .count(),
8339 0,
8340 "should have 0 historical threads under old key after worktree add"
8341 );
8342 assert_eq!(
8343 store
8344 .entries_for_main_worktree_path(&new_key_paths, None)
8345 .count(),
8346 2,
8347 "should have 2 historical threads under new key after worktree add"
8348 );
8349 });
8350
8351 // Sidebar should show threads under the new header.
8352 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8353 cx.run_until_parked();
8354 assert_eq!(
8355 visible_entries_as_strings(&sidebar, cx),
8356 vec![
8357 "v [project-a, project-b]",
8358 " Historical 2",
8359 " Historical 1",
8360 ]
8361 );
8362
8363 // Now remove the second worktree.
8364 let worktree_id = project.read_with(cx, |project, cx| {
8365 project
8366 .visible_worktrees(cx)
8367 .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
8368 .map(|wt| wt.read(cx).id())
8369 .expect("should find project-b worktree")
8370 });
8371 project.update(cx, |project, cx| {
8372 project.remove_worktree(worktree_id, cx);
8373 });
8374 cx.run_until_parked();
8375
8376 // Historical threads should migrate back to the original key.
8377 cx.update(|_window, cx| {
8378 let store = ThreadMetadataStore::global(cx).read(cx);
8379 assert_eq!(
8380 store
8381 .entries_for_main_worktree_path(&new_key_paths, None)
8382 .count(),
8383 0,
8384 "should have 0 historical threads under new key after worktree remove"
8385 );
8386 assert_eq!(
8387 store
8388 .entries_for_main_worktree_path(&old_key_paths, None)
8389 .count(),
8390 2,
8391 "should have 2 historical threads under old key after worktree remove"
8392 );
8393 });
8394
8395 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8396 cx.run_until_parked();
8397 assert_eq!(
8398 visible_entries_as_strings(&sidebar, cx),
8399 vec!["v [project-a]", " Historical 2", " Historical 1",]
8400 );
8401}
8402
8403#[gpui::test]
8404async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
8405 // When two workspaces share the same project group (same main path)
8406 // but have different folder paths (main repo vs linked worktree),
8407 // adding a worktree to the main workspace should regroup only that
8408 // workspace and its threads into the new project group. Threads for the
8409 // linked worktree workspace should remain under the original group.
8410 agent_ui::test_support::init_test(cx);
8411 cx.update(|cx| {
8412 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8413 ThreadStore::init_global(cx);
8414 ThreadMetadataStore::init_global(cx);
8415 language_model::LanguageModelRegistry::test(cx);
8416 prompt_store::init(cx);
8417 });
8418
8419 let fs = FakeFs::new(cx.executor());
8420 fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
8421 .await;
8422 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8423 .await;
8424 fs.add_linked_worktree_for_repo(
8425 Path::new("/project/.git"),
8426 false,
8427 git::repository::Worktree {
8428 path: std::path::PathBuf::from("/wt-feature"),
8429 ref_name: Some("refs/heads/feature".into()),
8430 sha: "aaa".into(),
8431 is_main: false,
8432 is_bare: false,
8433 },
8434 )
8435 .await;
8436 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8437
8438 // Workspace A: main repo at /project.
8439 let main_project =
8440 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
8441 // Workspace B: linked worktree of the same repo (same group, different folder).
8442 let worktree_project =
8443 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
8444
8445 main_project
8446 .update(cx, |p, cx| p.git_scans_complete(cx))
8447 .await;
8448 worktree_project
8449 .update(cx, |p, cx| p.git_scans_complete(cx))
8450 .await;
8451
8452 let (multi_workspace, cx) =
8453 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
8454 let sidebar = setup_sidebar(&multi_workspace, cx);
8455 multi_workspace.update_in(cx, |mw, window, cx| {
8456 mw.test_add_workspace(worktree_project.clone(), window, cx);
8457 });
8458 cx.run_until_parked();
8459
8460 // Save a thread for each workspace's folder paths.
8461 let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
8462 let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
8463 save_thread_metadata(
8464 acp::SessionId::new(Arc::from("thread-main")),
8465 Some("Main Thread".into()),
8466 time_main,
8467 Some(time_main),
8468 &main_project,
8469 cx,
8470 );
8471 save_thread_metadata(
8472 acp::SessionId::new(Arc::from("thread-wt")),
8473 Some("Worktree Thread".into()),
8474 time_wt,
8475 Some(time_wt),
8476 &worktree_project,
8477 cx,
8478 );
8479 cx.run_until_parked();
8480
8481 let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
8482 let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
8483
8484 // Sanity-check: each thread is indexed under its own folder paths, but
8485 // both appear under the shared sidebar group keyed by the main worktree.
8486 cx.update(|_window, cx| {
8487 let store = ThreadMetadataStore::global(cx).read(cx);
8488 assert_eq!(
8489 store.entries_for_path(&folder_paths_main, None).count(),
8490 1,
8491 "one thread under [/project]"
8492 );
8493 assert_eq!(
8494 store.entries_for_path(&folder_paths_wt, None).count(),
8495 1,
8496 "one thread under [/wt-feature]"
8497 );
8498 });
8499 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8500 cx.run_until_parked();
8501 assert_eq!(
8502 visible_entries_as_strings(&sidebar, cx),
8503 vec![
8504 "v [project]",
8505 " Worktree Thread {wt-feature}",
8506 " Main Thread",
8507 ]
8508 );
8509
8510 // Add /project-b to the main project only.
8511 main_project
8512 .update(cx, |project, cx| {
8513 project.find_or_create_worktree("/project-b", true, cx)
8514 })
8515 .await
8516 .expect("should add worktree");
8517 cx.run_until_parked();
8518
8519 // Main Thread (folder paths [/project]) should be regrouped to
8520 // [/project, /project-b]. Worktree Thread should remain under the
8521 // original [/project] group.
8522 let folder_paths_main_b =
8523 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
8524 cx.update(|_window, cx| {
8525 let store = ThreadMetadataStore::global(cx).read(cx);
8526 assert_eq!(
8527 store.entries_for_path(&folder_paths_main, None).count(),
8528 0,
8529 "main thread should no longer be under old folder paths [/project]"
8530 );
8531 assert_eq!(
8532 store.entries_for_path(&folder_paths_main_b, None).count(),
8533 1,
8534 "main thread should now be under [/project, /project-b]"
8535 );
8536 assert_eq!(
8537 store.entries_for_path(&folder_paths_wt, None).count(),
8538 1,
8539 "worktree thread should remain unchanged under [/wt-feature]"
8540 );
8541 });
8542
8543 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8544 cx.run_until_parked();
8545 assert_eq!(
8546 visible_entries_as_strings(&sidebar, cx),
8547 vec![
8548 "v [project]",
8549 " Worktree Thread {wt-feature}",
8550 "v [project, project-b]",
8551 " Main Thread",
8552 ]
8553 );
8554}
8555
8556#[gpui::test]
8557async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
8558 cx: &mut TestAppContext,
8559) {
8560 // When a linked worktree is opened as its own workspace and then a new
8561 // folder is added to the main project group, the linked worktree
8562 // workspace must still be reachable from some sidebar entry.
8563 let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
8564 let fs = _fs.clone();
8565
8566 // Set up git worktree infrastructure.
8567 fs.insert_tree(
8568 "/my-project/.git/worktrees/wt-0",
8569 serde_json::json!({
8570 "commondir": "../../",
8571 "HEAD": "ref: refs/heads/wt-0",
8572 }),
8573 )
8574 .await;
8575 fs.insert_tree(
8576 "/worktrees/wt-0",
8577 serde_json::json!({
8578 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8579 "src": {},
8580 }),
8581 )
8582 .await;
8583 fs.add_linked_worktree_for_repo(
8584 Path::new("/my-project/.git"),
8585 false,
8586 git::repository::Worktree {
8587 path: PathBuf::from("/worktrees/wt-0"),
8588 ref_name: Some("refs/heads/wt-0".into()),
8589 sha: "aaa".into(),
8590 is_main: false,
8591 is_bare: false,
8592 },
8593 )
8594 .await;
8595
8596 // Re-scan so the main project discovers the linked worktree.
8597 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8598
8599 let (multi_workspace, cx) =
8600 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8601 let sidebar = setup_sidebar(&multi_workspace, cx);
8602
8603 // Open the linked worktree as its own workspace.
8604 let worktree_project = project::Project::test(
8605 fs.clone() as Arc<dyn fs::Fs>,
8606 ["/worktrees/wt-0".as_ref()],
8607 cx,
8608 )
8609 .await;
8610 worktree_project
8611 .update(cx, |p, cx| p.git_scans_complete(cx))
8612 .await;
8613 multi_workspace.update_in(cx, |mw, window, cx| {
8614 mw.test_add_workspace(worktree_project.clone(), window, cx);
8615 });
8616 cx.run_until_parked();
8617
8618 // Both workspaces should be reachable.
8619 let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
8620 assert_eq!(workspace_count, 2, "should have 2 workspaces");
8621
8622 // Add a new folder to the main project, changing the project group key.
8623 fs.insert_tree(
8624 "/other-project",
8625 serde_json::json!({ ".git": {}, "src": {} }),
8626 )
8627 .await;
8628 project
8629 .update(cx, |project, cx| {
8630 project.find_or_create_worktree("/other-project", true, cx)
8631 })
8632 .await
8633 .expect("should add worktree");
8634 cx.run_until_parked();
8635
8636 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8637 cx.run_until_parked();
8638
8639 // The linked worktree workspace must still be reachable.
8640 let entries = visible_entries_as_strings(&sidebar, cx);
8641 let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
8642 mw.workspaces().map(|ws| ws.entity_id()).collect()
8643 });
8644 sidebar.read_with(cx, |sidebar, cx| {
8645 let multi_workspace = multi_workspace.read(cx);
8646 let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
8647 .contents
8648 .entries
8649 .iter()
8650 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
8651 .map(|ws| ws.entity_id())
8652 .collect();
8653 let all: std::collections::HashSet<gpui::EntityId> =
8654 mw_workspaces.iter().copied().collect();
8655 let unreachable = &all - &reachable;
8656 assert!(
8657 unreachable.is_empty(),
8658 "all workspaces should be reachable after adding folder; \
8659 unreachable: {:?}, entries: {:?}",
8660 unreachable,
8661 entries,
8662 );
8663 });
8664}
8665
8666mod property_test {
8667 use super::*;
8668 use gpui::proptest::prelude::*;
8669
8670 struct UnopenedWorktree {
8671 path: String,
8672 main_workspace_path: String,
8673 }
8674
8675 struct TestState {
8676 fs: Arc<FakeFs>,
8677 thread_counter: u32,
8678 workspace_counter: u32,
8679 worktree_counter: u32,
8680 saved_thread_ids: Vec<acp::SessionId>,
8681 unopened_worktrees: Vec<UnopenedWorktree>,
8682 }
8683
8684 impl TestState {
8685 fn new(fs: Arc<FakeFs>) -> Self {
8686 Self {
8687 fs,
8688 thread_counter: 0,
8689 workspace_counter: 1,
8690 worktree_counter: 0,
8691 saved_thread_ids: Vec::new(),
8692 unopened_worktrees: Vec::new(),
8693 }
8694 }
8695
8696 fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
8697 let id = self.thread_counter;
8698 self.thread_counter += 1;
8699 acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
8700 }
8701
8702 fn next_workspace_path(&mut self) -> String {
8703 let id = self.workspace_counter;
8704 self.workspace_counter += 1;
8705 format!("/prop-project-{id}")
8706 }
8707
8708 fn next_worktree_name(&mut self) -> String {
8709 let id = self.worktree_counter;
8710 self.worktree_counter += 1;
8711 format!("wt-{id}")
8712 }
8713 }
8714
8715 #[derive(Debug)]
8716 enum Operation {
8717 SaveThread { project_group_index: usize },
8718 SaveWorktreeThread { worktree_index: usize },
8719 ToggleAgentPanel,
8720 CreateDraftThread,
8721 AddProject { use_worktree: bool },
8722 ArchiveThread { index: usize },
8723 SwitchToThread { index: usize },
8724 SwitchToProjectGroup { index: usize },
8725 AddLinkedWorktree { project_group_index: usize },
8726 AddWorktreeToProject { project_group_index: usize },
8727 RemoveWorktreeFromProject { project_group_index: usize },
8728 }
8729
8730 // Distribution (out of 24 slots):
8731 // SaveThread: 5 slots (~21%)
8732 // SaveWorktreeThread: 2 slots (~8%)
8733 // ToggleAgentPanel: 1 slot (~4%)
8734 // CreateDraftThread: 1 slot (~4%)
8735 // AddProject: 1 slot (~4%)
8736 // ArchiveThread: 2 slots (~8%)
8737 // SwitchToThread: 2 slots (~8%)
8738 // SwitchToProjectGroup: 2 slots (~8%)
8739 // AddLinkedWorktree: 4 slots (~17%)
8740 // AddWorktreeToProject: 2 slots (~8%)
8741 // RemoveWorktreeFromProject: 2 slots (~8%)
8742 const DISTRIBUTION_SLOTS: u32 = 24;
8743
8744 impl TestState {
8745 fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
8746 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
8747
8748 match raw % DISTRIBUTION_SLOTS {
8749 0..=4 => Operation::SaveThread {
8750 project_group_index: extra % project_group_count,
8751 },
8752 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
8753 worktree_index: extra % self.unopened_worktrees.len(),
8754 },
8755 5..=6 => Operation::SaveThread {
8756 project_group_index: extra % project_group_count,
8757 },
8758 7 => Operation::ToggleAgentPanel,
8759 8 => Operation::CreateDraftThread,
8760 9 => Operation::AddProject {
8761 use_worktree: !self.unopened_worktrees.is_empty(),
8762 },
8763 10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
8764 index: extra % self.saved_thread_ids.len(),
8765 },
8766 10..=11 => Operation::AddProject {
8767 use_worktree: !self.unopened_worktrees.is_empty(),
8768 },
8769 12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
8770 index: extra % self.saved_thread_ids.len(),
8771 },
8772 12..=13 => Operation::SwitchToProjectGroup {
8773 index: extra % project_group_count,
8774 },
8775 14..=15 => Operation::SwitchToProjectGroup {
8776 index: extra % project_group_count,
8777 },
8778 16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
8779 project_group_index: extra % project_group_count,
8780 },
8781 16..=19 => Operation::SaveThread {
8782 project_group_index: extra % project_group_count,
8783 },
8784 20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
8785 project_group_index: extra % project_group_count,
8786 },
8787 20..=21 => Operation::SaveThread {
8788 project_group_index: extra % project_group_count,
8789 },
8790 22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
8791 project_group_index: extra % project_group_count,
8792 },
8793 22..=23 => Operation::SaveThread {
8794 project_group_index: extra % project_group_count,
8795 },
8796 _ => unreachable!(),
8797 }
8798 }
8799 }
8800
8801 fn save_thread_to_path_with_main(
8802 state: &mut TestState,
8803 path_list: PathList,
8804 main_worktree_paths: PathList,
8805 cx: &mut gpui::VisualTestContext,
8806 ) {
8807 let session_id = state.next_metadata_only_thread_id();
8808 let title: SharedString = format!("Thread {}", session_id).into();
8809 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
8810 .unwrap()
8811 + chrono::Duration::seconds(state.thread_counter as i64);
8812 let metadata = ThreadMetadata {
8813 thread_id: ThreadId::new(),
8814 session_id: Some(session_id),
8815 agent_id: agent::ZED_AGENT_ID.clone(),
8816 title: Some(title),
8817 updated_at,
8818 created_at: None,
8819 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
8820 archived: false,
8821 remote_connection: None,
8822 };
8823 cx.update(|_, cx| {
8824 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
8825 });
8826 cx.run_until_parked();
8827 }
8828
8829 async fn perform_operation(
8830 operation: Operation,
8831 state: &mut TestState,
8832 multi_workspace: &Entity<MultiWorkspace>,
8833 sidebar: &Entity<Sidebar>,
8834 cx: &mut gpui::VisualTestContext,
8835 ) {
8836 match operation {
8837 Operation::SaveThread {
8838 project_group_index,
8839 } => {
8840 // Find a workspace for this project group and create a real
8841 // thread via its agent panel.
8842 let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
8843 let keys = mw.project_group_keys();
8844 let key = &keys[project_group_index];
8845 let ws = mw
8846 .workspaces_for_project_group(key, cx)
8847 .and_then(|ws| ws.first().cloned())
8848 .unwrap_or_else(|| mw.workspace().clone());
8849 let project = ws.read(cx).project().clone();
8850 (ws, project)
8851 });
8852
8853 let panel =
8854 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
8855 if let Some(panel) = panel {
8856 let connection = StubAgentConnection::new();
8857 connection.set_next_prompt_updates(vec![
8858 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
8859 "Done".into(),
8860 )),
8861 ]);
8862 open_thread_with_connection(&panel, connection, cx);
8863 send_message(&panel, cx);
8864 let session_id = active_session_id(&panel, cx);
8865 state.saved_thread_ids.push(session_id.clone());
8866
8867 let title: SharedString = format!("Thread {}", state.thread_counter).into();
8868 state.thread_counter += 1;
8869 let updated_at =
8870 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
8871 .unwrap()
8872 + chrono::Duration::seconds(state.thread_counter as i64);
8873 save_thread_metadata(session_id, Some(title), updated_at, None, &project, cx);
8874 }
8875 }
8876 Operation::SaveWorktreeThread { worktree_index } => {
8877 let worktree = &state.unopened_worktrees[worktree_index];
8878 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
8879 let main_worktree_paths =
8880 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
8881 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
8882 }
8883
8884 Operation::ToggleAgentPanel => {
8885 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8886 let panel_open =
8887 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
8888 workspace.update_in(cx, |workspace, window, cx| {
8889 if panel_open {
8890 workspace.close_panel::<AgentPanel>(window, cx);
8891 } else {
8892 workspace.open_panel::<AgentPanel>(window, cx);
8893 }
8894 });
8895 }
8896 Operation::CreateDraftThread => {
8897 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8898 let panel =
8899 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
8900 if let Some(panel) = panel {
8901 panel.update_in(cx, |panel, window, cx| {
8902 panel.new_thread(&NewThread, window, cx);
8903 });
8904 cx.run_until_parked();
8905 }
8906 workspace.update_in(cx, |workspace, window, cx| {
8907 workspace.focus_panel::<AgentPanel>(window, cx);
8908 });
8909 }
8910 Operation::AddProject { use_worktree } => {
8911 let path = if use_worktree {
8912 // Open an existing linked worktree as a project (simulates Cmd+O
8913 // on a worktree directory).
8914 state.unopened_worktrees.remove(0).path
8915 } else {
8916 // Create a brand new project.
8917 let path = state.next_workspace_path();
8918 state
8919 .fs
8920 .insert_tree(
8921 &path,
8922 serde_json::json!({
8923 ".git": {},
8924 "src": {},
8925 }),
8926 )
8927 .await;
8928 path
8929 };
8930 let project = project::Project::test(
8931 state.fs.clone() as Arc<dyn fs::Fs>,
8932 [path.as_ref()],
8933 cx,
8934 )
8935 .await;
8936 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8937 multi_workspace.update_in(cx, |mw, window, cx| {
8938 mw.test_add_workspace(project.clone(), window, cx)
8939 });
8940 }
8941
8942 Operation::ArchiveThread { index } => {
8943 let session_id = state.saved_thread_ids[index].clone();
8944 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
8945 sidebar.archive_thread(&session_id, window, cx);
8946 });
8947 cx.run_until_parked();
8948 state.saved_thread_ids.remove(index);
8949 }
8950 Operation::SwitchToThread { index } => {
8951 let session_id = state.saved_thread_ids[index].clone();
8952 // Find the thread's position in the sidebar entries and select it.
8953 let thread_index = sidebar.read_with(cx, |sidebar, _| {
8954 sidebar.contents.entries.iter().position(|entry| {
8955 matches!(
8956 entry,
8957 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
8958 )
8959 })
8960 });
8961 if let Some(ix) = thread_index {
8962 sidebar.update_in(cx, |sidebar, window, cx| {
8963 sidebar.selection = Some(ix);
8964 sidebar.confirm(&Confirm, window, cx);
8965 });
8966 cx.run_until_parked();
8967 }
8968 }
8969 Operation::SwitchToProjectGroup { index } => {
8970 let workspace = multi_workspace.read_with(cx, |mw, cx| {
8971 let keys = mw.project_group_keys();
8972 let key = &keys[index];
8973 mw.workspaces_for_project_group(key, cx)
8974 .and_then(|ws| ws.first().cloned())
8975 .unwrap_or_else(|| mw.workspace().clone())
8976 });
8977 multi_workspace.update_in(cx, |mw, window, cx| {
8978 mw.activate(workspace, window, cx);
8979 });
8980 }
8981 Operation::AddLinkedWorktree {
8982 project_group_index,
8983 } => {
8984 // Get the main worktree path from the project group key.
8985 let main_path = multi_workspace.read_with(cx, |mw, _| {
8986 let keys = mw.project_group_keys();
8987 let key = &keys[project_group_index];
8988 key.path_list()
8989 .paths()
8990 .first()
8991 .unwrap()
8992 .to_string_lossy()
8993 .to_string()
8994 });
8995 let dot_git = format!("{}/.git", main_path);
8996 let worktree_name = state.next_worktree_name();
8997 let worktree_path = format!("/worktrees/{}", worktree_name);
8998
8999 state.fs
9000 .insert_tree(
9001 &worktree_path,
9002 serde_json::json!({
9003 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
9004 "src": {},
9005 }),
9006 )
9007 .await;
9008
9009 // Also create the worktree metadata dir inside the main repo's .git
9010 state
9011 .fs
9012 .insert_tree(
9013 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
9014 serde_json::json!({
9015 "commondir": "../../",
9016 "HEAD": format!("ref: refs/heads/{}", worktree_name),
9017 }),
9018 )
9019 .await;
9020
9021 let dot_git_path = std::path::Path::new(&dot_git);
9022 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
9023 state
9024 .fs
9025 .add_linked_worktree_for_repo(
9026 dot_git_path,
9027 false,
9028 git::repository::Worktree {
9029 path: worktree_pathbuf,
9030 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
9031 sha: "aaa".into(),
9032 is_main: false,
9033 is_bare: false,
9034 },
9035 )
9036 .await;
9037
9038 // Re-scan the main workspace's project so it discovers the new worktree.
9039 let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
9040 let keys = mw.project_group_keys();
9041 let key = &keys[project_group_index];
9042 mw.workspaces_for_project_group(key, cx)
9043 .and_then(|ws| ws.first().cloned())
9044 .unwrap()
9045 });
9046 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
9047 main_project
9048 .update(cx, |p, cx| p.git_scans_complete(cx))
9049 .await;
9050
9051 state.unopened_worktrees.push(UnopenedWorktree {
9052 path: worktree_path,
9053 main_workspace_path: main_path.clone(),
9054 });
9055 }
9056 Operation::AddWorktreeToProject {
9057 project_group_index,
9058 } => {
9059 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9060 let keys = mw.project_group_keys();
9061 let key = &keys[project_group_index];
9062 mw.workspaces_for_project_group(key, cx)
9063 .and_then(|ws| ws.first().cloned())
9064 });
9065 let Some(workspace) = workspace else { return };
9066 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9067
9068 let new_path = state.next_workspace_path();
9069 state
9070 .fs
9071 .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
9072 .await;
9073
9074 let result = project
9075 .update(cx, |project, cx| {
9076 project.find_or_create_worktree(&new_path, true, cx)
9077 })
9078 .await;
9079 if result.is_err() {
9080 return;
9081 }
9082 cx.run_until_parked();
9083 }
9084 Operation::RemoveWorktreeFromProject {
9085 project_group_index,
9086 } => {
9087 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9088 let keys = mw.project_group_keys();
9089 let key = &keys[project_group_index];
9090 mw.workspaces_for_project_group(key, cx)
9091 .and_then(|ws| ws.first().cloned())
9092 });
9093 let Some(workspace) = workspace else { return };
9094 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9095
9096 let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
9097 if worktree_count <= 1 {
9098 return;
9099 }
9100
9101 let worktree_id = project.read_with(cx, |p, cx| {
9102 p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
9103 });
9104 if let Some(worktree_id) = worktree_id {
9105 project.update(cx, |project, cx| {
9106 project.remove_worktree(worktree_id, cx);
9107 });
9108 cx.run_until_parked();
9109 }
9110 }
9111 }
9112 }
9113
9114 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
9115 sidebar.update_in(cx, |sidebar, _window, cx| {
9116 if let Some(mw) = sidebar.multi_workspace.upgrade() {
9117 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
9118 }
9119 sidebar.update_entries(cx);
9120 });
9121 }
9122
9123 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9124 verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
9125 verify_no_duplicate_threads(sidebar)?;
9126 verify_all_threads_are_shown(sidebar, cx)?;
9127 verify_active_state_matches_current_workspace(sidebar, cx)?;
9128 verify_all_workspaces_are_reachable(sidebar, cx)?;
9129 verify_workspace_group_key_integrity(sidebar, cx)?;
9130 Ok(())
9131 }
9132
9133 fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
9134 let mut seen: HashSet<acp::SessionId> = HashSet::default();
9135 let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
9136
9137 for entry in &sidebar.contents.entries {
9138 if let Some(session_id) = entry.session_id() {
9139 if !seen.insert(session_id.clone()) {
9140 let title = match entry {
9141 ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
9142 _ => "<unknown>".to_string(),
9143 };
9144 duplicates.push((session_id.clone(), title));
9145 }
9146 }
9147 }
9148
9149 anyhow::ensure!(
9150 duplicates.is_empty(),
9151 "threads appear more than once in sidebar: {:?}",
9152 duplicates,
9153 );
9154 Ok(())
9155 }
9156
9157 fn verify_every_group_in_multiworkspace_is_shown(
9158 sidebar: &Sidebar,
9159 cx: &App,
9160 ) -> anyhow::Result<()> {
9161 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9162 anyhow::bail!("sidebar should still have an associated multi-workspace");
9163 };
9164
9165 let mw = multi_workspace.read(cx);
9166
9167 // Every project group key in the multi-workspace that has a
9168 // non-empty path list should appear as a ProjectHeader in the
9169 // sidebar.
9170 let all_keys = mw.project_group_keys();
9171 let expected_keys: HashSet<&ProjectGroupKey> = all_keys
9172 .iter()
9173 .filter(|k| !k.path_list().paths().is_empty())
9174 .collect();
9175
9176 let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
9177 .contents
9178 .entries
9179 .iter()
9180 .filter_map(|entry| match entry {
9181 ListEntry::ProjectHeader { key, .. } => Some(key),
9182 _ => None,
9183 })
9184 .collect();
9185
9186 let missing = &expected_keys - &sidebar_keys;
9187 let stray = &sidebar_keys - &expected_keys;
9188
9189 anyhow::ensure!(
9190 missing.is_empty() && stray.is_empty(),
9191 "sidebar project groups don't match multi-workspace.\n\
9192 Only in multi-workspace (missing): {:?}\n\
9193 Only in sidebar (stray): {:?}",
9194 missing,
9195 stray,
9196 );
9197
9198 Ok(())
9199 }
9200
9201 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9202 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9203 anyhow::bail!("sidebar should still have an associated multi-workspace");
9204 };
9205 let workspaces = multi_workspace
9206 .read(cx)
9207 .workspaces()
9208 .cloned()
9209 .collect::<Vec<_>>();
9210 let thread_store = ThreadMetadataStore::global(cx);
9211
9212 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
9213 .contents
9214 .entries
9215 .iter()
9216 .filter_map(|entry| entry.session_id().cloned())
9217 .collect();
9218
9219 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
9220
9221 // Query using the same approach as the sidebar: iterate project
9222 // group keys, then do main + legacy queries per group.
9223 let mw = multi_workspace.read(cx);
9224 let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
9225 HashMap::default();
9226 for workspace in &workspaces {
9227 let key = workspace.read(cx).project_group_key(cx);
9228 workspaces_by_group
9229 .entry(key)
9230 .or_default()
9231 .push(workspace.clone());
9232 }
9233
9234 for group_key in mw.project_group_keys() {
9235 let path_list = group_key.path_list().clone();
9236 if path_list.paths().is_empty() {
9237 continue;
9238 }
9239
9240 let group_workspaces = workspaces_by_group
9241 .get(&group_key)
9242 .map(|ws| ws.as_slice())
9243 .unwrap_or_default();
9244
9245 // Main code path queries (run for all groups, even without workspaces).
9246 // Skip drafts (session_id: None) — they are not shown in the
9247 // sidebar entries.
9248 for metadata in thread_store
9249 .read(cx)
9250 .entries_for_main_worktree_path(&path_list, None)
9251 {
9252 if let Some(sid) = metadata.session_id.clone() {
9253 metadata_thread_ids.insert(sid);
9254 }
9255 }
9256 for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
9257 if let Some(sid) = metadata.session_id.clone() {
9258 metadata_thread_ids.insert(sid);
9259 }
9260 }
9261
9262 // Legacy: per-workspace queries for different root paths.
9263 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
9264 .iter()
9265 .flat_map(|ws| {
9266 ws.read(cx)
9267 .root_paths(cx)
9268 .into_iter()
9269 .map(|p| p.to_path_buf())
9270 })
9271 .collect();
9272
9273 for workspace in group_workspaces {
9274 let ws_path_list = workspace_path_list(workspace, cx);
9275 if ws_path_list != path_list {
9276 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
9277 if let Some(sid) = metadata.session_id.clone() {
9278 metadata_thread_ids.insert(sid);
9279 }
9280 }
9281 }
9282 }
9283
9284 for workspace in group_workspaces {
9285 for snapshot in root_repository_snapshots(workspace, cx) {
9286 let repo_path_list =
9287 PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
9288 if repo_path_list != path_list {
9289 continue;
9290 }
9291 for linked_worktree in snapshot.linked_worktrees() {
9292 if covered_paths.contains(&*linked_worktree.path) {
9293 continue;
9294 }
9295 let worktree_path_list =
9296 PathList::new(std::slice::from_ref(&linked_worktree.path));
9297 for metadata in thread_store
9298 .read(cx)
9299 .entries_for_path(&worktree_path_list, None)
9300 {
9301 if let Some(sid) = metadata.session_id.clone() {
9302 metadata_thread_ids.insert(sid);
9303 }
9304 }
9305 }
9306 }
9307 }
9308 }
9309
9310 anyhow::ensure!(
9311 sidebar_thread_ids == metadata_thread_ids,
9312 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
9313 sidebar_thread_ids,
9314 metadata_thread_ids,
9315 );
9316 Ok(())
9317 }
9318
9319 fn verify_active_state_matches_current_workspace(
9320 sidebar: &Sidebar,
9321 cx: &App,
9322 ) -> anyhow::Result<()> {
9323 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9324 anyhow::bail!("sidebar should still have an associated multi-workspace");
9325 };
9326
9327 let active_workspace = multi_workspace.read(cx).workspace();
9328
9329 // 1. active_entry should be Some when the panel has content.
9330 // It may be None when the panel is uninitialized (no drafts,
9331 // no threads), which is fine.
9332 // It may also temporarily point at a different workspace
9333 // when the workspace just changed and the new panel has no
9334 // content yet.
9335 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
9336 let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
9337 || panel.read(cx).active_conversation_view().is_some();
9338
9339 let Some(entry) = sidebar.active_entry.as_ref() else {
9340 if panel_has_content {
9341 anyhow::bail!("active_entry is None but panel has content (draft or thread)");
9342 }
9343 return Ok(());
9344 };
9345
9346 // If the entry workspace doesn't match the active workspace
9347 // and the panel has no content, this is a transient state that
9348 // will resolve when the panel gets content.
9349 if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
9350 return Ok(());
9351 }
9352
9353 // 2. The entry's workspace must agree with the multi-workspace's
9354 // active workspace.
9355 anyhow::ensure!(
9356 entry.workspace().entity_id() == active_workspace.entity_id(),
9357 "active_entry workspace ({:?}) != active workspace ({:?})",
9358 entry.workspace().entity_id(),
9359 active_workspace.entity_id(),
9360 );
9361
9362 // 3. The entry must match the agent panel's current state.
9363 if panel.read(cx).active_thread_id(cx).is_some() {
9364 anyhow::ensure!(
9365 matches!(entry, ActiveEntry { .. }),
9366 "panel shows a tracked draft but active_entry is {:?}",
9367 entry,
9368 );
9369 } else if let Some(thread_id) = panel
9370 .read(cx)
9371 .active_conversation_view()
9372 .map(|cv| cv.read(cx).parent_id())
9373 {
9374 anyhow::ensure!(
9375 matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
9376 "panel has thread {:?} but active_entry is {:?}",
9377 thread_id,
9378 entry,
9379 );
9380 }
9381
9382 // 4. Exactly one entry in sidebar contents must be uniquely
9383 // identified by the active_entry — unless the panel is showing
9384 // a draft, which is represented by the + button's active state
9385 // rather than a sidebar row.
9386 // TODO: Make this check more complete
9387 let is_draft = panel.read(cx).active_thread_is_draft(cx)
9388 || panel.read(cx).active_conversation_view().is_none();
9389 if is_draft {
9390 return Ok(());
9391 }
9392 let matching_count = sidebar
9393 .contents
9394 .entries
9395 .iter()
9396 .filter(|e| entry.matches_entry(e))
9397 .count();
9398 if matching_count != 1 {
9399 let thread_entries: Vec<_> = sidebar
9400 .contents
9401 .entries
9402 .iter()
9403 .filter_map(|e| match e {
9404 ListEntry::Thread(t) => Some(format!(
9405 "tid={:?} sid={:?}",
9406 t.metadata.thread_id, t.metadata.session_id
9407 )),
9408 _ => None,
9409 })
9410 .collect();
9411 let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
9412 let store_entries: Vec<_> = store
9413 .entries()
9414 .map(|m| {
9415 format!(
9416 "tid={:?} sid={:?} archived={} paths={:?}",
9417 m.thread_id,
9418 m.session_id,
9419 m.archived,
9420 m.folder_paths()
9421 )
9422 })
9423 .collect();
9424 anyhow::bail!(
9425 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
9426 entry,
9427 matching_count,
9428 thread_entries,
9429 store_entries,
9430 );
9431 }
9432
9433 Ok(())
9434 }
9435
9436 /// Every workspace in the multi-workspace should be "reachable" from
9437 /// the sidebar — meaning there is at least one entry (thread, draft,
9438 /// new-thread, or project header) that, when clicked, would activate
9439 /// that workspace.
9440 fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9441 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9442 anyhow::bail!("sidebar should still have an associated multi-workspace");
9443 };
9444
9445 let multi_workspace = multi_workspace.read(cx);
9446
9447 let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
9448 .contents
9449 .entries
9450 .iter()
9451 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9452 .map(|ws| ws.entity_id())
9453 .collect();
9454
9455 let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
9456 .workspaces()
9457 .map(|ws| ws.entity_id())
9458 .collect();
9459
9460 let unreachable = &all_workspace_ids - &reachable_workspaces;
9461
9462 anyhow::ensure!(
9463 unreachable.is_empty(),
9464 "The following workspaces are not reachable from any sidebar entry: {:?}",
9465 unreachable,
9466 );
9467
9468 Ok(())
9469 }
9470
9471 fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9472 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9473 anyhow::bail!("sidebar should still have an associated multi-workspace");
9474 };
9475 multi_workspace
9476 .read(cx)
9477 .assert_project_group_key_integrity(cx)
9478 }
9479
9480 #[gpui::property_test(config = ProptestConfig {
9481 cases: 20,
9482 ..Default::default()
9483 })]
9484 async fn test_sidebar_invariants(
9485 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
9486 raw_operations: Vec<u32>,
9487 cx: &mut TestAppContext,
9488 ) {
9489 use std::sync::atomic::{AtomicUsize, Ordering};
9490 static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
9491
9492 agent_ui::test_support::init_test(cx);
9493 cx.update(|cx| {
9494 cx.set_global(db::AppDatabase::test_new());
9495 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
9496 cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
9497 format!(
9498 "PROPTEST_THREAD_METADATA_{}",
9499 NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
9500 ),
9501 ));
9502
9503 ThreadStore::init_global(cx);
9504 ThreadMetadataStore::init_global(cx);
9505 language_model::LanguageModelRegistry::test(cx);
9506 prompt_store::init(cx);
9507
9508 // Auto-add an AgentPanel to every workspace so that implicitly
9509 // created workspaces (e.g. from thread activation) also have one.
9510 cx.observe_new(
9511 |workspace: &mut Workspace,
9512 window: Option<&mut Window>,
9513 cx: &mut gpui::Context<Workspace>| {
9514 if let Some(window) = window {
9515 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
9516 workspace.add_panel(panel, window, cx);
9517 }
9518 },
9519 )
9520 .detach();
9521 });
9522
9523 let fs = FakeFs::new(cx.executor());
9524 fs.insert_tree(
9525 "/my-project",
9526 serde_json::json!({
9527 ".git": {},
9528 "src": {},
9529 }),
9530 )
9531 .await;
9532 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
9533 let project =
9534 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
9535 .await;
9536 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9537
9538 let (multi_workspace, cx) =
9539 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9540 let sidebar = setup_sidebar(&multi_workspace, cx);
9541
9542 let mut state = TestState::new(fs);
9543 let mut executed: Vec<String> = Vec::new();
9544
9545 for &raw_op in &raw_operations {
9546 let project_group_count =
9547 multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
9548 let operation = state.generate_operation(raw_op, project_group_count);
9549 executed.push(format!("{:?}", operation));
9550 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
9551 cx.run_until_parked();
9552
9553 update_sidebar(&sidebar, cx);
9554 cx.run_until_parked();
9555
9556 let result =
9557 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
9558 if let Err(err) = result {
9559 let log = executed.join("\n ");
9560 panic!(
9561 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
9562 executed.len(),
9563 );
9564 }
9565 }
9566 }
9567}
9568
9569#[gpui::test]
9570async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
9571 cx: &mut TestAppContext,
9572 server_cx: &mut TestAppContext,
9573) {
9574 init_test(cx);
9575
9576 cx.update(|cx| {
9577 release_channel::init(semver::Version::new(0, 0, 0), cx);
9578 });
9579
9580 let app_state = cx.update(|cx| {
9581 let app_state = workspace::AppState::test(cx);
9582 workspace::init(app_state.clone(), cx);
9583 app_state
9584 });
9585
9586 // Set up the remote server side.
9587 let server_fs = FakeFs::new(server_cx.executor());
9588 server_fs
9589 .insert_tree(
9590 "/project",
9591 serde_json::json!({
9592 ".git": {},
9593 "src": { "main.rs": "fn main() {}" }
9594 }),
9595 )
9596 .await;
9597 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
9598
9599 // Create the linked worktree checkout path on the remote server,
9600 // but do not yet register it as a git-linked worktree. The real
9601 // regrouping update in this test should happen only after the
9602 // sidebar opens the closed remote thread.
9603 server_fs
9604 .insert_tree(
9605 "/project-wt-1",
9606 serde_json::json!({
9607 "src": { "main.rs": "fn main() {}" }
9608 }),
9609 )
9610 .await;
9611
9612 server_cx.update(|cx| {
9613 release_channel::init(semver::Version::new(0, 0, 0), cx);
9614 });
9615
9616 let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
9617
9618 server_cx.update(remote_server::HeadlessProject::init);
9619 let server_executor = server_cx.executor();
9620 let _headless = server_cx.new(|cx| {
9621 remote_server::HeadlessProject::new(
9622 remote_server::HeadlessAppState {
9623 session: server_session,
9624 fs: server_fs.clone(),
9625 http_client: Arc::new(http_client::BlockedHttpClient),
9626 node_runtime: node_runtime::NodeRuntime::unavailable(),
9627 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
9628 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
9629 startup_time: std::time::Instant::now(),
9630 },
9631 false,
9632 cx,
9633 )
9634 });
9635
9636 // Connect the client side and build a remote project.
9637 let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
9638 let project = cx.update(|cx| {
9639 let project_client = client::Client::new(
9640 Arc::new(clock::FakeSystemClock::new()),
9641 http_client::FakeHttpClient::with_404_response(),
9642 cx,
9643 );
9644 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
9645 project::Project::remote(
9646 remote_client,
9647 project_client,
9648 node_runtime::NodeRuntime::unavailable(),
9649 user_store,
9650 app_state.languages.clone(),
9651 app_state.fs.clone(),
9652 false,
9653 cx,
9654 )
9655 });
9656
9657 // Open the remote worktree.
9658 project
9659 .update(cx, |project, cx| {
9660 project.find_or_create_worktree(Path::new("/project"), true, cx)
9661 })
9662 .await
9663 .expect("should open remote worktree");
9664 cx.run_until_parked();
9665
9666 // Verify the project is remote.
9667 project.read_with(cx, |project, cx| {
9668 assert!(!project.is_local(), "project should be remote");
9669 assert!(
9670 project.remote_connection_options(cx).is_some(),
9671 "project should have remote connection options"
9672 );
9673 });
9674
9675 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
9676
9677 // Create MultiWorkspace with the remote project.
9678 let (multi_workspace, cx) =
9679 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9680 let sidebar = setup_sidebar(&multi_workspace, cx);
9681
9682 cx.run_until_parked();
9683
9684 // Save a thread for the main remote workspace (folder_paths match
9685 // the open workspace, so it will be classified as Open).
9686 let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
9687 save_thread_metadata(
9688 main_thread_id.clone(),
9689 Some("Main Thread".into()),
9690 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
9691 None,
9692 &project,
9693 cx,
9694 );
9695 cx.run_until_parked();
9696
9697 // Save a thread whose folder_paths point to a linked worktree path
9698 // that doesn't have an open workspace ("/project-wt-1"), but whose
9699 // main_worktree_paths match the project group key so it appears
9700 // in the sidebar under the same remote group. This simulates a
9701 // linked worktree workspace that was closed.
9702 let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
9703 let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
9704 (
9705 p.project_group_key(cx).path_list().clone(),
9706 p.remote_connection_options(cx),
9707 )
9708 });
9709 cx.update(|_window, cx| {
9710 let metadata = ThreadMetadata {
9711 thread_id: ThreadId::new(),
9712 session_id: Some(remote_thread_id.clone()),
9713 agent_id: agent::ZED_AGENT_ID.clone(),
9714 title: Some("Worktree Thread".into()),
9715 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
9716 created_at: None,
9717 worktree_paths: WorktreePaths::from_path_lists(
9718 main_worktree_paths,
9719 PathList::new(&[PathBuf::from("/project-wt-1")]),
9720 )
9721 .unwrap(),
9722 archived: false,
9723 remote_connection,
9724 };
9725 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
9726 });
9727 cx.run_until_parked();
9728
9729 focus_sidebar(&sidebar, cx);
9730 sidebar.update_in(cx, |sidebar, _window, _cx| {
9731 sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
9732 matches!(
9733 entry,
9734 ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
9735 )
9736 });
9737 });
9738
9739 let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
9740 let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
9741
9742 sidebar
9743 .update(cx, |_, cx| {
9744 cx.observe_self(move |sidebar, _cx| {
9745 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
9746 if let ListEntry::ProjectHeader { label, .. } = entry {
9747 Some(label.as_ref())
9748 } else {
9749 None
9750 }
9751 });
9752
9753 let Some(project_header) = project_headers.next() else {
9754 saw_separate_project_header_for_observer
9755 .store(true, std::sync::atomic::Ordering::SeqCst);
9756 return;
9757 };
9758
9759 if project_header != "project" || project_headers.next().is_some() {
9760 saw_separate_project_header_for_observer
9761 .store(true, std::sync::atomic::Ordering::SeqCst);
9762 }
9763 })
9764 })
9765 .detach();
9766
9767 multi_workspace.update(cx, |multi_workspace, cx| {
9768 let workspace = multi_workspace.workspace().clone();
9769 workspace.update(cx, |workspace: &mut Workspace, cx| {
9770 let remote_client = workspace
9771 .project()
9772 .read(cx)
9773 .remote_client()
9774 .expect("main remote project should have a remote client");
9775 remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
9776 remote_client.force_server_not_running(cx);
9777 });
9778 });
9779 });
9780 cx.run_until_parked();
9781
9782 let (server_session_2, connect_guard_2) =
9783 remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
9784 let _headless_2 = server_cx.new(|cx| {
9785 remote_server::HeadlessProject::new(
9786 remote_server::HeadlessAppState {
9787 session: server_session_2,
9788 fs: server_fs.clone(),
9789 http_client: Arc::new(http_client::BlockedHttpClient),
9790 node_runtime: node_runtime::NodeRuntime::unavailable(),
9791 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
9792 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
9793 startup_time: std::time::Instant::now(),
9794 },
9795 false,
9796 cx,
9797 )
9798 });
9799 drop(connect_guard_2);
9800
9801 let window = cx.windows()[0];
9802 cx.update_window(window, |_, window, cx| {
9803 window.dispatch_action(Confirm.boxed_clone(), cx);
9804 })
9805 .unwrap();
9806
9807 cx.run_until_parked();
9808
9809 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
9810 assert_eq!(
9811 mw.workspaces().count(),
9812 2,
9813 "confirming a closed remote thread should open a second workspace"
9814 );
9815 mw.workspaces()
9816 .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
9817 .unwrap()
9818 .clone()
9819 });
9820
9821 server_fs
9822 .add_linked_worktree_for_repo(
9823 Path::new("/project/.git"),
9824 true,
9825 git::repository::Worktree {
9826 path: PathBuf::from("/project-wt-1"),
9827 ref_name: Some("refs/heads/feature-wt".into()),
9828 sha: "abc123".into(),
9829 is_main: false,
9830 is_bare: false,
9831 },
9832 )
9833 .await;
9834
9835 server_cx.run_until_parked();
9836 cx.run_until_parked();
9837 server_cx.run_until_parked();
9838 cx.run_until_parked();
9839
9840 let entries_after_update = visible_entries_as_strings(&sidebar, cx);
9841 let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
9842 workspace.project().read(cx).project_group_key(cx)
9843 });
9844
9845 assert_eq!(
9846 group_after_update,
9847 project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
9848 "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
9849 final sidebar entries: {:?}",
9850 entries_after_update,
9851 );
9852
9853 sidebar.update(cx, |sidebar, _cx| {
9854 assert_remote_project_integration_sidebar_state(
9855 sidebar,
9856 &main_thread_id,
9857 &remote_thread_id,
9858 );
9859 });
9860
9861 assert!(
9862 !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
9863 "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
9864 final group: {:?}; final sidebar entries: {:?}",
9865 group_after_update,
9866 entries_after_update,
9867 );
9868}
9869
9870#[gpui::test]
9871async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
9872 // When the thread's folder_paths don't exactly match any workspace's
9873 // root paths (e.g. because a folder was added to the workspace after
9874 // the thread was created), workspace_to_remove is None. But the linked
9875 // worktree workspace still needs to be removed so that its worktree
9876 // entities are released, allowing git worktree removal to proceed.
9877 //
9878 // With the fix, archive_thread scans roots_to_archive for any linked
9879 // worktree workspaces and includes them in the removal set, even when
9880 // the thread's folder_paths don't match the workspace's root paths.
9881 init_test(cx);
9882 let fs = FakeFs::new(cx.executor());
9883
9884 fs.insert_tree(
9885 "/project",
9886 serde_json::json!({
9887 ".git": {
9888 "worktrees": {
9889 "feature-a": {
9890 "commondir": "../../",
9891 "HEAD": "ref: refs/heads/feature-a",
9892 },
9893 },
9894 },
9895 "src": {},
9896 }),
9897 )
9898 .await;
9899
9900 fs.insert_tree(
9901 "/worktrees/project/feature-a/project",
9902 serde_json::json!({
9903 ".git": "gitdir: /project/.git/worktrees/feature-a",
9904 "src": {
9905 "main.rs": "fn main() {}",
9906 },
9907 }),
9908 )
9909 .await;
9910
9911 fs.add_linked_worktree_for_repo(
9912 Path::new("/project/.git"),
9913 false,
9914 git::repository::Worktree {
9915 path: PathBuf::from("/worktrees/project/feature-a/project"),
9916 ref_name: Some("refs/heads/feature-a".into()),
9917 sha: "abc".into(),
9918 is_main: false,
9919 is_bare: false,
9920 },
9921 )
9922 .await;
9923
9924 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
9925
9926 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
9927 let worktree_project = project::Project::test(
9928 fs.clone(),
9929 ["/worktrees/project/feature-a/project".as_ref()],
9930 cx,
9931 )
9932 .await;
9933
9934 main_project
9935 .update(cx, |p, cx| p.git_scans_complete(cx))
9936 .await;
9937 worktree_project
9938 .update(cx, |p, cx| p.git_scans_complete(cx))
9939 .await;
9940
9941 let (multi_workspace, cx) =
9942 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
9943 let sidebar = setup_sidebar(&multi_workspace, cx);
9944
9945 multi_workspace.update_in(cx, |mw, window, cx| {
9946 mw.test_add_workspace(worktree_project.clone(), window, cx)
9947 });
9948
9949 // Save thread metadata using folder_paths that DON'T match the
9950 // workspace's root paths. This simulates the case where the workspace's
9951 // paths diverged (e.g. a folder was added after thread creation).
9952 // This causes workspace_to_remove to be None because
9953 // workspace_for_paths can't find a workspace with these exact paths.
9954 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
9955 save_thread_metadata_with_main_paths(
9956 "worktree-thread",
9957 "Worktree Thread",
9958 PathList::new(&[
9959 PathBuf::from("/worktrees/project/feature-a/project"),
9960 PathBuf::from("/nonexistent"),
9961 ]),
9962 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
9963 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
9964 cx,
9965 );
9966
9967 // Also save a main thread so the sidebar has something to show.
9968 save_thread_metadata(
9969 acp::SessionId::new(Arc::from("main-thread")),
9970 Some("Main Thread".into()),
9971 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
9972 None,
9973 &main_project,
9974 cx,
9975 );
9976 cx.run_until_parked();
9977
9978 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
9979 cx.run_until_parked();
9980
9981 assert_eq!(
9982 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
9983 2,
9984 "should start with 2 workspaces (main + linked worktree)"
9985 );
9986
9987 // Archive the worktree thread.
9988 sidebar.update_in(cx, |sidebar, window, cx| {
9989 sidebar.archive_thread(&wt_thread_id, window, cx);
9990 });
9991
9992 cx.run_until_parked();
9993
9994 // The linked worktree workspace should have been removed, even though
9995 // workspace_to_remove was None (paths didn't match).
9996 assert_eq!(
9997 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
9998 1,
9999 "linked worktree workspace should be removed after archiving, \
10000 even when folder_paths don't match workspace root paths"
10001 );
10002
10003 // The thread should still be archived (not unarchived due to an error).
10004 let still_archived = cx.update(|_, cx| {
10005 ThreadMetadataStore::global(cx)
10006 .read(cx)
10007 .entry_by_session(&wt_thread_id)
10008 .map(|t| t.archived)
10009 });
10010 assert_eq!(
10011 still_archived,
10012 Some(true),
10013 "thread should still be archived (not rolled back due to error)"
10014 );
10015
10016 // The linked worktree directory should be removed from disk.
10017 assert!(
10018 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10019 .await,
10020 "linked worktree directory should be removed from disk"
10021 );
10022}
10023
10024#[gpui::test]
10025async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10026 // When a workspace contains both a worktree being archived and other
10027 // worktrees that should remain, only the editor items referencing the
10028 // archived worktree should be closed — the workspace itself must be
10029 // preserved.
10030 init_test(cx);
10031 let fs = FakeFs::new(cx.executor());
10032
10033 fs.insert_tree(
10034 "/main-repo",
10035 serde_json::json!({
10036 ".git": {
10037 "worktrees": {
10038 "feature-b": {
10039 "commondir": "../../",
10040 "HEAD": "ref: refs/heads/feature-b",
10041 },
10042 },
10043 },
10044 "src": {
10045 "lib.rs": "pub fn hello() {}",
10046 },
10047 }),
10048 )
10049 .await;
10050
10051 fs.insert_tree(
10052 "/worktrees/main-repo/feature-b/main-repo",
10053 serde_json::json!({
10054 ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10055 "src": {
10056 "main.rs": "fn main() { hello(); }",
10057 },
10058 }),
10059 )
10060 .await;
10061
10062 fs.add_linked_worktree_for_repo(
10063 Path::new("/main-repo/.git"),
10064 false,
10065 git::repository::Worktree {
10066 path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10067 ref_name: Some("refs/heads/feature-b".into()),
10068 sha: "def".into(),
10069 is_main: false,
10070 is_bare: false,
10071 },
10072 )
10073 .await;
10074
10075 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10076
10077 // Create a single project that contains BOTH the main repo and the
10078 // linked worktree — this makes it a "mixed" workspace.
10079 let mixed_project = project::Project::test(
10080 fs.clone(),
10081 [
10082 "/main-repo".as_ref(),
10083 "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10084 ],
10085 cx,
10086 )
10087 .await;
10088
10089 mixed_project
10090 .update(cx, |p, cx| p.git_scans_complete(cx))
10091 .await;
10092
10093 let (multi_workspace, cx) = cx
10094 .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10095 let sidebar = setup_sidebar(&multi_workspace, cx);
10096
10097 // Open editor items in both worktrees so we can verify which ones
10098 // get closed.
10099 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10100
10101 let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10102 ws.project()
10103 .read(cx)
10104 .visible_worktrees(cx)
10105 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10106 .collect()
10107 });
10108
10109 let main_repo_wt_id = worktree_ids
10110 .iter()
10111 .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10112 .map(|(id, _)| *id)
10113 .expect("should find main-repo worktree");
10114
10115 let feature_b_wt_id = worktree_ids
10116 .iter()
10117 .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10118 .map(|(id, _)| *id)
10119 .expect("should find feature-b worktree");
10120
10121 // Open files from both worktrees.
10122 let main_repo_path = project::ProjectPath {
10123 worktree_id: main_repo_wt_id,
10124 path: Arc::from(rel_path("src/lib.rs")),
10125 };
10126 let feature_b_path = project::ProjectPath {
10127 worktree_id: feature_b_wt_id,
10128 path: Arc::from(rel_path("src/main.rs")),
10129 };
10130
10131 workspace
10132 .update_in(cx, |ws, window, cx| {
10133 ws.open_path(main_repo_path.clone(), None, true, window, cx)
10134 })
10135 .await
10136 .expect("should open main-repo file");
10137 workspace
10138 .update_in(cx, |ws, window, cx| {
10139 ws.open_path(feature_b_path.clone(), None, true, window, cx)
10140 })
10141 .await
10142 .expect("should open feature-b file");
10143
10144 cx.run_until_parked();
10145
10146 // Verify both items are open.
10147 let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10148 ws.panes()
10149 .iter()
10150 .flat_map(|pane| {
10151 pane.read(cx)
10152 .items()
10153 .filter_map(|item| item.project_path(cx))
10154 })
10155 .collect()
10156 });
10157 assert!(
10158 open_paths_before
10159 .iter()
10160 .any(|pp| pp.worktree_id == main_repo_wt_id),
10161 "main-repo file should be open"
10162 );
10163 assert!(
10164 open_paths_before
10165 .iter()
10166 .any(|pp| pp.worktree_id == feature_b_wt_id),
10167 "feature-b file should be open"
10168 );
10169
10170 // Save thread metadata for the linked worktree with deliberately
10171 // mismatched folder_paths to trigger the scan-based detection.
10172 save_thread_metadata_with_main_paths(
10173 "feature-b-thread",
10174 "Feature B Thread",
10175 PathList::new(&[
10176 PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10177 PathBuf::from("/nonexistent"),
10178 ]),
10179 PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10180 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10181 cx,
10182 );
10183
10184 // Save another thread that references only the main repo (not the
10185 // linked worktree) so archiving the feature-b thread's worktree isn't
10186 // blocked by another unarchived thread referencing the same path.
10187 save_thread_metadata_with_main_paths(
10188 "other-thread",
10189 "Other Thread",
10190 PathList::new(&[PathBuf::from("/main-repo")]),
10191 PathList::new(&[PathBuf::from("/main-repo")]),
10192 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10193 cx,
10194 );
10195 cx.run_until_parked();
10196
10197 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10198 cx.run_until_parked();
10199
10200 // There should still be exactly 1 workspace.
10201 assert_eq!(
10202 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10203 1,
10204 "should have 1 workspace (the mixed workspace)"
10205 );
10206
10207 // Archive the feature-b thread.
10208 let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10209 sidebar.update_in(cx, |sidebar, window, cx| {
10210 sidebar.archive_thread(&fb_session_id, window, cx);
10211 });
10212
10213 cx.run_until_parked();
10214
10215 // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10216 assert_eq!(
10217 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10218 1,
10219 "mixed workspace should be preserved"
10220 );
10221
10222 // Only the feature-b editor item should have been closed.
10223 let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10224 ws.panes()
10225 .iter()
10226 .flat_map(|pane| {
10227 pane.read(cx)
10228 .items()
10229 .filter_map(|item| item.project_path(cx))
10230 })
10231 .collect()
10232 });
10233 assert!(
10234 open_paths_after
10235 .iter()
10236 .any(|pp| pp.worktree_id == main_repo_wt_id),
10237 "main-repo file should still be open"
10238 );
10239 assert!(
10240 !open_paths_after
10241 .iter()
10242 .any(|pp| pp.worktree_id == feature_b_wt_id),
10243 "feature-b file should have been closed"
10244 );
10245}
10246
10247#[test]
10248fn test_worktree_info_branch_names_for_main_worktrees() {
10249 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10250 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10251
10252 let branch_by_path: HashMap<PathBuf, SharedString> =
10253 [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10254 .into_iter()
10255 .collect();
10256
10257 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10258 assert_eq!(infos.len(), 1);
10259 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10260 assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10261 assert_eq!(infos[0].name, SharedString::from("myapp"));
10262}
10263
10264#[test]
10265fn test_worktree_info_branch_names_for_linked_worktrees() {
10266 let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10267 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10268 let worktree_paths =
10269 WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10270
10271 let branch_by_path: HashMap<PathBuf, SharedString> = [(
10272 PathBuf::from("/projects/myapp-feature"),
10273 "feature-branch".into(),
10274 )]
10275 .into_iter()
10276 .collect();
10277
10278 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10279 assert_eq!(infos.len(), 1);
10280 assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10281 assert_eq!(
10282 infos[0].branch_name,
10283 Some(SharedString::from("feature-branch"))
10284 );
10285}
10286
10287#[test]
10288fn test_worktree_info_missing_branch_returns_none() {
10289 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10290 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10291
10292 let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10293
10294 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10295 assert_eq!(infos.len(), 1);
10296 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10297 assert_eq!(infos[0].branch_name, None);
10298 assert_eq!(infos[0].name, SharedString::from("myapp"));
10299}